newstack 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 (72) hide show
  1. newstack/__init__.py +4 -0
  2. newstack/__main__.py +8 -0
  3. newstack/cli.py +387 -0
  4. newstack/data/stacks/fastapi/stack.json +42 -0
  5. newstack/data/stacks/fastapi/template/.github/workflows/ci.yml +30 -0
  6. newstack/data/stacks/fastapi/template/.gitignore +11 -0
  7. newstack/data/stacks/fastapi/template/Dockerfile +13 -0
  8. newstack/data/stacks/fastapi/template/LICENSE +21 -0
  9. newstack/data/stacks/fastapi/template/README.md +30 -0
  10. newstack/data/stacks/fastapi/template/pyproject.toml +31 -0
  11. newstack/data/stacks/fastapi/template/src/{{package_name}}/__init__.py +4 -0
  12. newstack/data/stacks/fastapi/template/src/{{package_name}}/main.py +50 -0
  13. newstack/data/stacks/fastapi/template/tests/test_api.py +41 -0
  14. newstack/data/stacks/node-express-ts/stack.json +14 -0
  15. newstack/data/stacks/node-express-ts/template/.github/workflows/ci.yml +28 -0
  16. newstack/data/stacks/node-express-ts/template/.gitignore +6 -0
  17. newstack/data/stacks/node-express-ts/template/LICENSE +21 -0
  18. newstack/data/stacks/node-express-ts/template/README.md +24 -0
  19. newstack/data/stacks/node-express-ts/template/package.json +26 -0
  20. newstack/data/stacks/node-express-ts/template/src/app.ts +35 -0
  21. newstack/data/stacks/node-express-ts/template/src/server.ts +7 -0
  22. newstack/data/stacks/node-express-ts/template/tests/app.test.ts +32 -0
  23. newstack/data/stacks/node-express-ts/template/tsconfig.json +14 -0
  24. newstack/data/stacks/python-cli/stack.json +50 -0
  25. newstack/data/stacks/python-cli/template/.github/workflows/ci.yml +32 -0
  26. newstack/data/stacks/python-cli/template/.gitignore +11 -0
  27. newstack/data/stacks/python-cli/template/LICENSE +21 -0
  28. newstack/data/stacks/python-cli/template/README.md +35 -0
  29. newstack/data/stacks/python-cli/template/pyproject.toml +32 -0
  30. newstack/data/stacks/python-cli/template/src/{{package_name}}/__init__.py +4 -0
  31. newstack/data/stacks/python-cli/template/src/{{package_name}}/__main__.py +6 -0
  32. newstack/data/stacks/python-cli/template/src/{{package_name}}/cli.py +30 -0
  33. newstack/data/stacks/python-cli/template/tests/test_cli.py +20 -0
  34. newstack/data/stacks/python-lib/stack.json +42 -0
  35. newstack/data/stacks/python-lib/template/.github/workflows/ci.yml +32 -0
  36. newstack/data/stacks/python-lib/template/.gitignore +11 -0
  37. newstack/data/stacks/python-lib/template/LICENSE +21 -0
  38. newstack/data/stacks/python-lib/template/README.md +28 -0
  39. newstack/data/stacks/python-lib/template/pyproject.toml +34 -0
  40. newstack/data/stacks/python-lib/template/src/{{package_name}}/__init__.py +6 -0
  41. newstack/data/stacks/python-lib/template/src/{{package_name}}/core.py +23 -0
  42. newstack/data/stacks/python-lib/template/tests/test_core.py +31 -0
  43. newstack/data/stacks/react-vite-ts/stack.json +15 -0
  44. newstack/data/stacks/react-vite-ts/template/.github/workflows/ci.yml +29 -0
  45. newstack/data/stacks/react-vite-ts/template/.gitignore +7 -0
  46. newstack/data/stacks/react-vite-ts/template/LICENSE +21 -0
  47. newstack/data/stacks/react-vite-ts/template/README.md +25 -0
  48. newstack/data/stacks/react-vite-ts/template/index.html +12 -0
  49. newstack/data/stacks/react-vite-ts/template/package.json +28 -0
  50. newstack/data/stacks/react-vite-ts/template/src/App.test.tsx +20 -0
  51. newstack/data/stacks/react-vite-ts/template/src/App.tsx +15 -0
  52. newstack/data/stacks/react-vite-ts/template/src/main.tsx +16 -0
  53. newstack/data/stacks/react-vite-ts/template/src/styles.css +26 -0
  54. newstack/data/stacks/react-vite-ts/template/tsconfig.json +16 -0
  55. newstack/data/stacks/react-vite-ts/template/vite.config.ts +10 -0
  56. newstack/data/stacks/static-site/stack.json +14 -0
  57. newstack/data/stacks/static-site/template/.github/workflows/deploy-pages.yml +30 -0
  58. newstack/data/stacks/static-site/template/.gitignore +2 -0
  59. newstack/data/stacks/static-site/template/LICENSE +21 -0
  60. newstack/data/stacks/static-site/template/README.md +21 -0
  61. newstack/data/stacks/static-site/template/index.html +34 -0
  62. newstack/data/stacks/static-site/template/main.js +15 -0
  63. newstack/data/stacks/static-site/template/styles.css +74 -0
  64. newstack/data/stacks/static-site/template/tests/site.test.mjs +22 -0
  65. newstack/registry.py +137 -0
  66. newstack/render.py +163 -0
  67. newstack/verify.py +133 -0
  68. newstack-0.1.0.dist-info/METADATA +197 -0
  69. newstack-0.1.0.dist-info/RECORD +72 -0
  70. newstack-0.1.0.dist-info/WHEEL +4 -0
  71. newstack-0.1.0.dist-info/entry_points.txt +2 -0
  72. newstack-0.1.0.dist-info/licenses/LICENSE +21 -0
