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.
- newstack/__init__.py +4 -0
- newstack/__main__.py +8 -0
- newstack/cli.py +387 -0
- newstack/data/stacks/fastapi/stack.json +42 -0
- newstack/data/stacks/fastapi/template/.github/workflows/ci.yml +30 -0
- newstack/data/stacks/fastapi/template/.gitignore +11 -0
- newstack/data/stacks/fastapi/template/Dockerfile +13 -0
- newstack/data/stacks/fastapi/template/LICENSE +21 -0
- newstack/data/stacks/fastapi/template/README.md +30 -0
- newstack/data/stacks/fastapi/template/pyproject.toml +31 -0
- newstack/data/stacks/fastapi/template/src/{{package_name}}/__init__.py +4 -0
- newstack/data/stacks/fastapi/template/src/{{package_name}}/main.py +50 -0
- newstack/data/stacks/fastapi/template/tests/test_api.py +41 -0
- newstack/data/stacks/node-express-ts/stack.json +14 -0
- newstack/data/stacks/node-express-ts/template/.github/workflows/ci.yml +28 -0
- newstack/data/stacks/node-express-ts/template/.gitignore +6 -0
- newstack/data/stacks/node-express-ts/template/LICENSE +21 -0
- newstack/data/stacks/node-express-ts/template/README.md +24 -0
- newstack/data/stacks/node-express-ts/template/package.json +26 -0
- newstack/data/stacks/node-express-ts/template/src/app.ts +35 -0
- newstack/data/stacks/node-express-ts/template/src/server.ts +7 -0
- newstack/data/stacks/node-express-ts/template/tests/app.test.ts +32 -0
- newstack/data/stacks/node-express-ts/template/tsconfig.json +14 -0
- newstack/data/stacks/python-cli/stack.json +50 -0
- newstack/data/stacks/python-cli/template/.github/workflows/ci.yml +32 -0
- newstack/data/stacks/python-cli/template/.gitignore +11 -0
- newstack/data/stacks/python-cli/template/LICENSE +21 -0
- newstack/data/stacks/python-cli/template/README.md +35 -0
- newstack/data/stacks/python-cli/template/pyproject.toml +32 -0
- newstack/data/stacks/python-cli/template/src/{{package_name}}/__init__.py +4 -0
- newstack/data/stacks/python-cli/template/src/{{package_name}}/__main__.py +6 -0
- newstack/data/stacks/python-cli/template/src/{{package_name}}/cli.py +30 -0
- newstack/data/stacks/python-cli/template/tests/test_cli.py +20 -0
- newstack/data/stacks/python-lib/stack.json +42 -0
- newstack/data/stacks/python-lib/template/.github/workflows/ci.yml +32 -0
- newstack/data/stacks/python-lib/template/.gitignore +11 -0
- newstack/data/stacks/python-lib/template/LICENSE +21 -0
- newstack/data/stacks/python-lib/template/README.md +28 -0
- newstack/data/stacks/python-lib/template/pyproject.toml +34 -0
- newstack/data/stacks/python-lib/template/src/{{package_name}}/__init__.py +6 -0
- newstack/data/stacks/python-lib/template/src/{{package_name}}/core.py +23 -0
- newstack/data/stacks/python-lib/template/tests/test_core.py +31 -0
- newstack/data/stacks/react-vite-ts/stack.json +15 -0
- newstack/data/stacks/react-vite-ts/template/.github/workflows/ci.yml +29 -0
- newstack/data/stacks/react-vite-ts/template/.gitignore +7 -0
- newstack/data/stacks/react-vite-ts/template/LICENSE +21 -0
- newstack/data/stacks/react-vite-ts/template/README.md +25 -0
- newstack/data/stacks/react-vite-ts/template/index.html +12 -0
- newstack/data/stacks/react-vite-ts/template/package.json +28 -0
- newstack/data/stacks/react-vite-ts/template/src/App.test.tsx +20 -0
- newstack/data/stacks/react-vite-ts/template/src/App.tsx +15 -0
- newstack/data/stacks/react-vite-ts/template/src/main.tsx +16 -0
- newstack/data/stacks/react-vite-ts/template/src/styles.css +26 -0
- newstack/data/stacks/react-vite-ts/template/tsconfig.json +16 -0
- newstack/data/stacks/react-vite-ts/template/vite.config.ts +10 -0
- newstack/data/stacks/static-site/stack.json +14 -0
- newstack/data/stacks/static-site/template/.github/workflows/deploy-pages.yml +30 -0
- newstack/data/stacks/static-site/template/.gitignore +2 -0
- newstack/data/stacks/static-site/template/LICENSE +21 -0
- newstack/data/stacks/static-site/template/README.md +21 -0
- newstack/data/stacks/static-site/template/index.html +34 -0
- newstack/data/stacks/static-site/template/main.js +15 -0
- newstack/data/stacks/static-site/template/styles.css +74 -0
- newstack/data/stacks/static-site/template/tests/site.test.mjs +22 -0
- newstack/registry.py +137 -0
- newstack/render.py +163 -0
- newstack/verify.py +133 -0
- newstack-0.1.0.dist-info/METADATA +197 -0
- newstack-0.1.0.dist-info/RECORD +72 -0
- newstack-0.1.0.dist-info/WHEEL +4 -0
- newstack-0.1.0.dist-info/entry_points.txt +2 -0
- 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
|
+
[](https://github.com/DawnofGenX/newstack/actions/workflows/ci.yml)
|
|
28
|
+

|
|
29
|
+

|
|
30
|
+
[](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).
|