qtmp 0.2.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.
- qtmp/__init__.py +1 -0
- qtmp/cheatsheet_manager.py +75 -0
- qtmp/cheatsheets/fastapi.md +24 -0
- qtmp/cheatsheets/react.md +22 -0
- qtmp/cli.py +234 -0
- qtmp/context.py +94 -0
- qtmp/doctor.py +51 -0
- qtmp/executor.py +98 -0
- qtmp/planner.py +84 -0
- qtmp/recipes/__init__.py +51 -0
- qtmp/recipes/base.py +62 -0
- qtmp/recipes/database.py +112 -0
- qtmp/recipes/fastapi.py +92 -0
- qtmp/recipes/react.py +59 -0
- qtmp/recipes/router_axios.py +69 -0
- qtmp/recipes/tailwind.py +69 -0
- qtmp/utils.py +180 -0
- qtmp-0.2.0.dist-info/METADATA +10 -0
- qtmp-0.2.0.dist-info/RECORD +22 -0
- qtmp-0.2.0.dist-info/WHEEL +5 -0
- qtmp-0.2.0.dist-info/entry_points.txt +2 -0
- qtmp-0.2.0.dist-info/top_level.txt +1 -0
qtmp/recipes/__init__.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""
|
|
2
|
+
qtmp.recipes
|
|
3
|
+
--------------
|
|
4
|
+
The registry every other module reads from. Adding a new recipe means:
|
|
5
|
+
1. Write a Recipe subclass in its own file (see any existing recipe).
|
|
6
|
+
2. Add one line here.
|
|
7
|
+
Nothing in planner.py, executor.py, or cli.py needs to change.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from qtmp.recipes.react import ReactRecipe
|
|
11
|
+
from qtmp.recipes.tailwind import TailwindRecipe
|
|
12
|
+
from qtmp.recipes.router_axios import RouterRecipe, AxiosRecipe, ShadcnRecipe
|
|
13
|
+
from qtmp.recipes.fastapi import FastAPIRecipe
|
|
14
|
+
from qtmp.recipes.database import PostgresRecipe, DbDriverPostgresRecipe, SQLAlchemyRecipe
|
|
15
|
+
|
|
16
|
+
REGISTRY = {
|
|
17
|
+
r.provides: r
|
|
18
|
+
for r in [
|
|
19
|
+
ReactRecipe(),
|
|
20
|
+
TailwindRecipe(),
|
|
21
|
+
RouterRecipe(),
|
|
22
|
+
AxiosRecipe(),
|
|
23
|
+
ShadcnRecipe(),
|
|
24
|
+
FastAPIRecipe(),
|
|
25
|
+
PostgresRecipe(),
|
|
26
|
+
DbDriverPostgresRecipe(),
|
|
27
|
+
SQLAlchemyRecipe(),
|
|
28
|
+
]
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
CREATABLE = {key: r for key, r in REGISTRY.items() if r.creatable}
|
|
32
|
+
|
|
33
|
+
ALIASES = {
|
|
34
|
+
"reactjs": "react",
|
|
35
|
+
"react.js": "react",
|
|
36
|
+
"fast": "fastapi",
|
|
37
|
+
"postgresql": "postgres",
|
|
38
|
+
"psql": "postgres",
|
|
39
|
+
"pg": "postgres",
|
|
40
|
+
"router": "router",
|
|
41
|
+
"react-router": "router",
|
|
42
|
+
"react-router-dom": "router",
|
|
43
|
+
"sqlalchemy": "sqlalchemy",
|
|
44
|
+
"shadcn-ui": "shadcn",
|
|
45
|
+
"shadcn/ui": "shadcn",
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def resolve_name(name: str) -> str:
|
|
50
|
+
key = name.strip().lower()
|
|
51
|
+
return ALIASES.get(key, key)
|
qtmp/recipes/base.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""
|
|
2
|
+
qtmp.recipes.base
|
|
3
|
+
-------------------
|
|
4
|
+
A Recipe is the only thing a developer needs to write to teach qtmp
|
|
5
|
+
about a new piece of the stack. It answers three questions:
|
|
6
|
+
|
|
7
|
+
- requires(ctx) -> what other capabilities must exist first?
|
|
8
|
+
- detect(ctx) -> is this capability already satisfied? (idempotency)
|
|
9
|
+
- plan(ctx) -> what Steps would satisfy it, if not?
|
|
10
|
+
|
|
11
|
+
Nothing in qtmp/planner.py, qtmp/executor.py, or qtmp/cli.py needs to
|
|
12
|
+
change when a new Recipe is added — only qtmp/recipes/__init__.py's
|
|
13
|
+
registry needs one new line.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from abc import ABC, abstractmethod
|
|
17
|
+
from typing import List
|
|
18
|
+
|
|
19
|
+
from qtmp.planner import Step
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Recipe(ABC):
|
|
23
|
+
#: human-readable name, e.g. "Tailwind CSS"
|
|
24
|
+
name: str = ""
|
|
25
|
+
#: capability id this recipe satisfies, e.g. "tailwind" — this is
|
|
26
|
+
#: what `qtmp add <id>` and `requires()` refer to.
|
|
27
|
+
provides: str = ""
|
|
28
|
+
#: True if this recipe supports `qtmp create <provides>` (scaffolding
|
|
29
|
+
#: a brand-new project) in addition to `qtmp add`.
|
|
30
|
+
creatable: bool = False
|
|
31
|
+
|
|
32
|
+
def requires(self, ctx) -> List[str]:
|
|
33
|
+
"""
|
|
34
|
+
Capability ids that must be satisfied before this one can run.
|
|
35
|
+
Can depend on `ctx` — e.g. SQLAlchemy's driver requirement
|
|
36
|
+
depends on which database is actually in play.
|
|
37
|
+
"""
|
|
38
|
+
return []
|
|
39
|
+
|
|
40
|
+
@abstractmethod
|
|
41
|
+
def detect(self, ctx) -> bool:
|
|
42
|
+
"""Is this capability already present in ctx.cwd?"""
|
|
43
|
+
raise NotImplementedError
|
|
44
|
+
|
|
45
|
+
@abstractmethod
|
|
46
|
+
def plan(self, ctx) -> List[Step]:
|
|
47
|
+
"""
|
|
48
|
+
Steps needed to satisfy this capability. Only ever called when
|
|
49
|
+
detect(ctx) is False — recipes don't need their own "already
|
|
50
|
+
installed" guard, the planner handles that centrally.
|
|
51
|
+
"""
|
|
52
|
+
raise NotImplementedError
|
|
53
|
+
|
|
54
|
+
def create(self, ctx, **kwargs):
|
|
55
|
+
"""
|
|
56
|
+
Only for creatable recipes: scaffold a brand-new project.
|
|
57
|
+
Returns the new project's directory. Not part of the
|
|
58
|
+
detect/plan/requires idempotency contract — creating "my-app"
|
|
59
|
+
twice is expected to be a user decision (a new folder), not
|
|
60
|
+
something the planner should intervene in.
|
|
61
|
+
"""
|
|
62
|
+
raise NotImplementedError(f"{self.name} does not support 'create'")
|
qtmp/recipes/database.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""
|
|
2
|
+
qtmp.recipes.database
|
|
3
|
+
-----------------------
|
|
4
|
+
Three capabilities that show real dependency resolution instead of a
|
|
5
|
+
single flat "postgres" module:
|
|
6
|
+
|
|
7
|
+
postgres -> a local server + database/user exist
|
|
8
|
+
db-driver-postgres -> psycopg2-binary is installed in the venv
|
|
9
|
+
sqlalchemy -> SQLAlchemy is installed, *and* requires a
|
|
10
|
+
driver for whatever database is in play
|
|
11
|
+
|
|
12
|
+
Running `qtmp add sqlalchemy` on a fresh FastAPI project resolves to:
|
|
13
|
+
fastapi (already installed, skipped)
|
|
14
|
+
-> db-driver-postgres (installs psycopg2-binary)
|
|
15
|
+
-> sqlalchemy (installs SQLAlchemy)
|
|
16
|
+
|
|
17
|
+
This is the "add SQLAlchemy -> driver required -> resolve -> install"
|
|
18
|
+
flow, expressed as two lines of `requires()` rather than special-cased
|
|
19
|
+
CLI logic.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import os
|
|
23
|
+
import subprocess
|
|
24
|
+
from typing import List
|
|
25
|
+
|
|
26
|
+
from qtmp.planner import Step
|
|
27
|
+
from qtmp.recipes.base import Recipe
|
|
28
|
+
from qtmp.utils import prompt, which
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class PostgresRecipe(Recipe):
|
|
32
|
+
name = "PostgreSQL (local server)"
|
|
33
|
+
provides = "postgres"
|
|
34
|
+
|
|
35
|
+
def detect(self, ctx) -> bool:
|
|
36
|
+
return which("psql") is not None
|
|
37
|
+
|
|
38
|
+
def plan(self, ctx) -> List[Step]:
|
|
39
|
+
# We can't silently "apt install postgresql" for someone — this
|
|
40
|
+
# is a step deliberately left as guidance, not automation, since
|
|
41
|
+
# it usually needs sudo / a package manager choice we shouldn't
|
|
42
|
+
# make on the user's behalf.
|
|
43
|
+
return [
|
|
44
|
+
Step(
|
|
45
|
+
"PostgreSQL is not installed — install it yourself, then re-run:\n"
|
|
46
|
+
" macOS: brew install postgresql\n"
|
|
47
|
+
" Ubuntu: sudo apt install postgresql",
|
|
48
|
+
apply_fn=lambda: None,
|
|
49
|
+
)
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class DbDriverPostgresRecipe(Recipe):
|
|
54
|
+
name = "psycopg2 (PostgreSQL driver)"
|
|
55
|
+
provides = "db-driver-postgres"
|
|
56
|
+
|
|
57
|
+
def requires(self, ctx) -> List[str]:
|
|
58
|
+
# Always declare fastapi as a dependency — resolve_plan() checks
|
|
59
|
+
# FastAPIRecipe.detect(ctx) itself and marks it already_satisfied
|
|
60
|
+
# if true, so this never double-installs. (A venv existing is
|
|
61
|
+
# not the same thing as fastapi being installed in it — don't
|
|
62
|
+
# use has_venv as a shortcut here.)
|
|
63
|
+
return ["fastapi"]
|
|
64
|
+
|
|
65
|
+
def detect(self, ctx) -> bool:
|
|
66
|
+
python = ctx.venv_python()
|
|
67
|
+
if not os.path.isfile(python):
|
|
68
|
+
return False
|
|
69
|
+
result = subprocess.run(
|
|
70
|
+
[python, "-c", "import psycopg2"], capture_output=True
|
|
71
|
+
)
|
|
72
|
+
return result.returncode == 0
|
|
73
|
+
|
|
74
|
+
def plan(self, ctx) -> List[Step]:
|
|
75
|
+
return [
|
|
76
|
+
Step(
|
|
77
|
+
"Install psycopg2-binary",
|
|
78
|
+
command=[ctx.venv_pip(), "install", "psycopg2-binary"],
|
|
79
|
+
)
|
|
80
|
+
]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class SQLAlchemyRecipe(Recipe):
|
|
84
|
+
name = "SQLAlchemy"
|
|
85
|
+
provides = "sqlalchemy"
|
|
86
|
+
|
|
87
|
+
def requires(self, ctx) -> List[str]:
|
|
88
|
+
# This is the resolution step from your diagram: SQLAlchemy
|
|
89
|
+
# itself doesn't care which database, but *something* has to
|
|
90
|
+
# decide which driver to install. Default to Postgres — a
|
|
91
|
+
# future recipe (e.g. db-driver-sqlite) could be chosen instead
|
|
92
|
+
# based on a --db flag or a detected DATABASE_URL scheme.
|
|
93
|
+
# fastapi is always declared too (see DbDriverPostgresRecipe for
|
|
94
|
+
# why has_venv can't be used as a substitute check).
|
|
95
|
+
return ["fastapi", "db-driver-postgres"]
|
|
96
|
+
|
|
97
|
+
def detect(self, ctx) -> bool:
|
|
98
|
+
python = ctx.venv_python()
|
|
99
|
+
if not os.path.isfile(python):
|
|
100
|
+
return False
|
|
101
|
+
result = subprocess.run(
|
|
102
|
+
[python, "-c", "import sqlalchemy"], capture_output=True
|
|
103
|
+
)
|
|
104
|
+
return result.returncode == 0
|
|
105
|
+
|
|
106
|
+
def plan(self, ctx) -> List[Step]:
|
|
107
|
+
return [
|
|
108
|
+
Step(
|
|
109
|
+
"Install SQLAlchemy",
|
|
110
|
+
command=[ctx.venv_pip(), "install", "sqlalchemy"],
|
|
111
|
+
)
|
|
112
|
+
]
|
qtmp/recipes/fastapi.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""
|
|
2
|
+
qtmp.recipes.fastapi
|
|
3
|
+
----------------------
|
|
4
|
+
Provides "fastapi". Creates a venv + installs fastapi/uvicorn + writes
|
|
5
|
+
a working main.py. Also the anchor of the Python dependency chain that
|
|
6
|
+
SQLAlchemy's driver resolution hangs off of.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import sys
|
|
11
|
+
from typing import List
|
|
12
|
+
|
|
13
|
+
from qtmp.planner import Step
|
|
14
|
+
from qtmp.recipes.base import Recipe
|
|
15
|
+
from qtmp.utils import prompt, which
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
MAIN_PY_TEMPLATE = '''from fastapi import FastAPI
|
|
19
|
+
|
|
20
|
+
app = FastAPI(title="{title}")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@app.get("/")
|
|
24
|
+
def read_root():
|
|
25
|
+
return {{"message": "{title} is running"}}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@app.get("/health")
|
|
29
|
+
def health():
|
|
30
|
+
return {{"status": "ok"}}
|
|
31
|
+
'''
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _write_main_py(cwd, title):
|
|
35
|
+
with open(os.path.join(cwd, "main.py"), "w") as f:
|
|
36
|
+
f.write(MAIN_PY_TEMPLATE.format(title=title))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class FastAPIRecipe(Recipe):
|
|
40
|
+
name = "FastAPI"
|
|
41
|
+
provides = "fastapi"
|
|
42
|
+
creatable = True
|
|
43
|
+
|
|
44
|
+
def detect(self, ctx) -> bool:
|
|
45
|
+
main_py = os.path.join(ctx.cwd, "main.py")
|
|
46
|
+
if not os.path.isfile(main_py):
|
|
47
|
+
return False
|
|
48
|
+
with open(main_py) as f:
|
|
49
|
+
return "fastapi" in f.read().lower()
|
|
50
|
+
|
|
51
|
+
def plan(self, ctx) -> List[Step]:
|
|
52
|
+
title = os.path.basename(ctx.cwd.rstrip("/")) or "app"
|
|
53
|
+
return [
|
|
54
|
+
Step(
|
|
55
|
+
"Create virtual environment",
|
|
56
|
+
command=[sys.executable, "-m", "venv", "venv"],
|
|
57
|
+
cwd=ctx.cwd,
|
|
58
|
+
),
|
|
59
|
+
Step(
|
|
60
|
+
"Install fastapi + uvicorn",
|
|
61
|
+
command=[ctx.venv_pip(), "install", "fastapi", "uvicorn[standard]"],
|
|
62
|
+
),
|
|
63
|
+
Step(
|
|
64
|
+
"Write main.py",
|
|
65
|
+
apply_fn=lambda: _write_main_py(ctx.cwd, title),
|
|
66
|
+
),
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
def create(self, ctx, **kwargs):
|
|
70
|
+
if not which(sys.executable):
|
|
71
|
+
raise RuntimeError("Python interpreter not found.")
|
|
72
|
+
|
|
73
|
+
name = prompt("Project name", default="my-fastapi-app")
|
|
74
|
+
project_dir = os.path.join(ctx.cwd, name)
|
|
75
|
+
os.makedirs(project_dir, exist_ok=True)
|
|
76
|
+
|
|
77
|
+
from qtmp.utils import Spinner, run, ok
|
|
78
|
+
from qtmp.context import ProjectContext
|
|
79
|
+
|
|
80
|
+
venv_dir = os.path.join(project_dir, "venv")
|
|
81
|
+
with Spinner("Creating virtual environment"):
|
|
82
|
+
run([sys.executable, "-m", "venv", venv_dir])
|
|
83
|
+
|
|
84
|
+
sub_ctx = ProjectContext.detect(project_dir)
|
|
85
|
+
pip = sub_ctx.venv_pip()
|
|
86
|
+
with Spinner("Installing fastapi + uvicorn"):
|
|
87
|
+
run([pip, "install", "--upgrade", "pip"])
|
|
88
|
+
run([pip, "install", "fastapi", "uvicorn[standard]"])
|
|
89
|
+
|
|
90
|
+
_write_main_py(project_dir, name)
|
|
91
|
+
ok(f"FastAPI project ready at {project_dir}")
|
|
92
|
+
return project_dir, False
|
qtmp/recipes/react.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""
|
|
2
|
+
qtmp.recipes.react
|
|
3
|
+
--------------------
|
|
4
|
+
Provides "react". Creatable (scaffolds a brand-new Vite project) and
|
|
5
|
+
also a dependency target — e.g. `qtmp add tailwind` in an empty folder
|
|
6
|
+
will pull this in automatically via requires().
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
from typing import List
|
|
11
|
+
|
|
12
|
+
from qtmp.planner import Step
|
|
13
|
+
from qtmp.recipes.base import Recipe
|
|
14
|
+
from qtmp.utils import prompt, confirm, which
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ReactRecipe(Recipe):
|
|
18
|
+
name = "React"
|
|
19
|
+
provides = "react"
|
|
20
|
+
creatable = True
|
|
21
|
+
|
|
22
|
+
def detect(self, ctx) -> bool:
|
|
23
|
+
return "react" in ctx.node_deps
|
|
24
|
+
|
|
25
|
+
def plan(self, ctx) -> List[Step]:
|
|
26
|
+
# `plan()` for react-as-a-dependency assumes a name isn't being
|
|
27
|
+
# asked interactively (that only happens via `create`), so this
|
|
28
|
+
# covers the case "something requires react and it's missing" —
|
|
29
|
+
# it scaffolds a project named after the current folder, in place.
|
|
30
|
+
folder_name = os.path.basename(ctx.cwd.rstrip("/")) or "app"
|
|
31
|
+
return [
|
|
32
|
+
Step(
|
|
33
|
+
f"Scaffold React (Vite) into ./{folder_name}",
|
|
34
|
+
command=["npm", "create", "vite@latest", ".", "--", "--template", "react"],
|
|
35
|
+
cwd=ctx.cwd,
|
|
36
|
+
),
|
|
37
|
+
Step("Install npm dependencies", command=["npm", "install"], cwd=ctx.cwd),
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
def create(self, ctx, **kwargs):
|
|
41
|
+
if not which("npm"):
|
|
42
|
+
raise RuntimeError("npm not found on PATH. Install Node.js first: https://nodejs.org")
|
|
43
|
+
|
|
44
|
+
name = prompt("Project name", default="my-react-app")
|
|
45
|
+
level = prompt("Beginner or advanced setup? [b/a]", default="b").strip().lower()
|
|
46
|
+
advanced = level.startswith("a")
|
|
47
|
+
template = "react-ts" if advanced else "react"
|
|
48
|
+
|
|
49
|
+
from qtmp.utils import Spinner, run, ok
|
|
50
|
+
|
|
51
|
+
with Spinner(f"Scaffolding {name} with Vite"):
|
|
52
|
+
run(["npm", "create", "vite@latest", name, "--", "--template", template], cwd=ctx.cwd)
|
|
53
|
+
|
|
54
|
+
project_dir = os.path.join(ctx.cwd, name)
|
|
55
|
+
with Spinner("Installing dependencies"):
|
|
56
|
+
run(["npm", "install"], cwd=project_dir)
|
|
57
|
+
|
|
58
|
+
ok(f"React project ready at {project_dir}")
|
|
59
|
+
return project_dir, advanced
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""
|
|
2
|
+
qtmp.recipes.router_axios
|
|
3
|
+
---------------------------
|
|
4
|
+
Two small recipes, both requiring "react", showing what a minimal
|
|
5
|
+
add-on recipe looks like once the base project is a dependency someone
|
|
6
|
+
else already resolved.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from typing import List
|
|
10
|
+
|
|
11
|
+
from qtmp.planner import Step
|
|
12
|
+
from qtmp.recipes.base import Recipe
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class RouterRecipe(Recipe):
|
|
16
|
+
name = "React Router"
|
|
17
|
+
provides = "router"
|
|
18
|
+
|
|
19
|
+
def requires(self, ctx) -> List[str]:
|
|
20
|
+
return [] if ctx.is_node else ["react"]
|
|
21
|
+
|
|
22
|
+
def detect(self, ctx) -> bool:
|
|
23
|
+
return "react-router-dom" in ctx.node_deps
|
|
24
|
+
|
|
25
|
+
def plan(self, ctx) -> List[Step]:
|
|
26
|
+
return [
|
|
27
|
+
Step(
|
|
28
|
+
"Install react-router-dom",
|
|
29
|
+
command=["npm", "install", "react-router-dom"],
|
|
30
|
+
cwd=ctx.cwd,
|
|
31
|
+
)
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class AxiosRecipe(Recipe):
|
|
36
|
+
name = "Axios"
|
|
37
|
+
provides = "axios"
|
|
38
|
+
|
|
39
|
+
def requires(self, ctx) -> List[str]:
|
|
40
|
+
return [] if ctx.is_node else ["react"]
|
|
41
|
+
|
|
42
|
+
def detect(self, ctx) -> bool:
|
|
43
|
+
return "axios" in ctx.node_deps
|
|
44
|
+
|
|
45
|
+
def plan(self, ctx) -> List[Step]:
|
|
46
|
+
return [
|
|
47
|
+
Step("Install axios", command=["npm", "install", "axios"], cwd=ctx.cwd)
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class ShadcnRecipe(Recipe):
|
|
52
|
+
name = "shadcn/ui"
|
|
53
|
+
provides = "shadcn"
|
|
54
|
+
|
|
55
|
+
def requires(self, ctx) -> List[str]:
|
|
56
|
+
return ["tailwind"] # shadcn/ui always needs Tailwind configured first
|
|
57
|
+
|
|
58
|
+
def detect(self, ctx) -> bool:
|
|
59
|
+
import os
|
|
60
|
+
return os.path.isfile(f"{ctx.cwd}/components.json")
|
|
61
|
+
|
|
62
|
+
def plan(self, ctx) -> List[Step]:
|
|
63
|
+
return [
|
|
64
|
+
Step(
|
|
65
|
+
"Initialize shadcn/ui",
|
|
66
|
+
command=["npx", "shadcn@latest", "init", "-d"],
|
|
67
|
+
cwd=ctx.cwd,
|
|
68
|
+
)
|
|
69
|
+
]
|
qtmp/recipes/tailwind.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""
|
|
2
|
+
qtmp.recipes.tailwind
|
|
3
|
+
-----------------------
|
|
4
|
+
Provides "tailwind". Demonstrates the simplest form of dependency
|
|
5
|
+
resolution: if you `qtmp add tailwind` in an empty folder, it pulls in
|
|
6
|
+
"react" first, because Tailwind needs *some* JS project to attach to.
|
|
7
|
+
If a node project already exists, it attaches directly.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
from typing import List
|
|
12
|
+
|
|
13
|
+
from qtmp.planner import Step
|
|
14
|
+
from qtmp.recipes.base import Recipe
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
TAILWIND_CONFIG = (
|
|
18
|
+
"/** @type {import('tailwindcss').Config} */\n"
|
|
19
|
+
"export default {\n"
|
|
20
|
+
' content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],\n'
|
|
21
|
+
" theme: { extend: {} },\n"
|
|
22
|
+
" plugins: [],\n"
|
|
23
|
+
"}\n"
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _write_config(cwd):
|
|
28
|
+
with open(os.path.join(cwd, "tailwind.config.js"), "w") as f:
|
|
29
|
+
f.write(TAILWIND_CONFIG)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _append_directives(cwd):
|
|
33
|
+
css_path = os.path.join(cwd, "src", "index.css")
|
|
34
|
+
if os.path.isdir(os.path.join(cwd, "src")):
|
|
35
|
+
with open(css_path, "a") as f:
|
|
36
|
+
f.write("\n@tailwind base;\n@tailwind components;\n@tailwind utilities;\n")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class TailwindRecipe(Recipe):
|
|
40
|
+
name = "Tailwind CSS"
|
|
41
|
+
provides = "tailwind"
|
|
42
|
+
|
|
43
|
+
def requires(self, ctx) -> List[str]:
|
|
44
|
+
return [] if ctx.is_node else ["react"]
|
|
45
|
+
|
|
46
|
+
def detect(self, ctx) -> bool:
|
|
47
|
+
return ctx.has_tailwind_config
|
|
48
|
+
|
|
49
|
+
def plan(self, ctx) -> List[Step]:
|
|
50
|
+
return [
|
|
51
|
+
Step(
|
|
52
|
+
"Install tailwindcss, postcss, autoprefixer",
|
|
53
|
+
command=["npm", "install", "-D", "tailwindcss", "postcss", "autoprefixer"],
|
|
54
|
+
cwd=ctx.cwd,
|
|
55
|
+
),
|
|
56
|
+
Step(
|
|
57
|
+
"Run tailwindcss init",
|
|
58
|
+
command=["npx", "tailwindcss", "init", "-p"],
|
|
59
|
+
cwd=ctx.cwd,
|
|
60
|
+
),
|
|
61
|
+
Step(
|
|
62
|
+
"Write tailwind.config.js content globs",
|
|
63
|
+
apply_fn=lambda: _write_config(ctx.cwd),
|
|
64
|
+
),
|
|
65
|
+
Step(
|
|
66
|
+
"Add @tailwind directives to src/index.css",
|
|
67
|
+
apply_fn=lambda: _append_directives(ctx.cwd),
|
|
68
|
+
),
|
|
69
|
+
]
|
qtmp/utils.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""
|
|
2
|
+
qtmp.utils
|
|
3
|
+
----------------
|
|
4
|
+
Small helpers: colored terminal output, safe subprocess execution,
|
|
5
|
+
and a minimal spinner. Pure stdlib, no third-party deps.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
import shutil
|
|
11
|
+
import time
|
|
12
|
+
import threading
|
|
13
|
+
import itertools
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class C:
|
|
17
|
+
"""ANSI color codes (auto-disabled on unsupported terminals)."""
|
|
18
|
+
_enabled = sys.stdout.isatty()
|
|
19
|
+
RESET = "\033[0m" if _enabled else ""
|
|
20
|
+
BOLD = "\033[1m" if _enabled else ""
|
|
21
|
+
DIM = "\033[2m" if _enabled else ""
|
|
22
|
+
ITALIC = "\033[3m" if _enabled else ""
|
|
23
|
+
GREEN = "\033[32m" if _enabled else ""
|
|
24
|
+
RED = "\033[31m" if _enabled else ""
|
|
25
|
+
YELLOW = "\033[33m" if _enabled else ""
|
|
26
|
+
CYAN = "\033[36m" if _enabled else ""
|
|
27
|
+
MAGENTA = "\033[35m" if _enabled else ""
|
|
28
|
+
BLUE = "\033[34m" if _enabled else ""
|
|
29
|
+
WHITE = "\033[97m" if _enabled else ""
|
|
30
|
+
# Bright variants read better on dark terminal backgrounds.
|
|
31
|
+
B_CYAN = "\033[96m" if _enabled else ""
|
|
32
|
+
B_MAGENTA = "\033[95m" if _enabled else ""
|
|
33
|
+
B_GREEN = "\033[92m" if _enabled else ""
|
|
34
|
+
B_YELLOW = "\033[93m" if _enabled else ""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
LOGO = r"""
|
|
38
|
+
_
|
|
39
|
+
__ _| |_ _ __ ___ _ __
|
|
40
|
+
/ _` | __| '_ ` _ \| '_ \
|
|
41
|
+
| (_| | |_| | | | | | |_) |
|
|
42
|
+
\__, |\__|_| |_| |_| .__/
|
|
43
|
+
|_| |_|
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def print_logo(tagline: str = "scaffold your stack, without the busywork"):
|
|
48
|
+
print(f"{C.B_CYAN}{LOGO}{C.RESET}")
|
|
49
|
+
print(f" {C.DIM}{C.ITALIC}{tagline}{C.RESET}\n")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def banner(text: str):
|
|
53
|
+
width = max(len(text) + 4, 20)
|
|
54
|
+
top = "╭" + "─" * (width - 2) + "╮"
|
|
55
|
+
bottom = "╰" + "─" * (width - 2) + "╯"
|
|
56
|
+
pad = width - 2 - len(text)
|
|
57
|
+
left_pad = pad // 2
|
|
58
|
+
right_pad = pad - left_pad
|
|
59
|
+
print(f"\n{C.B_MAGENTA}{top}{C.RESET}")
|
|
60
|
+
print(f"{C.B_MAGENTA}│{C.RESET}{' ' * left_pad}{C.BOLD}{C.WHITE}{text}{C.RESET}{' ' * right_pad}{C.B_MAGENTA}│{C.RESET}")
|
|
61
|
+
print(f"{C.B_MAGENTA}{bottom}{C.RESET}")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def info(text: str):
|
|
65
|
+
print(f"{C.B_CYAN}i{C.RESET} {text}")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def ok(text: str):
|
|
69
|
+
print(f"{C.B_GREEN}✓{C.RESET} {text}")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def warn(text: str):
|
|
73
|
+
print(f"{C.B_YELLOW}!{C.RESET} {text}")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def err(text: str):
|
|
77
|
+
print(f"{C.RED}✗{C.RESET} {text}")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def which(binary: str) -> bool:
|
|
81
|
+
"""Check whether a binary exists on PATH."""
|
|
82
|
+
return shutil.which(binary) is not None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class Spinner:
|
|
86
|
+
"""A tiny context-manager spinner for long-running commands."""
|
|
87
|
+
|
|
88
|
+
FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
|
|
89
|
+
|
|
90
|
+
def __init__(self, message: str):
|
|
91
|
+
self.message = message
|
|
92
|
+
self._stop = threading.Event()
|
|
93
|
+
self._thread = None
|
|
94
|
+
|
|
95
|
+
def _spin(self):
|
|
96
|
+
for frame in itertools.cycle(self.FRAMES):
|
|
97
|
+
if self._stop.is_set():
|
|
98
|
+
break
|
|
99
|
+
sys.stdout.write(f"\r{C.MAGENTA}{frame}{C.RESET} {self.message} ")
|
|
100
|
+
sys.stdout.flush()
|
|
101
|
+
time.sleep(0.08)
|
|
102
|
+
sys.stdout.write("\r" + " " * (len(self.message) + 10) + "\r")
|
|
103
|
+
sys.stdout.flush()
|
|
104
|
+
|
|
105
|
+
def __enter__(self):
|
|
106
|
+
if sys.stdout.isatty():
|
|
107
|
+
self._thread = threading.Thread(target=self._spin, daemon=True)
|
|
108
|
+
self._thread.start()
|
|
109
|
+
else:
|
|
110
|
+
print(f"... {self.message}")
|
|
111
|
+
return self
|
|
112
|
+
|
|
113
|
+
def __exit__(self, *exc):
|
|
114
|
+
self._stop.set()
|
|
115
|
+
if self._thread:
|
|
116
|
+
self._thread.join()
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def run(cmd, cwd=None, spinner_msg=None, check=True) -> subprocess.CompletedProcess:
|
|
120
|
+
"""
|
|
121
|
+
Run a shell command, streaming output live to the console.
|
|
122
|
+
`cmd` can be a list (preferred) or a string.
|
|
123
|
+
|
|
124
|
+
Returns the CompletedProcess. Raises CalledProcessError if check=True
|
|
125
|
+
and the command fails (caller can catch this to report cleanly).
|
|
126
|
+
"""
|
|
127
|
+
shown = cmd if isinstance(cmd, str) else " ".join(cmd)
|
|
128
|
+
print(f"{C.DIM}$ {shown}{C.RESET}")
|
|
129
|
+
|
|
130
|
+
result = subprocess.run(
|
|
131
|
+
cmd,
|
|
132
|
+
cwd=cwd,
|
|
133
|
+
shell=isinstance(cmd, str),
|
|
134
|
+
text=True,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
if check and result.returncode != 0:
|
|
138
|
+
raise subprocess.CalledProcessError(result.returncode, cmd)
|
|
139
|
+
|
|
140
|
+
return result
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def prompt(msg: str, default: str = None) -> str:
|
|
144
|
+
suffix = f" [{default}]" if default else ""
|
|
145
|
+
val = input(f"{C.BOLD}{msg}{suffix}: {C.RESET}").strip()
|
|
146
|
+
return val or (default or "")
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def confirm(msg: str, default: bool = True) -> bool:
|
|
150
|
+
suffix = "[Y/n]" if default else "[y/N]"
|
|
151
|
+
val = input(f"{C.BOLD}{msg} {suffix}: {C.RESET}").strip().lower()
|
|
152
|
+
if not val:
|
|
153
|
+
return default
|
|
154
|
+
return val in ("y", "yes")
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def prompt_directory(default_cwd: str) -> str:
|
|
158
|
+
"""
|
|
159
|
+
Simple directory picker: default is "create right here" — just hit
|
|
160
|
+
Enter. Only asks for a name if the person wants a fresh subfolder,
|
|
161
|
+
never a full path.
|
|
162
|
+
"""
|
|
163
|
+
import os as _os
|
|
164
|
+
|
|
165
|
+
info(f"Creating in: {default_cwd}")
|
|
166
|
+
name = prompt("Folder name (Enter = use this directory as-is)", default="")
|
|
167
|
+
|
|
168
|
+
if not name:
|
|
169
|
+
return default_cwd
|
|
170
|
+
|
|
171
|
+
path = _os.path.join(default_cwd, name)
|
|
172
|
+
if _os.path.isfile(path):
|
|
173
|
+
err(f"{path} is a file, not a directory.")
|
|
174
|
+
return prompt_directory(default_cwd)
|
|
175
|
+
|
|
176
|
+
if not _os.path.isdir(path):
|
|
177
|
+
_os.makedirs(path, exist_ok=True)
|
|
178
|
+
ok(f"Created ./{name}")
|
|
179
|
+
|
|
180
|
+
return path
|