newstack/registry.py ADDED
@@ -0,0 +1,137 @@
1
+ """Stack discovery: every stack is a directory with a ``stack.json`` manifest."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+
10
+ BUILTIN_ROOT = Path(__file__).resolve().parent / "data" / "stacks"
11
+ ENV_VAR = "NEWSTACK_STACKS"
12
+
13
+
14
+ class RegistryError(Exception):
15
+ """A stack manifest is missing or malformed."""
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class VerifyStep:
20
+ """One command that proves a generated project works."""
21
+
22
+ name: str
23
+ cmd: str
24
+ tools: tuple[str, ...] = ()
25
+ timeout: int = 600
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class Stack:
30
+ name: str
31
+ title: str
32
+ language: str
33
+ blurb: str
34
+ requires: tuple[str, ...]
35
+ verify: tuple[VerifyStep, ...]
36
+ default_description: str
37
+ next_steps: tuple[str, ...]
38
+ directory: Path
39
+
40
+ @property
41
+ def template_dir(self) -> Path:
42
+ return self.directory / "template"
43
+
44
+ @property
45
+ def docs_url(self) -> str:
46
+ return f"https://github.com/DawnofGenX/newstack/tree/main/src/newstack/data/stacks/{self.name}"
47
+
48
+
49
+ def _as_tuple(value: object, field: str, where: Path) -> tuple[str, ...]:
50
+ if value is None:
51
+ return ()
52
+ if not isinstance(value, list) or not all(isinstance(item, str) for item in value):
53
+ raise RegistryError(f"{where}: '{field}' must be a list of strings")
54
+ return tuple(value)
55
+
56
+
57
+ def load_stack(directory: Path) -> Stack:
58
+ manifest = directory / "stack.json"
59
+ if not manifest.is_file():
60
+ raise RegistryError(f"{directory}: missing stack.json")
61
+ try:
62
+ data = json.loads(manifest.read_text(encoding="utf-8"))
63
+ except json.JSONDecodeError as exc:
64
+ raise RegistryError(f"{manifest}: invalid JSON ({exc})") from exc
65
+ if not isinstance(data, dict):
66
+ raise RegistryError(f"{manifest}: top level must be an object")
67
+
68
+ for key in ("name", "title", "language", "blurb"):
69
+ if not isinstance(data.get(key), str) or not data[key].strip():
70
+ raise RegistryError(f"{manifest}: '{key}' must be a non-empty string")
71
+
72
+ if data["name"] != directory.name:
73
+ raise RegistryError(
74
+ f"{manifest}: name '{data['name']}' must match directory '{directory.name}'"
75
+ )
76
+
77
+ raw_verify = data.get("verify", [])
78
+ if not isinstance(raw_verify, list):
79
+ raise RegistryError(f"{manifest}: 'verify' must be a list")
80
+ steps: list[VerifyStep] = []
81
+ for index, item in enumerate(raw_verify):
82
+ if not isinstance(item, dict) or not isinstance(item.get("cmd"), str):
83
+ raise RegistryError(f"{manifest}: verify[{index}] needs a 'cmd' string")
84
+ steps.append(
85
+ VerifyStep(
86
+ name=str(item.get("name") or item["cmd"].split()[0]),
87
+ cmd=item["cmd"],
88
+ tools=_as_tuple(item.get("tools"), f"verify[{index}].tools", manifest),
89
+ timeout=int(item.get("timeout", 600)),
90
+ )
91
+ )
92
+
93
+ if not (directory / "template").is_dir():
94
+ raise RegistryError(f"{directory}: missing template/ directory")
95
+
96
+ return Stack(
97
+ name=data["name"],
98
+ title=data["title"],
99
+ language=data["language"],
100
+ blurb=data["blurb"],
101
+ requires=_as_tuple(data.get("requires"), "requires", manifest),
102
+ verify=tuple(steps),
103
+ default_description=str(data.get("default_description") or f"A {data['title']} project."),
104
+ next_steps=_as_tuple(data.get("next_steps"), "next_steps", manifest),
105
+ directory=directory,
106
+ )
107
+
108
+
109
+ def search_roots() -> list[Path]:
110
+ """Built-in stacks first; ``NEWSTACK_STACKS`` entries may override them by name."""
111
+ roots = [BUILTIN_ROOT]
112
+ extra = os.environ.get(ENV_VAR, "")
113
+ for entry in extra.split(os.pathsep):
114
+ if entry.strip():
115
+ roots.append(Path(entry).expanduser())
116
+ return roots
117
+
118
+
119
+ def load_stacks() -> dict[str, Stack]:
120
+ stacks: dict[str, Stack] = {}
121
+ for root in search_roots():
122
+ if not root.is_dir():
123
+ continue
124
+ for directory in sorted(p for p in root.iterdir() if p.is_dir()):
125
+ if (directory / "stack.json").is_file():
126
+ stack = load_stack(directory)
127
+ stacks[stack.name] = stack
128
+ return dict(sorted(stacks.items()))
129
+
130
+
131
+ def get_stack(name: str) -> Stack:
132
+ stacks = load_stacks()
133
+ try:
134
+ return stacks[name]
135
+ except KeyError:
136
+ available = ", ".join(stacks) or "(none found)"
137
+ raise RegistryError(f"unknown stack '{name}' — available: {available}") from None
newstack/render.py ADDED
@@ -0,0 +1,163 @@
1
+ """Template rendering and file generation.
2
+
3
+ Deliberately dependency-free: templates are plain files with ``{{ variable }}``
4
+ placeholders, rendered by a regex substitution. No Jinja, no JS runtime, so
5
+ ``uvx newstack`` starts in well under a second and works offline.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import re
12
+ from dataclasses import dataclass, field
13
+ from pathlib import Path
14
+
15
+ VAR_RE = re.compile(r"\{\{\s*([A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*)\s*\}\}")
16
+
17
+
18
+ class TemplateError(Exception):
19
+ """A template could not be rendered or written."""
20
+
21
+
22
+ _MISSING = object()
23
+
24
+
25
+ def _lookup(context: dict, dotted: str) -> object:
26
+ value: object = context
27
+ for part in dotted.split("."):
28
+ if isinstance(value, dict) and part in value:
29
+ value = value[part]
30
+ else:
31
+ return _MISSING
32
+ return value
33
+
34
+
35
+ def render_string(text: str, context: dict, *, missing: list[str] | None = None) -> str:
36
+ """Replace every ``{{ key }}`` in *text* using *context* (dotted keys allowed).
37
+
38
+ Two escapes keep templating out of the way of the languages we generate:
39
+
40
+ * ``${{ ... }}`` is left untouched, so GitHub Actions expressions pass through.
41
+ * ``{{!`` emits a literal ``{{``.
42
+ """
43
+
44
+ def _sub(match: re.Match[str]) -> str:
45
+ if match.start() and text[match.start() - 1] == "$":
46
+ return match.group(0)
47
+ value = _lookup(context, match.group(1))
48
+ if value is _MISSING:
49
+ if missing is not None:
50
+ missing.append(match.group(1))
51
+ return match.group(0)
52
+ return "" if value is None else str(value)
53
+
54
+ return VAR_RE.sub(_sub, text).replace("{{!", "{{")
55
+
56
+
57
+ @dataclass
58
+ class GenerateResult:
59
+ root: Path
60
+ files: list[str] = field(default_factory=list)
61
+ directories: list[str] = field(default_factory=list)
62
+ overwritten: list[str] = field(default_factory=list)
63
+ dry_run: bool = False
64
+
65
+ @property
66
+ def count(self) -> int:
67
+ return len(self.files)
68
+
69
+
70
+ def _safe_relpath(rel: str, source: str) -> str:
71
+ candidate = Path(rel)
72
+ if candidate.is_absolute() or ".." in candidate.parts:
73
+ raise TemplateError(f"refusing to write outside the project: {rel!r} (from {source})")
74
+ return candidate.as_posix()
75
+
76
+
77
+ @dataclass(frozen=True)
78
+ class _Entry:
79
+ rel: str
80
+ payload: bytes
81
+ executable: bool
82
+
83
+
84
+ def _collect(template_dir: Path, context: dict) -> tuple[list[_Entry], list[str]]:
85
+ """Render the whole template in memory first, so a bad template writes nothing."""
86
+ missing: list[str] = []
87
+ entries: list[_Entry] = []
88
+ directories: list[str] = []
89
+
90
+ for src in sorted(template_dir.rglob("*")):
91
+ if src.is_dir() and src.name == "__pycache__":
92
+ continue
93
+ rel_src = src.relative_to(template_dir).as_posix()
94
+ rel = _safe_relpath(render_string(rel_src, context, missing=missing), rel_src)
95
+ if src.is_dir():
96
+ directories.append(rel)
97
+ continue
98
+
99
+ raw = src.read_bytes()
100
+ try:
101
+ text = raw.decode("utf-8")
102
+ except UnicodeDecodeError:
103
+ payload = raw
104
+ else:
105
+ payload = render_string(text, context, missing=missing).encode("utf-8")
106
+
107
+ executable = bool(os.access(src, os.X_OK) or src.suffix in {".sh", ".command"})
108
+ entries.append(_Entry(rel=rel, payload=payload, executable=executable))
109
+
110
+ if missing:
111
+ names = ", ".join(sorted(set(missing)))
112
+ raise TemplateError(f"unknown template variable(s) in {template_dir.name}: {names}")
113
+ return entries, directories
114
+
115
+
116
+ def generate(
117
+ template_dir: Path | str,
118
+ destination: Path | str,
119
+ context: dict,
120
+ *,
121
+ force: bool = False,
122
+ dry_run: bool = False,
123
+ ) -> GenerateResult:
124
+ """Render *template_dir* into *destination*.
125
+
126
+ Raises :class:`TemplateError` on unknown variables, path escapes, or a
127
+ non-empty destination without ``force``. Nothing is written until the whole
128
+ template has rendered successfully.
129
+ """
130
+ template_dir = Path(template_dir)
131
+ destination = Path(destination)
132
+ if not template_dir.is_dir():
133
+ raise TemplateError(f"template directory not found: {template_dir}")
134
+
135
+ if destination.exists() and not destination.is_dir():
136
+ raise TemplateError(f"destination exists and is not a directory: {destination}")
137
+ if destination.is_dir() and any(destination.iterdir()) and not force:
138
+ raise TemplateError(
139
+ f"destination is not empty: {destination} (use --force to write into it anyway)"
140
+ )
141
+
142
+ entries, directories = _collect(template_dir, context)
143
+ result = GenerateResult(root=destination, dry_run=dry_run)
144
+ result.directories = directories
145
+ result.files = [entry.rel for entry in entries]
146
+
147
+ if dry_run:
148
+ return result
149
+
150
+ destination.mkdir(parents=True, exist_ok=True)
151
+ for rel_dir in directories:
152
+ (destination / rel_dir).mkdir(parents=True, exist_ok=True)
153
+
154
+ for entry in entries:
155
+ target = destination / entry.rel
156
+ if target.exists():
157
+ result.overwritten.append(entry.rel)
158
+ target.parent.mkdir(parents=True, exist_ok=True)
159
+ target.write_bytes(entry.payload)
160
+ if entry.executable:
161
+ target.chmod(0o755)
162
+
163
+ return result
newstack/verify.py ADDED
@@ -0,0 +1,133 @@
1
+ """Prove a stack works: generate it into a throwaway directory and run its checks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shutil
6
+ import subprocess
7
+ import tempfile
8
+ import time
9
+ from dataclasses import dataclass, field
10
+ from pathlib import Path
11
+
12
+ from .registry import Stack
13
+ from .render import generate, render_string
14
+
15
+
16
+ @dataclass
17
+ class StepOutcome:
18
+ name: str
19
+ ok: bool
20
+ seconds: float
21
+ skipped: str | None = None
22
+ output: str = ""
23
+
24
+
25
+ @dataclass
26
+ class CheckResult:
27
+ stack: str
28
+ title: str
29
+ ok: bool
30
+ seconds: float
31
+ directory: str
32
+ outcomes: list[StepOutcome] = field(default_factory=list)
33
+
34
+ @property
35
+ def skipped(self) -> list[str]:
36
+ return [o.name for o in self.outcomes if o.skipped]
37
+
38
+ @property
39
+ def failed(self) -> list[StepOutcome]:
40
+ return [o for o in self.outcomes if not o.ok and not o.skipped]
41
+
42
+ def as_dict(self) -> dict:
43
+ return {
44
+ "stack": self.stack,
45
+ "title": self.title,
46
+ "ok": self.ok,
47
+ "seconds": round(self.seconds, 2),
48
+ "directory": self.directory,
49
+ "steps": [
50
+ {
51
+ "name": o.name,
52
+ "ok": o.ok,
53
+ "skipped": o.skipped,
54
+ "seconds": round(o.seconds, 2),
55
+ "output": o.output,
56
+ }
57
+ for o in self.outcomes
58
+ ],
59
+ }
60
+
61
+
62
+ def missing_tools(stack: Stack) -> list[str]:
63
+ """Toolchains this stack needs that are not on PATH right now."""
64
+ needed = {tool for step in stack.verify for tool in step.tools}
65
+ return sorted(tool for tool in needed if shutil.which(tool) is None)
66
+
67
+
68
+ def _tail(text: str, limit: int = 4000) -> str:
69
+ text = text.strip()
70
+ return text if len(text) <= limit else "…\n" + text[-limit:]
71
+
72
+
73
+ def check_stack(
74
+ stack: Stack,
75
+ context: dict,
76
+ *,
77
+ keep: bool = False,
78
+ verbose: bool = False,
79
+ project_name: str = "check-app",
80
+ ) -> CheckResult:
81
+ root = Path(tempfile.mkdtemp(prefix=f"newstack-{stack.name}-"))
82
+ started = time.perf_counter()
83
+ result = CheckResult(
84
+ stack=stack.name, title=stack.title, ok=True, seconds=0.0, directory=str(root)
85
+ )
86
+ try:
87
+ generate(stack.template_dir, root, context)
88
+ if not stack.verify:
89
+ result.outcomes.append(StepOutcome(name="generate", ok=True, seconds=0.0))
90
+ for step in stack.verify:
91
+ absent = [tool for tool in step.tools if shutil.which(tool) is None]
92
+ if absent:
93
+ result.outcomes.append(
94
+ StepOutcome(
95
+ name=step.name,
96
+ ok=False,
97
+ seconds=0.0,
98
+ skipped=f"missing {', '.join(absent)}",
99
+ )
100
+ )
101
+ result.ok = False
102
+ continue
103
+ step_started = time.perf_counter()
104
+ try:
105
+ completed = subprocess.run(
106
+ render_string(step.cmd, context),
107
+ shell=True,
108
+ cwd=root,
109
+ capture_output=True,
110
+ text=True,
111
+ timeout=step.timeout,
112
+ )
113
+ ok = completed.returncode == 0
114
+ output = (completed.stdout or "") + (completed.stderr or "")
115
+ except subprocess.TimeoutExpired:
116
+ ok = False
117
+ output = f"timed out after {step.timeout}s"
118
+ result.outcomes.append(
119
+ StepOutcome(
120
+ name=step.name,
121
+ ok=ok,
122
+ seconds=time.perf_counter() - step_started,
123
+ output=_tail(output) if verbose or not ok else "",
124
+ )
125
+ )
126
+ if not ok:
127
+ result.ok = False
128
+ break
129
+ finally:
130
+ result.seconds = time.perf_counter() - started
131
+ if not keep:
132
+ shutil.rmtree(root, ignore_errors=True)
133
+ return result
@@ -0,0 +1,197 @@
1
+ Metadata-Version: 2.5
2
+ Name: newstack
3
+ Version: 0.1.0
4
+ Summary: Scaffold a new project on any popular stack — every generated starter is proven green in CI.
5
+ Project-URL: Homepage, https://github.com/DawnofGenX/newstack
6
+ Project-URL: Repository, https://github.com/DawnofGenX/newstack
7
+ Project-URL: Issues, https://github.com/DawnofGenX/newstack/issues
8
+ Project-URL: Changelog, https://github.com/DawnofGenX/newstack/blob/main/CHANGELOG.md
9
+ Author-email: Priyansh Kansara <pkansara0412@gmail.com>
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: boilerplate,cli,generator,scaffold,scaffolding,starter,template
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Environment :: Console
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Topic :: Software Development :: Code Generators
22
+ Requires-Python: >=3.10
23
+ Description-Content-Type: text/markdown
24
+
25
+ # newstack
26
+
27
+ [![CI](https://github.com/DawnofGenX/newstack/actions/workflows/ci.yml/badge.svg)](https://github.com/DawnofGenX/newstack/actions/workflows/ci.yml)
28
+ ![python](https://img.shields.io/badge/python-3.10%2B-blue)
29
+ ![dependencies](https://img.shields.io/badge/runtime%20dependencies-0-brightgreen)
30
+ [![license](https://img.shields.io/badge/license-MIT-blue)](LICENSE)
31
+
32
+ **Scaffold a new project on any popular stack — and every starter it emits is proven green in CI.**
33
+
34
+ ```console
35
+ $ newstack new python-lib ~/code/mylib
36
+ newstack 0.1.0 · python-lib → /home/you/code/mylib
37
+
38
+ created 8 files
39
+ git initialised on main, first commit made
40
+
41
+ next:
42
+ cd /home/you/code/mylib
43
+ uv sync
44
+ uv run pytest
45
+ ```
46
+
47
+ ## Why this exists
48
+
49
+ `cookiecutter`, `copier`, `yeoman`, `degit`, `npm create …` and `gh repo create --template`
50
+ all solve *part* of the problem. Two things are still missing in practice:
51
+
52
+ 1. **One tool for every stack.** Python templates are Python-only, JS templates are
53
+ JS-only, and `gh repo create --template` clones a whole repository with no variables.
54
+ 2. **A guarantee that the template still works.** Templates rot silently. You find out
55
+ the starter is broken *after* you have already started building on top of it.
56
+
57
+ `newstack` fixes both. It is one zero-dependency CLI that carries a curated stack
58
+ registry, and **every stack in that registry is generated and checked on every push**
59
+ (`newstack check --all` in CI). If a starter breaks, the badge goes red — not your
60
+ first day of work.
61
+
62
+ ## Install
63
+
64
+ Not on PyPI yet — install straight from the repository:
65
+
66
+ ```bash
67
+ uv tool install git+https://github.com/DawnofGenX/newstack
68
+ # or, without installing:
69
+ uvx --from git+https://github.com/DawnofGenX/newstack newstack list
70
+ ```
71
+
72
+ From a clone:
73
+
74
+ ```bash
75
+ git clone https://github.com/DawnofGenX/newstack && cd newstack
76
+ uv sync && uv run newstack list
77
+ ```
78
+
79
+ ## Use
80
+
81
+ ```bash
82
+ newstack list # what is available, and what each stack needs
83
+ newstack new react-vite-ts ~/code/app
84
+ newstack new fastapi ~/code/api -d "Billing service" --author "Your Name"
85
+ newstack new python-lib ~/code/lib --dry-run # preview, write nothing
86
+ newstack new python-cli ~/code/tool --github # create the repo and push it too
87
+ newstack check --all # prove every stack on this machine
88
+ newstack doctor # which toolchains do I have?
89
+ ```
90
+
91
+ Every command takes `--json` where it makes sense, so the tool is scriptable.
92
+
93
+ `newstack new` renders variables, initialises git with a first commit, and (with
94
+ `--github`) creates the remote repository and pushes — one command from empty
95
+ directory to CI-ready project.
96
+
97
+ ## Stacks
98
+
99
+ | Stack | What you get | Toolchain |
100
+ | --- | --- | --- |
101
+ | `python-lib` | src layout, hatchling build, pytest, ruff, 4-version CI matrix | `uv` |
102
+ | `python-cli` | console entry point on argparse, zero runtime deps, tested CLI | `uv` |
103
+ | `fastapi` | typed JSON API, pydantic models, TestClient tests, Dockerfile | `uv` |
104
+ | `react-vite-ts` | React 19 + Vite + TS, Vitest + Testing Library, typecheck + build | `node`, `npm` |
105
+ | `node-express-ts` | Express 5 API, typed handlers, supertest tests | `node`, `npm` |
106
+ | `static-site` | HTML/CSS/JS page with a GitHub Pages deploy workflow | `node` |
107
+
108
+ Each generated project ships its own `.github/workflows/` that is ready to run, plus a
109
+ README, an MIT licence and a `.gitignore`.
110
+
111
+ ## The green guarantee
112
+
113
+ A template is only as good as its last successful run, so `newstack` treats
114
+ verification as a first-class command:
115
+
116
+ ```console
117
+ $ newstack check --all
118
+ fastapi PASS 1.0s 3 step(s)
119
+ node-express-ts PASS 2.2s 3 step(s)
120
+ python-cli PASS 0.5s 4 step(s)
121
+ python-lib PASS 0.4s 3 step(s)
122
+ react-vite-ts PASS 3.2s 4 step(s)
123
+ static-site PASS 0.1s 3 step(s)
124
+ ```
125
+
126
+ (Real timings from a warm package cache — a cold run takes about a minute because
127
+ `npm install` and `uv pip install` have to fetch packages.)
128
+
129
+ `check` generates the stack into a throwaway directory, runs the commands listed in its
130
+ manifest (`install → lint → test → build`), reports per-step timings, and deletes the
131
+ directory unless you pass `--keep`. The same command runs in this repository's CI, once
132
+ per stack, on every push.
133
+
134
+ ## How it compares
135
+
136
+ | | newstack | cookiecutter | copier | yeoman | degit / `npm create` | `gh repo create --template` |
137
+ | --- | --- | --- | --- | --- | --- | --- |
138
+ | Cross-language stacks in one tool | **yes** | no | no | partial | no | n/a |
139
+ | Runtime dependencies | **0** | Python | Python + git | Node | Node | gh |
140
+ | Generated starter verified in CI | **yes, per stack** | no | no | no | no | no |
141
+ | Template updates after generation | no (roadmap) | no | **yes** (`copier update`) | no | no | no |
142
+ | Creates the git repo + remote | **yes** (`--github`) | no | via hooks | no | no | yes |
143
+
144
+ Pick `copier` if you need `copier update` three-way merges against a single template you
145
+ own. Pick `newstack` when you want the same standard applied across several languages
146
+ without maintaining one template per stack.
147
+
148
+ ## Adding a stack
149
+
150
+ A stack is a directory with a manifest and a template tree:
151
+
152
+ ```text
153
+ src/newstack/data/stacks/my-stack/
154
+ ├── stack.json
155
+ └── template/
156
+ ├── README.md
157
+ └── ... # {{ project_name }}, {{ package_name }}, {{ npm_name }},
158
+ # {{ title }}, {{ description }}, {{ author }}, {{ email }},
159
+ # {{ year }}, {{ date }}
160
+ ```
161
+
162
+ ```json
163
+ {
164
+ "name": "my-stack",
165
+ "title": "My stack",
166
+ "language": "python",
167
+ "blurb": "One line shown by `newstack list`",
168
+ "requires": ["uv"],
169
+ "next_steps": ["cd {path}", "uv run pytest"],
170
+ "verify": [
171
+ {"name": "test", "cmd": "uv run pytest", "tools": ["uv"], "timeout": 600}
172
+ ]
173
+ }
174
+ ```
175
+
176
+ `{{!` escapes a literal `{{`, and `${{ ... }}` (GitHub Actions) is left untouched.
177
+ Point `NEWSTACK_STACKS` at a directory of your own stacks to use private ones — same
178
+ manifest format, no fork required.
179
+
180
+ ## Design
181
+
182
+ - **Zero runtime dependencies.** `argparse` and the standard library only, so `uvx newstack`
183
+ starts instantly and works offline.
184
+ - **Templates are plain files.** No Jinja, no DSL — a reviewer can read a template and know
185
+ exactly what gets written.
186
+ - **Verification is a product feature, not an afterthought.** `check` is the same code path
187
+ CI uses.
188
+
189
+ ## Roadmap
190
+
191
+ - `newstack update` — re-apply upstream template changes to an existing project.
192
+ - More stacks: Go service, Rust CLI, Django, Vue, SvelteKit.
193
+ - `--license apache-2.0|bsd-3` and other variable choices beyond the current defaults.
194
+
195
+ ## Licence
196
+
197
+ MIT — see [LICENSE](LICENSE).