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.
Files changed (36) hide show
  1. saas_maker/__init__.py +3 -0
  2. saas_maker/cli.py +162 -0
  3. saas_maker/color.py +46 -0
  4. saas_maker/envfiles.py +152 -0
  5. saas_maker/fetch.py +103 -0
  6. saas_maker/fields.py +383 -0
  7. saas_maker/module_gen.py +397 -0
  8. saas_maker/naming.py +152 -0
  9. saas_maker/preflight.py +37 -0
  10. saas_maker/provision.py +110 -0
  11. saas_maker/rename.py +129 -0
  12. saas_maker/scaffold.py +137 -0
  13. saas_maker/secrets_gen.py +13 -0
  14. saas_maker/stack.py +36 -0
  15. saas_maker/templates/module/backend/controller.py.j2 +130 -0
  16. saas_maker/templates/module/backend/migration.py.j2 +46 -0
  17. saas_maker/templates/module/backend/model.py.j2 +58 -0
  18. saas_maker/templates/module/backend/schemas.py.j2 +50 -0
  19. saas_maker/templates/module/backend/service.py.j2 +103 -0
  20. saas_maker/templates/module/backend/test.py.j2 +235 -0
  21. saas_maker/templates/module/frontend/badge.tsx.j2 +24 -0
  22. saas_maker/templates/module/frontend/delete_dialog.tsx.j2 +59 -0
  23. saas_maker/templates/module/frontend/form_dialog.tsx.j2 +215 -0
  24. saas_maker/templates/module/frontend/hook.ts.j2 +69 -0
  25. saas_maker/templates/module/frontend/list.tsx.j2 +221 -0
  26. saas_maker/templates/module/frontend/page.tsx.j2 +155 -0
  27. saas_maker/templates/module/frontend/page_test.tsx.j2 +112 -0
  28. saas_maker/templates/module/frontend/status.ts.j2 +9 -0
  29. saas_maker/templates/module/frontend/types.ts.j2 +46 -0
  30. saas_maker/templates/module/frontend/use_debounce.ts.j2 +13 -0
  31. saas_maker/wizard.py +227 -0
  32. saas_maker-0.1.0.dist-info/METADATA +86 -0
  33. saas_maker-0.1.0.dist-info/RECORD +36 -0
  34. saas_maker-0.1.0.dist-info/WHEEL +4 -0
  35. saas_maker-0.1.0.dist-info/entry_points.txt +2 -0
  36. saas_maker-0.1.0.dist-info/licenses/LICENSE +202 -0
saas_maker/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """SaaS Maker CLI."""
2
+
3
+ __version__ = "0.1.0"
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)