saas-maker 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.
- saas_maker/__init__.py +3 -0
- saas_maker/cli.py +162 -0
- saas_maker/color.py +46 -0
- saas_maker/envfiles.py +152 -0
- saas_maker/fetch.py +103 -0
- saas_maker/fields.py +383 -0
- saas_maker/module_gen.py +397 -0
- saas_maker/naming.py +152 -0
- saas_maker/preflight.py +37 -0
- saas_maker/provision.py +110 -0
- saas_maker/rename.py +129 -0
- saas_maker/scaffold.py +137 -0
- saas_maker/secrets_gen.py +13 -0
- saas_maker/stack.py +36 -0
- saas_maker/templates/module/backend/controller.py.j2 +130 -0
- saas_maker/templates/module/backend/migration.py.j2 +46 -0
- saas_maker/templates/module/backend/model.py.j2 +58 -0
- saas_maker/templates/module/backend/schemas.py.j2 +50 -0
- saas_maker/templates/module/backend/service.py.j2 +103 -0
- saas_maker/templates/module/backend/test.py.j2 +235 -0
- saas_maker/templates/module/frontend/badge.tsx.j2 +24 -0
- saas_maker/templates/module/frontend/delete_dialog.tsx.j2 +59 -0
- saas_maker/templates/module/frontend/form_dialog.tsx.j2 +215 -0
- saas_maker/templates/module/frontend/hook.ts.j2 +69 -0
- saas_maker/templates/module/frontend/list.tsx.j2 +221 -0
- saas_maker/templates/module/frontend/page.tsx.j2 +155 -0
- saas_maker/templates/module/frontend/page_test.tsx.j2 +112 -0
- saas_maker/templates/module/frontend/status.ts.j2 +9 -0
- saas_maker/templates/module/frontend/types.ts.j2 +46 -0
- saas_maker/templates/module/frontend/use_debounce.ts.j2 +13 -0
- saas_maker/wizard.py +227 -0
- saas_maker-0.1.0.dist-info/METADATA +86 -0
- saas_maker-0.1.0.dist-info/RECORD +36 -0
- saas_maker-0.1.0.dist-info/WHEEL +4 -0
- saas_maker-0.1.0.dist-info/entry_points.txt +2 -0
- saas_maker-0.1.0.dist-info/licenses/LICENSE +202 -0
saas_maker/__init__.py
ADDED
saas_maker/cli.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""SaaS Maker CLI — `uvx saas-maker new <name>` / `saas-maker generate module <name>`."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from saas_maker import (
|
|
8
|
+
__version__,
|
|
9
|
+
module_gen,
|
|
10
|
+
preflight,
|
|
11
|
+
scaffold,
|
|
12
|
+
stack,
|
|
13
|
+
wizard,
|
|
14
|
+
)
|
|
15
|
+
from saas_maker import (
|
|
16
|
+
fields as fields_mod,
|
|
17
|
+
)
|
|
18
|
+
from saas_maker.naming import Names, NamingError
|
|
19
|
+
|
|
20
|
+
app = typer.Typer(
|
|
21
|
+
name="saas-maker",
|
|
22
|
+
help="The SaaS Maker generator — multi-tenant SaaS (FastAPI + React), omakase.",
|
|
23
|
+
no_args_is_help=True,
|
|
24
|
+
)
|
|
25
|
+
generate_app = typer.Typer(help="Code generators (à la `rails generate`).", no_args_is_help=True)
|
|
26
|
+
app.add_typer(generate_app, name="generate")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _version_callback(value: bool) -> None:
|
|
30
|
+
if value:
|
|
31
|
+
typer.echo(f"saas-maker {__version__} (template {stack.STACK_REF})")
|
|
32
|
+
raise typer.Exit()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@app.callback()
|
|
36
|
+
def main(
|
|
37
|
+
version: bool = typer.Option(
|
|
38
|
+
False,
|
|
39
|
+
"--version",
|
|
40
|
+
callback=_version_callback,
|
|
41
|
+
is_eager=True,
|
|
42
|
+
help="Show the CLI and pinned template versions.",
|
|
43
|
+
),
|
|
44
|
+
) -> None:
|
|
45
|
+
pass
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@app.command()
|
|
49
|
+
def new(
|
|
50
|
+
name: str = typer.Argument(..., help="Project name (lowercase, dashes)"),
|
|
51
|
+
defaults: bool = typer.Option(
|
|
52
|
+
False, "--defaults", help="Skip the wizard — placeholder config, CI-friendly."
|
|
53
|
+
),
|
|
54
|
+
skip_provision: bool = typer.Option(
|
|
55
|
+
False, "--skip-provision", help="Write files only; no uv/npm/createdb/migrate."
|
|
56
|
+
),
|
|
57
|
+
ref: str = typer.Option(
|
|
58
|
+
stack.STACK_REF, "--ref", help="Template tag/branch to scaffold (default: pinned)."
|
|
59
|
+
),
|
|
60
|
+
source: Path | None = typer.Option(
|
|
61
|
+
None, "--source", help="Local saas-maker checkout to copy instead of downloading (dev)."
|
|
62
|
+
),
|
|
63
|
+
output: Path | None = typer.Option(
|
|
64
|
+
None, "--output", help="Parent directory for the project (default: current directory)."
|
|
65
|
+
),
|
|
66
|
+
) -> None:
|
|
67
|
+
"""Scaffold a configured SaaS project: wizard → branding → .envs → provision → git."""
|
|
68
|
+
try:
|
|
69
|
+
slug = scaffold.validate_name(name)
|
|
70
|
+
|
|
71
|
+
typer.echo("🔎 Preflight:")
|
|
72
|
+
for check in preflight.run_checks():
|
|
73
|
+
mark = "✅" if check.found else "⚠️ "
|
|
74
|
+
hint = "" if check.found else f" ({check.hint})"
|
|
75
|
+
typer.echo(f" {mark} {check.name}: {check.detail}{hint}")
|
|
76
|
+
typer.echo("")
|
|
77
|
+
|
|
78
|
+
answers = wizard.default_answers(slug) if defaults else wizard.run_wizard(slug)
|
|
79
|
+
scaffold.run_new(
|
|
80
|
+
answers,
|
|
81
|
+
target_parent=(output or Path.cwd()).expanduser().resolve(),
|
|
82
|
+
ref=ref,
|
|
83
|
+
source=source,
|
|
84
|
+
skip_provision=skip_provision,
|
|
85
|
+
echo=typer.echo,
|
|
86
|
+
)
|
|
87
|
+
typer.echo("")
|
|
88
|
+
typer.echo("Teach your coding agent to extend this project:")
|
|
89
|
+
typer.echo(" npx skills add SaaS-Maker-Stack/skills --skill '*'")
|
|
90
|
+
except Exception as exc:
|
|
91
|
+
if isinstance(exc, typer.Exit):
|
|
92
|
+
raise
|
|
93
|
+
typer.secho(f"error: {exc}", fg=typer.colors.RED, err=True)
|
|
94
|
+
raise typer.Exit(code=1) from exc
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@generate_app.command("module")
|
|
98
|
+
def gen_module(
|
|
99
|
+
name: str = typer.Argument(..., help="Module name, snake_case singular (invoice, work_order)"),
|
|
100
|
+
fields: str | None = typer.Option(
|
|
101
|
+
None,
|
|
102
|
+
"--fields",
|
|
103
|
+
help="name:kind[?][:Label],… — kinds: str text int float bool date datetime. "
|
|
104
|
+
"First field is the title. Default: " + fields_mod.DEFAULT_FIELDS,
|
|
105
|
+
),
|
|
106
|
+
status: str | None = typer.Option(
|
|
107
|
+
None, "--status", help="value=Label,… adds a status column, filter and badge."
|
|
108
|
+
),
|
|
109
|
+
label: str | None = typer.Option(None, "--label", help="Spanish singular label (Factura)."),
|
|
110
|
+
label_plural: str | None = typer.Option(None, "--label-plural", help="Spanish plural label."),
|
|
111
|
+
feminine: bool = typer.Option(False, "--feminine", help='Feminine noun ("Nueva factura").'),
|
|
112
|
+
plural: str | None = typer.Option(None, "--plural", help="snake_case plural override."),
|
|
113
|
+
icon: str = typer.Option("FolderKanban", "--icon", help="lucide-react icon for the sidebar."),
|
|
114
|
+
defaults: bool = typer.Option(False, "--defaults", help="No prompts; use flags/defaults."),
|
|
115
|
+
backend_only: bool = typer.Option(False, "--backend-only"),
|
|
116
|
+
frontend_only: bool = typer.Option(False, "--frontend-only"),
|
|
117
|
+
) -> None:
|
|
118
|
+
"""Scaffold a tenant-scoped CRUD (table, API, page, tests) inside a saas-maker project."""
|
|
119
|
+
try:
|
|
120
|
+
if fields is None and not defaults:
|
|
121
|
+
answers = wizard.run_module_wizard(name, fields_mod.DEFAULT_FIELDS)
|
|
122
|
+
label = label or answers["label"]
|
|
123
|
+
label_plural = label_plural or answers["label_plural"]
|
|
124
|
+
feminine = feminine or answers["feminine"]
|
|
125
|
+
fields = answers["fields"]
|
|
126
|
+
status = status or answers["status"]
|
|
127
|
+
names = Names.build(
|
|
128
|
+
name,
|
|
129
|
+
plural=plural,
|
|
130
|
+
label=label,
|
|
131
|
+
label_plural=label_plural,
|
|
132
|
+
feminine=feminine,
|
|
133
|
+
icon=icon,
|
|
134
|
+
)
|
|
135
|
+
parsed_fields = fields_mod.parse_fields(fields or fields_mod.DEFAULT_FIELDS)
|
|
136
|
+
parsed_status = fields_mod.parse_status(status)
|
|
137
|
+
written = module_gen.generate(
|
|
138
|
+
names,
|
|
139
|
+
parsed_fields,
|
|
140
|
+
parsed_status,
|
|
141
|
+
cwd=Path.cwd(),
|
|
142
|
+
backend=not frontend_only,
|
|
143
|
+
frontend=not backend_only,
|
|
144
|
+
echo=typer.echo,
|
|
145
|
+
)
|
|
146
|
+
except (module_gen.ModuleGenError, fields_mod.FieldError, NamingError) as exc:
|
|
147
|
+
typer.secho(f"error: {exc}", fg=typer.colors.RED, err=True)
|
|
148
|
+
raise typer.Exit(code=1) from exc
|
|
149
|
+
|
|
150
|
+
project = module_gen.detect_project(Path.cwd())
|
|
151
|
+
for path in written:
|
|
152
|
+
typer.echo(f" write {path.relative_to(project)}")
|
|
153
|
+
typer.echo("\nNext steps:")
|
|
154
|
+
if not frontend_only:
|
|
155
|
+
typer.echo(" cd backend && make migrate && make test && make lint")
|
|
156
|
+
if not backend_only:
|
|
157
|
+
typer.echo(" cd frontend && npm run lint && npm run typecheck && npm test")
|
|
158
|
+
typer.echo(f" document {names.api_prefix} in backend/CLAUDE.md and frontend/CLAUDE.md")
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
if __name__ == "__main__":
|
|
162
|
+
app()
|
saas_maker/color.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Hex color -> OKLCH hue, so the wizard accepts `#0066ff` as well as `262`.
|
|
2
|
+
|
|
3
|
+
The template derives its whole accent palette from one OKLCH hue angle
|
|
4
|
+
(`--brand-hue`). sRGB -> linear -> OKLab (Björn Ottosson's matrices) -> hue.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import math
|
|
8
|
+
import re
|
|
9
|
+
|
|
10
|
+
HEX_RE = re.compile(r"^#?([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _srgb_to_linear(c: float) -> float:
|
|
14
|
+
return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def hex_to_hue(value: str) -> int:
|
|
18
|
+
"""`#0066ff` -> 262. Raises ValueError for anything that is not a hex color."""
|
|
19
|
+
match = HEX_RE.match(value.strip())
|
|
20
|
+
if not match:
|
|
21
|
+
raise ValueError(f"'{value}' is not a hex color")
|
|
22
|
+
digits = match.group(1)
|
|
23
|
+
if len(digits) == 3:
|
|
24
|
+
digits = "".join(ch * 2 for ch in digits)
|
|
25
|
+
r, g, b = (_srgb_to_linear(int(digits[i : i + 2], 16) / 255) for i in (0, 2, 4))
|
|
26
|
+
|
|
27
|
+
l_ = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b
|
|
28
|
+
m_ = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b
|
|
29
|
+
s_ = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b
|
|
30
|
+
l_, m_, s_ = (math.copysign(abs(x) ** (1 / 3), x) for x in (l_, m_, s_))
|
|
31
|
+
|
|
32
|
+
a = 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_
|
|
33
|
+
b_ = 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_
|
|
34
|
+
if math.hypot(a, b_) < 0.01:
|
|
35
|
+
raise ValueError(f"'{value}' is a gray — it has no hue; pick a chromatic color")
|
|
36
|
+
return round(math.degrees(math.atan2(b_, a))) % 360
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def parse_brand(value: str) -> int:
|
|
40
|
+
"""Accepts a hue angle (0-360) or a hex color."""
|
|
41
|
+
text = value.strip()
|
|
42
|
+
if text.isdigit(): # a bare number is an angle; hex colors need the '#'
|
|
43
|
+
if int(text) > 360:
|
|
44
|
+
raise ValueError("hue angle must be between 0 and 360 (prefix hex colors with #)")
|
|
45
|
+
return int(text)
|
|
46
|
+
return hex_to_hue(text)
|
saas_maker/envfiles.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Pure renderers: answers -> `.env` / `.kamal/secrets` / README text.
|
|
2
|
+
|
|
3
|
+
Everything here is golden-testable: no I/O, no randomness (secrets come in).
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from saas_maker.secrets_gen import GeneratedSecrets
|
|
7
|
+
from saas_maker.wizard import Answers
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def render_backend_env(a: Answers, s: GeneratedSecrets) -> str:
|
|
11
|
+
"""Local development `.env` for the backend (Mailpit for mail)."""
|
|
12
|
+
return f"""# App
|
|
13
|
+
APP_NAME={a.name}
|
|
14
|
+
DEBUG=true
|
|
15
|
+
|
|
16
|
+
# Database
|
|
17
|
+
POSTGRES_HOST={a.pg_host}
|
|
18
|
+
POSTGRES_PORT={a.pg_port}
|
|
19
|
+
POSTGRES_USER={a.pg_user}
|
|
20
|
+
POSTGRES_PASSWORD={a.pg_password}
|
|
21
|
+
POSTGRES_DB={a.db_name}
|
|
22
|
+
|
|
23
|
+
# JWT Authentication (generated by saas-maker; rotate with: openssl rand -hex 32)
|
|
24
|
+
JWT_SECRET_KEY={s.jwt_secret}
|
|
25
|
+
JWT_ALGORITHM=HS256
|
|
26
|
+
ACCESS_TOKEN_EXPIRE_MINUTES=30
|
|
27
|
+
REFRESH_TOKEN_EXPIRE_DAYS=7
|
|
28
|
+
|
|
29
|
+
# Invitation System
|
|
30
|
+
INVITE_TOKEN_EXPIRE_DAYS=7
|
|
31
|
+
FRONTEND_URL=http://localhost:5190
|
|
32
|
+
|
|
33
|
+
# Email verification
|
|
34
|
+
EMAIL_VERIFICATION_TOKEN_EXPIRE_HOURS=48
|
|
35
|
+
REQUIRE_EMAIL_VERIFICATION=false
|
|
36
|
+
|
|
37
|
+
# Rate limiting on auth endpoints
|
|
38
|
+
RATE_LIMIT_ENABLED=true
|
|
39
|
+
RATE_LIMIT_AUTH=10/minute
|
|
40
|
+
|
|
41
|
+
# CORS
|
|
42
|
+
CORS_ORIGINS=http://localhost:5190,http://localhost:5191,http://localhost:3000
|
|
43
|
+
CORS_ALLOW_CREDENTIALS=true
|
|
44
|
+
|
|
45
|
+
# Email SMTP Configuration (local: Mailpit via docker compose, UI on :8025)
|
|
46
|
+
SMTP_HOST=localhost
|
|
47
|
+
SMTP_PORT=1025
|
|
48
|
+
SMTP_USER=
|
|
49
|
+
SMTP_PASSWORD=
|
|
50
|
+
SMTP_FROM_EMAIL={a.smtp_from}
|
|
51
|
+
SMTP_FROM_NAME={a.name}
|
|
52
|
+
SMTP_TLS=false
|
|
53
|
+
SMTP_SSL=false
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def render_vite_env() -> str:
|
|
58
|
+
return "# API Base URL\nVITE_API_BASE_URL=http://localhost:8090\n"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def render_backend_secrets(a: Answers, s: GeneratedSecrets) -> str:
|
|
62
|
+
"""`backend/.kamal/secrets` — production values for Kamal (gitignored)."""
|
|
63
|
+
return f"""# Docker Registry
|
|
64
|
+
DOCKER_REGISTRY_PASSWORD={a.docker_password}
|
|
65
|
+
|
|
66
|
+
# Database
|
|
67
|
+
POSTGRES_USER=postgres
|
|
68
|
+
POSTGRES_PASSWORD={a.pg_password}
|
|
69
|
+
POSTGRES_DB={a.db_name}
|
|
70
|
+
|
|
71
|
+
# JWT
|
|
72
|
+
JWT_SECRET_KEY={s.jwt_secret}
|
|
73
|
+
|
|
74
|
+
# Frontend URL (for invitation links)
|
|
75
|
+
FRONTEND_URL=https://{a.app_domain}
|
|
76
|
+
|
|
77
|
+
# SMTP Configuration
|
|
78
|
+
SMTP_HOST={a.smtp_host}
|
|
79
|
+
SMTP_PORT={a.smtp_port}
|
|
80
|
+
SMTP_USER={a.smtp_user}
|
|
81
|
+
SMTP_PASSWORD={a.smtp_password}
|
|
82
|
+
SMTP_FROM_EMAIL={a.smtp_from}
|
|
83
|
+
SMTP_FROM_NAME={a.name}
|
|
84
|
+
SMTP_TLS=true
|
|
85
|
+
SMTP_SSL=false
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def render_registry_secrets(a: Answers) -> str:
|
|
90
|
+
"""`frontend/.kamal/secrets` and `admin/.kamal/secrets`."""
|
|
91
|
+
return f"# Docker Registry\nDOCKER_REGISTRY_PASSWORD={a.docker_password}\n"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def render_readme(a: Answers, ref: str) -> str:
|
|
95
|
+
return f"""# {a.name}
|
|
96
|
+
|
|
97
|
+
Multi-tenant SaaS generated with [saas-maker](https://github.com/SaaS-Maker-Stack/saas-maker)
|
|
98
|
+
(`saas-maker new`, template `{ref}`).
|
|
99
|
+
|
|
100
|
+
| Path | Service | Run |
|
|
101
|
+
|------|---------|-----|
|
|
102
|
+
| `backend/` | FastAPI + SQLModel + PostgreSQL (API) | `make dev` (:8090) |
|
|
103
|
+
| `frontend/` | React + Vite — the tenant app | `npm run dev` (:5190) |
|
|
104
|
+
| `admin/` | React + Vite — platform admin panel | `npm run dev` (:5191) |
|
|
105
|
+
|
|
106
|
+
Each service is its own git repository, deployed independently with Kamal
|
|
107
|
+
(`config/deploy.yml`, secrets in `.kamal/secrets`). `DESIGN.md` is the design
|
|
108
|
+
system; `CLAUDE.md` files guide coding agents.
|
|
109
|
+
|
|
110
|
+
## Development
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
cd backend && make install && make migrate && make dev # API on :8090 (docs at /docs)
|
|
114
|
+
cd frontend && npm install && npm run dev # app on :5190
|
|
115
|
+
cd admin && npm install && npm run dev # admin on :5191
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Local mail goes to Mailpit (`docker compose up -d` starts Postgres + Mailpit,
|
|
119
|
+
UI on http://localhost:8025). Configuration lives in each service's `.env`.
|
|
120
|
+
|
|
121
|
+
## Quality
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
cd backend && make lint && make test && make audit
|
|
125
|
+
cd frontend && npm run lint && npm run typecheck && npm test && npm run audit
|
|
126
|
+
cd admin && npm run lint && npm run typecheck && npm test && npm run audit
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
## Adding a module
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
uvx saas-maker generate module invoice --label Factura --feminine \\
|
|
133
|
+
--fields "number:str:Número,amount:float:Monto" --status "draft=Borrador,paid=Pagada"
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Generates the tenant-scoped table, API, page, sidebar entry and tests on both
|
|
137
|
+
sides. Review the migration, then `cd backend && make migrate`.
|
|
138
|
+
|
|
139
|
+
## Production
|
|
140
|
+
|
|
141
|
+
| Service | URL |
|
|
142
|
+
|---------|-----|
|
|
143
|
+
| App | https://{a.app_domain} |
|
|
144
|
+
| Admin | https://{a.admin_domain} |
|
|
145
|
+
| API | https://{a.api_domain} |
|
|
146
|
+
|
|
147
|
+
```bash
|
|
148
|
+
cd backend && kamal deploy
|
|
149
|
+
cd frontend && kamal deploy
|
|
150
|
+
cd admin && kamal deploy
|
|
151
|
+
```
|
|
152
|
+
"""
|
saas_maker/fetch.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Degit-style fetch: GitHub tarballs at a pinned ref, no git history.
|
|
2
|
+
|
|
3
|
+
`--source <path>` swaps the download for a local copy of a saas-maker checkout
|
|
4
|
+
(the dev/test escape hatch).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import io
|
|
8
|
+
import shutil
|
|
9
|
+
import tarfile
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
|
|
14
|
+
from saas_maker import stack
|
|
15
|
+
|
|
16
|
+
# Never copy these from a local checkout (dev artifacts / secrets).
|
|
17
|
+
_COPY_IGNORE = shutil.ignore_patterns(
|
|
18
|
+
".git",
|
|
19
|
+
".venv",
|
|
20
|
+
"node_modules",
|
|
21
|
+
"__pycache__",
|
|
22
|
+
"*.pyc",
|
|
23
|
+
".pytest_cache",
|
|
24
|
+
".ruff_cache",
|
|
25
|
+
".mypy_cache",
|
|
26
|
+
"dist",
|
|
27
|
+
"build",
|
|
28
|
+
".env",
|
|
29
|
+
".claude",
|
|
30
|
+
".DS_Store",
|
|
31
|
+
"secrets", # .kamal/secrets — regenerated per project
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class FetchError(RuntimeError):
|
|
36
|
+
pass
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _download_tarball(repo: str, ref: str) -> bytes:
|
|
40
|
+
url = stack.CODELOAD_URL.format(org=stack.ORG, repo=repo, ref=ref)
|
|
41
|
+
try:
|
|
42
|
+
resp = httpx.get(url, follow_redirects=True, timeout=60)
|
|
43
|
+
resp.raise_for_status()
|
|
44
|
+
except httpx.HTTPError as exc:
|
|
45
|
+
raise FetchError(f"Could not download {stack.ORG}/{repo}@{ref}: {exc}") from exc
|
|
46
|
+
return resp.content
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _extract_into(tar_bytes: bytes, dest: Path, only: list[str] | None = None) -> None:
|
|
50
|
+
"""Extract a GitHub tarball into dest, stripping the top-level dir."""
|
|
51
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
52
|
+
with tarfile.open(fileobj=io.BytesIO(tar_bytes), mode="r:gz") as tar:
|
|
53
|
+
for member in tar.getmembers():
|
|
54
|
+
parts = member.name.split("/", 1)
|
|
55
|
+
if len(parts) < 2 or not parts[1]:
|
|
56
|
+
continue
|
|
57
|
+
rel = parts[1]
|
|
58
|
+
if only is not None and rel not in only:
|
|
59
|
+
continue
|
|
60
|
+
if member.isdir():
|
|
61
|
+
(dest / rel).mkdir(parents=True, exist_ok=True)
|
|
62
|
+
continue
|
|
63
|
+
if member.issym():
|
|
64
|
+
if "/" not in member.linkname and ".." not in member.linkname:
|
|
65
|
+
target = dest / rel
|
|
66
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
67
|
+
target.unlink(missing_ok=True)
|
|
68
|
+
target.symlink_to(member.linkname)
|
|
69
|
+
continue
|
|
70
|
+
if not member.isfile():
|
|
71
|
+
continue
|
|
72
|
+
target = dest / rel
|
|
73
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
74
|
+
extracted = tar.extractfile(member)
|
|
75
|
+
if extracted is None:
|
|
76
|
+
continue
|
|
77
|
+
target.write_bytes(extracted.read())
|
|
78
|
+
target.chmod(member.mode)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def fetch_stack(dest: Path, ref: str, source: Path | None = None) -> None:
|
|
82
|
+
"""Lay backend + frontend + admin + the parent's root files under dest."""
|
|
83
|
+
if source is not None:
|
|
84
|
+
_copy_local(source, dest)
|
|
85
|
+
return
|
|
86
|
+
for dirname, repo in stack.SERVICES.items():
|
|
87
|
+
_extract_into(_download_tarball(repo, ref), dest / dirname)
|
|
88
|
+
_extract_into(_download_tarball(stack.PARENT_REPO, ref), dest, only=stack.PARENT_ROOT_FILES)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _copy_local(source: Path, dest: Path) -> None:
|
|
92
|
+
source = source.expanduser().resolve()
|
|
93
|
+
for dirname in stack.SERVICES:
|
|
94
|
+
src_dir = source / dirname
|
|
95
|
+
if not src_dir.is_dir():
|
|
96
|
+
raise FetchError(f"--source {source} has no {dirname}/ directory")
|
|
97
|
+
shutil.copytree(src_dir, dest / dirname, ignore=_COPY_IGNORE)
|
|
98
|
+
for root_file in stack.PARENT_ROOT_FILES:
|
|
99
|
+
src_file = source / root_file
|
|
100
|
+
if src_file.is_file():
|
|
101
|
+
target = dest / root_file
|
|
102
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
103
|
+
shutil.copy2(src_file, target)
|