dktcli 1.0.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.
- devkittools/__init__.py +3 -0
- devkittools/__main__.py +6 -0
- devkittools/cli.py +179 -0
- devkittools/tools/__init__.py +1 -0
- devkittools/tools/bench.py +73 -0
- devkittools/tools/changeloggen.py +181 -0
- devkittools/tools/clean.py +110 -0
- devkittools/tools/colortool.py +126 -0
- devkittools/tools/commitwriter.py +199 -0
- devkittools/tools/contextpacker.py +226 -0
- devkittools/tools/cron.py +152 -0
- devkittools/tools/doctor.py +74 -0
- devkittools/tools/du.py +72 -0
- devkittools/tools/enc.py +131 -0
- devkittools/tools/envtool.py +141 -0
- devkittools/tools/findtool.py +119 -0
- devkittools/tools/gen.py +182 -0
- devkittools/tools/gx.py +184 -0
- devkittools/tools/hashtool.py +93 -0
- devkittools/tools/httptool.py +131 -0
- devkittools/tools/ignore.py +213 -0
- devkittools/tools/initproj.py +166 -0
- devkittools/tools/jsontool.py +200 -0
- devkittools/tools/net.py +114 -0
- devkittools/tools/portkiller.py +246 -0
- devkittools/tools/regextool.py +84 -0
- devkittools/tools/repostats.py +197 -0
- devkittools/tools/secrets_scan.py +108 -0
- devkittools/tools/serve.py +118 -0
- devkittools/tools/snip.py +94 -0
- devkittools/tools/system.py +157 -0
- devkittools/tools/timetool.py +130 -0
- devkittools/tools/todo.py +69 -0
- devkittools/tools/tree.py +86 -0
- devkittools/tools/watch.py +82 -0
- devkittools/utils.py +561 -0
- dktcli-1.0.0.dist-info/METADATA +148 -0
- dktcli-1.0.0.dist-info/RECORD +42 -0
- dktcli-1.0.0.dist-info/WHEEL +5 -0
- dktcli-1.0.0.dist-info/entry_points.txt +3 -0
- dktcli-1.0.0.dist-info/licenses/LICENSE +21 -0
- dktcli-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""dkt repostats (rs) - quick stats and health summary for a project."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from collections import Counter
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Dict, List, Optional
|
|
10
|
+
|
|
11
|
+
from ..utils import (bar, c, git, human_num, human_size, is_git_repo, list_project_files,
|
|
12
|
+
resolve_folder)
|
|
13
|
+
|
|
14
|
+
EPILOG = """\
|
|
15
|
+
examples:
|
|
16
|
+
dkt rs stats for the current folder
|
|
17
|
+
dkt rs --folder "C:\\code\\app" --top 10
|
|
18
|
+
dkt rs --json machine-readable output
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
LANGS = {
|
|
22
|
+
".py": "Python", ".js": "JavaScript", ".mjs": "JavaScript", ".cjs": "JavaScript",
|
|
23
|
+
".jsx": "JavaScript (JSX)", ".ts": "TypeScript", ".tsx": "TypeScript (TSX)",
|
|
24
|
+
".java": "Java", ".kt": "Kotlin", ".c": "C", ".h": "C/C++ Header", ".hpp": "C/C++ Header",
|
|
25
|
+
".cpp": "C++", ".cc": "C++", ".cs": "C#", ".go": "Go", ".rs": "Rust", ".rb": "Ruby",
|
|
26
|
+
".php": "PHP", ".swift": "Swift", ".lua": "Lua", ".sh": "Shell", ".bash": "Shell",
|
|
27
|
+
".ps1": "PowerShell", ".bat": "Batch", ".cmd": "Batch", ".html": "HTML", ".htm": "HTML",
|
|
28
|
+
".css": "CSS", ".scss": "SCSS", ".sass": "SCSS", ".less": "Less", ".json": "JSON",
|
|
29
|
+
".yml": "YAML", ".yaml": "YAML", ".toml": "TOML", ".xml": "XML", ".md": "Markdown",
|
|
30
|
+
".txt": "Text", ".sql": "SQL", ".vue": "Vue", ".svelte": "Svelte", ".dart": "Dart",
|
|
31
|
+
".r": "R", ".mcfunction": "mcfunction", ".ini": "INI", ".cfg": "Config", ".gradle": "Gradle",
|
|
32
|
+
}
|
|
33
|
+
NAMES = {"dockerfile": "Dockerfile", "makefile": "Makefile"}
|
|
34
|
+
MAX_READ = 5 * 1024 * 1024
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def language_of(path: Path) -> str:
|
|
38
|
+
n = path.name.lower()
|
|
39
|
+
if n in NAMES:
|
|
40
|
+
return NAMES[n]
|
|
41
|
+
return LANGS.get(path.suffix.lower(), "Other")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def collect(folder: Path, files: List[Path]) -> dict:
|
|
45
|
+
langs = {} # type: Dict[str, List[int]]
|
|
46
|
+
biggest = []
|
|
47
|
+
total = {"files": 0, "text_files": 0, "lines": 0, "blank": 0, "bytes": 0, "skipped": 0}
|
|
48
|
+
for rel in files:
|
|
49
|
+
full = folder / rel
|
|
50
|
+
try:
|
|
51
|
+
size = full.stat().st_size
|
|
52
|
+
except OSError:
|
|
53
|
+
continue
|
|
54
|
+
total["files"] += 1
|
|
55
|
+
total["bytes"] += size
|
|
56
|
+
if size > MAX_READ:
|
|
57
|
+
total["skipped"] += 1
|
|
58
|
+
continue
|
|
59
|
+
try:
|
|
60
|
+
data = full.read_bytes()
|
|
61
|
+
except OSError:
|
|
62
|
+
continue
|
|
63
|
+
if b"\0" in data[:8192]:
|
|
64
|
+
total["skipped"] += 1
|
|
65
|
+
continue
|
|
66
|
+
lines = data.count(b"\n") + (1 if data and not data.endswith(b"\n") else 0)
|
|
67
|
+
blank = sum(1 for ln in data.splitlines() if not ln.strip())
|
|
68
|
+
lang = language_of(rel)
|
|
69
|
+
entry = langs.setdefault(lang, [0, 0, 0])
|
|
70
|
+
entry[0] += 1
|
|
71
|
+
entry[1] += lines
|
|
72
|
+
entry[2] += lines - blank
|
|
73
|
+
total["text_files"] += 1
|
|
74
|
+
total["lines"] += lines
|
|
75
|
+
total["blank"] += blank
|
|
76
|
+
biggest.append((lines, rel.as_posix(), size))
|
|
77
|
+
biggest.sort(reverse=True)
|
|
78
|
+
return {"langs": langs, "biggest": biggest, "total": total}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def git_stats(folder: Path, top: int, known: set) -> Optional[dict]:
|
|
82
|
+
if not is_git_repo(folder):
|
|
83
|
+
return None
|
|
84
|
+
try:
|
|
85
|
+
commits = int(git(folder, "rev-list", "--count", "HEAD", check=False).strip() or 0)
|
|
86
|
+
except ValueError:
|
|
87
|
+
commits = 0
|
|
88
|
+
if commits == 0:
|
|
89
|
+
return None
|
|
90
|
+
contributors = []
|
|
91
|
+
for line in git(folder, "shortlog", "-sn", "--no-merges", "HEAD", check=False).splitlines():
|
|
92
|
+
if "\t" in line:
|
|
93
|
+
n, name = line.strip().split("\t", 1)
|
|
94
|
+
contributors.append((name.strip(), int(n)))
|
|
95
|
+
roots = [d for d in git(folder, "log", "--max-parents=0", "--format=%ad", "--date=short",
|
|
96
|
+
check=False).split() if d]
|
|
97
|
+
last = git(folder, "log", "-1", "--format=%ad", "--date=short", check=False).strip()
|
|
98
|
+
churn = Counter()
|
|
99
|
+
out = git(folder, "log", "-n", "3000", "--name-only", "--pretty=format:", "--relative", check=False)
|
|
100
|
+
for line in out.splitlines():
|
|
101
|
+
line = line.strip()
|
|
102
|
+
if line and line in known:
|
|
103
|
+
churn[line] += 1
|
|
104
|
+
return {"commits": commits, "contributors": contributors, "first": min(roots) if roots else "?",
|
|
105
|
+
"last": last, "most_changed": churn.most_common(top)}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def health(folder: Path) -> List[tuple]:
|
|
109
|
+
names = {n.lower() for n in os.listdir(str(folder))}
|
|
110
|
+
has_tests = any(n in names for n in ("tests", "test", "__tests__", "spec")) or any(
|
|
111
|
+
n.startswith("test_") for n in names)
|
|
112
|
+
return [
|
|
113
|
+
("README", any(n.startswith("readme") for n in names)),
|
|
114
|
+
("LICENSE", any(n.startswith(("license", "licence", "copying")) for n in names)),
|
|
115
|
+
(".gitignore", ".gitignore" in names),
|
|
116
|
+
("Tests", has_tests),
|
|
117
|
+
("CI config", (folder / ".github" / "workflows").is_dir() or ".gitlab-ci.yml" in names),
|
|
118
|
+
]
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def run(args: argparse.Namespace) -> int:
|
|
122
|
+
folder = resolve_folder(args.folder)
|
|
123
|
+
files = list_project_files(folder)
|
|
124
|
+
data = collect(folder, files)
|
|
125
|
+
known = {f.as_posix() for f in files}
|
|
126
|
+
gs = git_stats(folder, args.top, known)
|
|
127
|
+
hc = health(folder)
|
|
128
|
+
t = data["total"]
|
|
129
|
+
langs = sorted(data["langs"].items(), key=lambda kv: kv[1][1], reverse=True)
|
|
130
|
+
|
|
131
|
+
if args.json:
|
|
132
|
+
print(json.dumps({
|
|
133
|
+
"folder": str(folder), "totals": t,
|
|
134
|
+
"languages": {k: {"files": v[0], "lines": v[1], "code_lines": v[2]} for k, v in langs},
|
|
135
|
+
"largest_files": [{"path": p, "lines": l, "bytes": s} for l, p, s in data["biggest"][:args.top]],
|
|
136
|
+
"git": gs, "health": {k: v for k, v in hc},
|
|
137
|
+
}, indent=2))
|
|
138
|
+
return 0
|
|
139
|
+
|
|
140
|
+
print()
|
|
141
|
+
print(c(" RepoStats", "bold") + " - " + c(folder.name, "cyan"))
|
|
142
|
+
print(c(" " + str(folder), "dim"))
|
|
143
|
+
print()
|
|
144
|
+
print(c(" Overview", "bold"))
|
|
145
|
+
print(" Files %s (%s)" % (human_num(t["files"]), human_size(t["bytes"])))
|
|
146
|
+
print(" Lines %s (code %s, blank %s)" % (
|
|
147
|
+
human_num(t["lines"]), human_num(t["lines"] - t["blank"]), human_num(t["blank"])))
|
|
148
|
+
print(" Languages %d" % len(langs))
|
|
149
|
+
if t["skipped"]:
|
|
150
|
+
print(c(" (%d binary/huge files not counted for lines)" % t["skipped"], "dim"))
|
|
151
|
+
print()
|
|
152
|
+
if langs:
|
|
153
|
+
print(c(" Languages", "bold"))
|
|
154
|
+
mx = max(v[1] for _, v in langs) or 1
|
|
155
|
+
for name, (nf, nl, _) in langs[:12]:
|
|
156
|
+
pct = 100.0 * nl / t["lines"] if t["lines"] else 0
|
|
157
|
+
print(" %-18s %5s %-5s %9s lines %5.1f%% %s" % (name, human_num(nf), "file" if nf == 1 else "files", human_num(nl), pct,
|
|
158
|
+
c(bar(nl, mx, 16), "cyan")))
|
|
159
|
+
print()
|
|
160
|
+
if data["biggest"]:
|
|
161
|
+
print(c(" Largest files (by lines)", "bold"))
|
|
162
|
+
for lines, path, size in data["biggest"][:args.top]:
|
|
163
|
+
print(" %7s %s" % (human_num(lines), path))
|
|
164
|
+
print()
|
|
165
|
+
if gs:
|
|
166
|
+
print(c(" Git", "bold"))
|
|
167
|
+
print(" Commits %s (%s -> %s)" % (human_num(gs["commits"]), gs["first"], gs["last"]))
|
|
168
|
+
print(" Authors %d" % len(gs["contributors"]))
|
|
169
|
+
for name, n in gs["contributors"][:args.top]:
|
|
170
|
+
print(" %5d %s" % (n, name))
|
|
171
|
+
if gs["most_changed"]:
|
|
172
|
+
print()
|
|
173
|
+
print(c(" Most changed files", "bold"))
|
|
174
|
+
for path, n in gs["most_changed"]:
|
|
175
|
+
print(" %5dx %s" % (n, path))
|
|
176
|
+
print()
|
|
177
|
+
print(c(" Health check", "bold"))
|
|
178
|
+
for name, ok in hc:
|
|
179
|
+
print(" %s %s" % (c("✔", "green") if ok else c("✘", "red"), name))
|
|
180
|
+
print()
|
|
181
|
+
return 0
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def register(sub, common) -> None:
|
|
185
|
+
p = sub.add_parser(
|
|
186
|
+
"repostats", aliases=["rs"], parents=[common],
|
|
187
|
+
help="language breakdown, biggest files, git activity",
|
|
188
|
+
description="Quick stats for any project: lines per language, biggest files,\n"
|
|
189
|
+
"most-changed files, contributors and a health checklist.",
|
|
190
|
+
epilog=EPILOG, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
191
|
+
p.add_argument("-f", "--folder", metavar="PATH", help="project folder (default: current folder)")
|
|
192
|
+
p.add_argument("-n", "--top", type=int, default=5, metavar="N", help="rows in top-N lists (default 5)")
|
|
193
|
+
p.add_argument("--json", action="store_true", help="output JSON")
|
|
194
|
+
p.set_defaults(func=run)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
META = ("Project & Code", "repostats", "rs", "lines per language, biggest files, git activity, health")
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""dkt secrets (sec) - scan a project for accidentally committed secrets."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import json
|
|
6
|
+
import math
|
|
7
|
+
import re
|
|
8
|
+
from collections import Counter
|
|
9
|
+
|
|
10
|
+
from ..utils import c, fail, is_binary_chunk, list_project_files, ok, resolve_folder, sym, warn
|
|
11
|
+
|
|
12
|
+
META = ("Project & Code", "secrets", "sec", "scan for leaked API keys, tokens, private keys")
|
|
13
|
+
|
|
14
|
+
EPILOG = """\
|
|
15
|
+
examples:
|
|
16
|
+
dkt sec scan the current project
|
|
17
|
+
dkt sec --entropy also flag random-looking strings assigned to keys
|
|
18
|
+
dkt sec --json for CI; exits 1 when something is found
|
|
19
|
+
Values are always masked in the output.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
PATTERNS = [
|
|
23
|
+
("AWS access key", re.compile(r"\b(AKIA|ASIA)[0-9A-Z]{16}\b")),
|
|
24
|
+
("GitHub token", re.compile(r"\b(ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36,}\b")),
|
|
25
|
+
("GitHub fine-grained token", re.compile(r"\bgithub_pat_[A-Za-z0-9_]{50,}\b")),
|
|
26
|
+
("Slack token", re.compile(r"\bxox[abprs]-[A-Za-z0-9-]{10,}\b")),
|
|
27
|
+
("Stripe live key", re.compile(r"\b[sr]k_live_[A-Za-z0-9]{16,}\b")),
|
|
28
|
+
("Google API key", re.compile(r"\bAIza[0-9A-Za-z_\-]{35}\b")),
|
|
29
|
+
("OpenAI-style key", re.compile(r"\bsk-[A-Za-z0-9_\-]{32,}\b")),
|
|
30
|
+
("Anthropic key", re.compile(r"\bsk-ant-[A-Za-z0-9_\-]{20,}\b")),
|
|
31
|
+
("Private key block", re.compile(r"-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY-----")),
|
|
32
|
+
("JWT", re.compile(r"\beyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b")),
|
|
33
|
+
("Discord bot token", re.compile(r"\b[MNO][A-Za-z\d_-]{23,25}\.[\w-]{6}\.[\w-]{27,}\b")),
|
|
34
|
+
("Connection string with password", re.compile(r"\b[a-z][a-z0-9+.-]*://[^\s:/@]+:[^\s:/@]{3,}@[^\s/]+")),
|
|
35
|
+
]
|
|
36
|
+
ASSIGN_RE = re.compile(r"""(?i)\b([a-z0-9_]*(?:secret|token|passw(?:or)?d|api[_-]?key|apikey|auth)[a-z0-9_]*)\b\s*[:=]\s*['"]([^'"\s]{8,})['"]""")
|
|
37
|
+
SKIP_EXT = {".png", ".jpg", ".jpeg", ".gif", ".ico", ".svg", ".woff", ".woff2", ".ttf", ".pdf", ".zip", ".lock", ".min.js", ".map"}
|
|
38
|
+
PLACEHOLDER = re.compile(r"(?i)(example|your[_-]|xxx|changeme|placeholder|<.*>|\$\{|{{|dummy|sample|test|\*{3,})")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def entropy(s: str) -> float:
|
|
42
|
+
if not s:
|
|
43
|
+
return 0.0
|
|
44
|
+
n = len(s)
|
|
45
|
+
return -sum(v / n * math.log2(v / n) for v in Counter(s).values())
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def masked(v: str) -> str:
|
|
49
|
+
return v[:4] + "*" * 6 + v[-2:] if len(v) > 10 else "*" * len(v)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def run(args) -> int:
|
|
53
|
+
folder = resolve_folder(args.folder)
|
|
54
|
+
findings = []
|
|
55
|
+
scanned = 0
|
|
56
|
+
for rel in list_project_files(folder):
|
|
57
|
+
p = folder / rel
|
|
58
|
+
if p.suffix.lower() in SKIP_EXT or p.name.endswith(".min.js"):
|
|
59
|
+
continue
|
|
60
|
+
try:
|
|
61
|
+
if p.stat().st_size > 1_000_000:
|
|
62
|
+
continue
|
|
63
|
+
raw = p.read_bytes()
|
|
64
|
+
except OSError:
|
|
65
|
+
continue
|
|
66
|
+
if is_binary_chunk(raw[:4096]):
|
|
67
|
+
continue
|
|
68
|
+
scanned += 1
|
|
69
|
+
text = raw.decode("utf-8", errors="replace")
|
|
70
|
+
for i, line in enumerate(text.splitlines(), 1):
|
|
71
|
+
if len(line) > 2000:
|
|
72
|
+
continue
|
|
73
|
+
line_hit = False
|
|
74
|
+
for label, rx in PATTERNS:
|
|
75
|
+
m = rx.search(line)
|
|
76
|
+
if m:
|
|
77
|
+
line_hit = True
|
|
78
|
+
findings.append((str(rel).replace("\\", "/"), i, label, masked(m.group(0))))
|
|
79
|
+
if args.entropy and not line_hit:
|
|
80
|
+
for m in ASSIGN_RE.finditer(line):
|
|
81
|
+
val = m.group(2)
|
|
82
|
+
if not PLACEHOLDER.search(val) and entropy(val) > 3.5:
|
|
83
|
+
findings.append((str(rel).replace("\\", "/"), i, "high-entropy %s" % m.group(1), masked(val)))
|
|
84
|
+
if p.name.startswith(".env") and p.name not in (".env.example", ".env.sample", ".env.template"):
|
|
85
|
+
findings.append((str(rel).replace("\\", "/"), 0, ".env file in project (is it git-ignored?)", ""))
|
|
86
|
+
if args.json:
|
|
87
|
+
print(json.dumps([dict(zip(("file", "line", "type", "value"), f)) for f in findings], indent=2))
|
|
88
|
+
return 1 if findings else 0
|
|
89
|
+
if not findings:
|
|
90
|
+
ok("no secrets found (%d files scanned)" % scanned)
|
|
91
|
+
return 0
|
|
92
|
+
for fpath, line, label, val in findings:
|
|
93
|
+
loc = "%s:%s" % (fpath, line) if line else fpath
|
|
94
|
+
print(" %s %s %s %s" % (c(sym("fail"), "bred"), c(label, "bold"), c(loc, "bcyan"), c(val, "gray")))
|
|
95
|
+
print()
|
|
96
|
+
warn("%d potential secret%s in %d files. Rotate anything real, then remove it from git history." % (
|
|
97
|
+
len(findings), "" if len(findings) == 1 else "s", scanned))
|
|
98
|
+
return 1
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def register(sub, common) -> None:
|
|
102
|
+
p = sub.add_parser("secrets", aliases=["sec"], parents=[common], help=META[3],
|
|
103
|
+
description="Scan for leaked credentials (respects .gitignore).",
|
|
104
|
+
epilog=EPILOG, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
105
|
+
p.add_argument("-f", "--folder", metavar="PATH")
|
|
106
|
+
p.add_argument("--entropy", action="store_true", help="also flag high-entropy values assigned to key/secret/token names")
|
|
107
|
+
p.add_argument("--json", action="store_true")
|
|
108
|
+
p.set_defaults(func=run)
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""dkt serve (sv) - instant static file server with CORS, SPA mode and LAN URL."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import functools
|
|
6
|
+
import http.server
|
|
7
|
+
import socket
|
|
8
|
+
import socketserver
|
|
9
|
+
import threading
|
|
10
|
+
import time
|
|
11
|
+
import webbrowser
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from ..utils import DktError, box, c, resolve_folder, sym
|
|
15
|
+
|
|
16
|
+
META = ("Servers & Network", "serve", "sv", "static file server: CORS, SPA mode, LAN URL, open browser")
|
|
17
|
+
|
|
18
|
+
EPILOG = """\
|
|
19
|
+
examples:
|
|
20
|
+
dkt sv serve the current folder on :8000
|
|
21
|
+
dkt sv -p 3000 --open pick a port and open the browser
|
|
22
|
+
dkt sv public --spa single-page-app mode (unknown paths -> index.html)
|
|
23
|
+
dkt sv --cors --no-cache handy for front-end work
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def lan_ip() -> str:
|
|
28
|
+
try:
|
|
29
|
+
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
30
|
+
s.connect(("10.255.255.255", 1))
|
|
31
|
+
ip = s.getsockname()[0]
|
|
32
|
+
s.close()
|
|
33
|
+
return ip
|
|
34
|
+
except Exception:
|
|
35
|
+
return "127.0.0.1"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def make_handler(root: Path, cors: bool, spa: bool, no_cache: bool, quiet: bool):
|
|
39
|
+
class Handler(http.server.SimpleHTTPRequestHandler):
|
|
40
|
+
def end_headers(self):
|
|
41
|
+
if cors:
|
|
42
|
+
self.send_header("Access-Control-Allow-Origin", "*")
|
|
43
|
+
self.send_header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
|
44
|
+
self.send_header("Access-Control-Allow-Headers", "*")
|
|
45
|
+
if no_cache:
|
|
46
|
+
self.send_header("Cache-Control", "no-store, must-revalidate")
|
|
47
|
+
super().end_headers()
|
|
48
|
+
|
|
49
|
+
def do_OPTIONS(self):
|
|
50
|
+
self.send_response(204)
|
|
51
|
+
self.end_headers()
|
|
52
|
+
|
|
53
|
+
def send_head(self):
|
|
54
|
+
if spa:
|
|
55
|
+
path = Path(self.translate_path(self.path))
|
|
56
|
+
if not path.exists() and "." not in Path(self.path.split("?")[0]).name:
|
|
57
|
+
self.path = "/index.html"
|
|
58
|
+
return super().send_head()
|
|
59
|
+
|
|
60
|
+
def log_message(self, fmt, *args):
|
|
61
|
+
if quiet:
|
|
62
|
+
return
|
|
63
|
+
code = str(args[1]) if len(args) > 1 else ""
|
|
64
|
+
col = "bgreen" if code.startswith("2") else "byellow" if code.startswith(("3", "1")) else "bred"
|
|
65
|
+
print(" %s %s %s" % (c(time.strftime("%H:%M:%S"), "gray"), c(code, col), args[0] if args else ""))
|
|
66
|
+
return functools.partial(Handler, directory=str(root))
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def run(args) -> int:
|
|
70
|
+
root = resolve_folder(args.folder)
|
|
71
|
+
handler = make_handler(root, args.cors, args.spa, args.no_cache, args.quiet)
|
|
72
|
+
|
|
73
|
+
class Server(socketserver.ThreadingMixIn, http.server.HTTPServer):
|
|
74
|
+
daemon_threads = True
|
|
75
|
+
allow_reuse_address = True
|
|
76
|
+
|
|
77
|
+
port = args.port
|
|
78
|
+
srv = None
|
|
79
|
+
for p in range(port, port + 20):
|
|
80
|
+
try:
|
|
81
|
+
srv = Server((args.host, p), handler)
|
|
82
|
+
port = p
|
|
83
|
+
break
|
|
84
|
+
except OSError:
|
|
85
|
+
continue
|
|
86
|
+
if srv is None:
|
|
87
|
+
raise DktError("Ports %d-%d are all busy. Try 'dkt pk --show %d'." % (args.port, args.port + 19, args.port))
|
|
88
|
+
lines = ["%s http://localhost:%d" % (c("local ", "gray"), port)]
|
|
89
|
+
if args.host in ("0.0.0.0", ""):
|
|
90
|
+
lines.append("%s http://%s:%d" % (c("network", "gray"), lan_ip(), port))
|
|
91
|
+
lines += ["", c("serving ", "gray") + str(root), c("Ctrl+C to stop", "gray")]
|
|
92
|
+
if port != args.port:
|
|
93
|
+
lines.insert(0, c("port %d was busy, using %d" % (args.port, port), "yellow"))
|
|
94
|
+
box("dkt serve", lines)
|
|
95
|
+
if args.open:
|
|
96
|
+
threading.Timer(0.4, lambda: webbrowser.open("http://localhost:%d" % port)).start()
|
|
97
|
+
try:
|
|
98
|
+
srv.serve_forever()
|
|
99
|
+
except KeyboardInterrupt:
|
|
100
|
+
print("\n stopped.")
|
|
101
|
+
finally:
|
|
102
|
+
srv.server_close()
|
|
103
|
+
return 0
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def register(sub, common) -> None:
|
|
107
|
+
p = sub.add_parser("serve", aliases=["sv"], parents=[common], help=META[3],
|
|
108
|
+
description="Serve a folder over HTTP.", epilog=EPILOG,
|
|
109
|
+
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
110
|
+
p.add_argument("folder", nargs="?", help="folder to serve (default: current)")
|
|
111
|
+
p.add_argument("-p", "--port", type=int, default=8000)
|
|
112
|
+
p.add_argument("--host", default="0.0.0.0", help="bind address (default 0.0.0.0; use 127.0.0.1 for local only)")
|
|
113
|
+
p.add_argument("--cors", action="store_true", help="send permissive CORS headers")
|
|
114
|
+
p.add_argument("--spa", action="store_true", help="fallback to index.html for unknown routes")
|
|
115
|
+
p.add_argument("--no-cache", action="store_true", help="disable browser caching")
|
|
116
|
+
p.add_argument("--open", action="store_true", help="open in the default browser")
|
|
117
|
+
p.add_argument("-q", "--quiet", action="store_true", help="don't log requests")
|
|
118
|
+
p.set_defaults(func=run)
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""dkt snip - a personal snippet / command notebook that lives in your terminal."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
from ..utils import (DktError, c, config_dir, copy_to_clipboard, info, load_json_file, ok, read_input,
|
|
8
|
+
save_json_file, sym, table)
|
|
9
|
+
|
|
10
|
+
META = ("Data & Text", "snip", "", "save & recall snippets / commands (copy to clipboard)")
|
|
11
|
+
|
|
12
|
+
EPILOG = """\
|
|
13
|
+
examples:
|
|
14
|
+
dkt snip add deploy "ssh me@host 'cd app && git pull'"
|
|
15
|
+
dkt snip add sql-dump --paste save from clipboard
|
|
16
|
+
echo "some text" | dkt snip add note1
|
|
17
|
+
dkt snip get deploy --copy
|
|
18
|
+
dkt snip ls all snippets
|
|
19
|
+
dkt snip ls ssh search names and content
|
|
20
|
+
dkt snip rm deploy
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _path():
|
|
25
|
+
return config_dir() / "snippets.json"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _load():
|
|
29
|
+
return load_json_file(_path(), {})
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def cmd_add(args) -> int:
|
|
33
|
+
text = read_input(args.text, None, args.paste, "snippet text")
|
|
34
|
+
data = _load()
|
|
35
|
+
existed = args.name in data
|
|
36
|
+
data[args.name] = text.rstrip("\n")
|
|
37
|
+
save_json_file(_path(), data)
|
|
38
|
+
ok("%s snippet '%s' (%d chars)" % ("updated" if existed else "saved", args.name, len(text)))
|
|
39
|
+
return 0
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def cmd_get(args) -> int:
|
|
43
|
+
data = _load()
|
|
44
|
+
if args.name not in data:
|
|
45
|
+
close = [k for k in data if args.name.lower() in k.lower()]
|
|
46
|
+
raise DktError("No snippet named '%s'.%s" % (args.name, (" Did you mean: " + ", ".join(close)) if close else ""))
|
|
47
|
+
print(data[args.name])
|
|
48
|
+
if args.copy and copy_to_clipboard(data[args.name]):
|
|
49
|
+
info(c("%s copied to clipboard" % sym("ok"), "green"))
|
|
50
|
+
return 0
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def cmd_ls(args) -> int:
|
|
54
|
+
data = _load()
|
|
55
|
+
q = (args.query or "").lower()
|
|
56
|
+
rows = [(c(k, "bold bcyan"), v.replace("\n", " ")[:70]) for k, v in sorted(data.items())
|
|
57
|
+
if not q or q in k.lower() or q in v.lower()]
|
|
58
|
+
if not rows:
|
|
59
|
+
print(" no snippets%s" % (" matching '%s'" % q if q else " yet - add one with: dkt snip add NAME TEXT"))
|
|
60
|
+
return 0
|
|
61
|
+
table(["NAME", "PREVIEW"], rows)
|
|
62
|
+
return 0
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def cmd_rm(args) -> int:
|
|
66
|
+
data = _load()
|
|
67
|
+
if args.name not in data:
|
|
68
|
+
raise DktError("No snippet named '%s'." % args.name)
|
|
69
|
+
del data[args.name]
|
|
70
|
+
save_json_file(_path(), data)
|
|
71
|
+
ok("removed '%s'" % args.name)
|
|
72
|
+
return 0
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def register(sub, common) -> None:
|
|
76
|
+
p = sub.add_parser("snip", parents=[common], help=META[3], description="Personal snippet store.",
|
|
77
|
+
epilog=EPILOG, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
78
|
+
p.set_defaults(func=lambda a: (p.print_help() or 0))
|
|
79
|
+
g = p.add_subparsers(dest="sub", metavar="<add|get|ls|rm>")
|
|
80
|
+
a = g.add_parser("add", help="save a snippet")
|
|
81
|
+
a.add_argument("name")
|
|
82
|
+
a.add_argument("text", nargs="?")
|
|
83
|
+
a.add_argument("--paste", action="store_true", help="take text from the clipboard")
|
|
84
|
+
a.set_defaults(func=cmd_add)
|
|
85
|
+
gt = g.add_parser("get", help="print (and optionally copy) a snippet")
|
|
86
|
+
gt.add_argument("name")
|
|
87
|
+
gt.add_argument("--copy", action="store_true")
|
|
88
|
+
gt.set_defaults(func=cmd_get)
|
|
89
|
+
ls = g.add_parser("ls", aliases=["list"], help="list / search snippets")
|
|
90
|
+
ls.add_argument("query", nargs="?")
|
|
91
|
+
ls.set_defaults(func=cmd_ls)
|
|
92
|
+
rm = g.add_parser("rm", help="delete a snippet")
|
|
93
|
+
rm.add_argument("name")
|
|
94
|
+
rm.set_defaults(func=cmd_rm)
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""dkt update / config / completion / about - the toolkit's own housekeeping commands."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import json
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
import urllib.request
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from ..utils import (DktError, Spinner, banner, box, c, config_dir, info, kv, load_config, ok, save_json_file,
|
|
12
|
+
sym, warn)
|
|
13
|
+
|
|
14
|
+
META = ("dkt itself", "update", "", "upgrade dkt to the newest version from PyPI")
|
|
15
|
+
|
|
16
|
+
PKG = "dktcli"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _ver(s: str):
|
|
20
|
+
out = []
|
|
21
|
+
for part in s.split("."):
|
|
22
|
+
digits = "".join(ch for ch in part if ch.isdigit())
|
|
23
|
+
out.append(int(digits) if digits else 0)
|
|
24
|
+
return tuple(out)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def latest_version(timeout: float = 6.0):
|
|
28
|
+
req = urllib.request.Request("https://pypi.org/pypi/%s/json" % PKG, headers={"User-Agent": "dkt-update"})
|
|
29
|
+
with urllib.request.urlopen(req, timeout=timeout) as r:
|
|
30
|
+
return json.loads(r.read().decode())["info"]["version"]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def source_checkout():
|
|
34
|
+
root = Path(__file__).resolve().parents[3]
|
|
35
|
+
return root if (root / ".git").exists() and (root / "pyproject.toml").exists() else None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def run_update(args) -> int:
|
|
39
|
+
from .. import __version__
|
|
40
|
+
src = source_checkout()
|
|
41
|
+
if src and not args.force:
|
|
42
|
+
warn("dkt is running from a source checkout (%s)." % src)
|
|
43
|
+
print(" Update it with: update.bat (or: git pull && pip install -e .)")
|
|
44
|
+
return 0
|
|
45
|
+
try:
|
|
46
|
+
with Spinner("checking PyPI"):
|
|
47
|
+
latest = latest_version()
|
|
48
|
+
except Exception:
|
|
49
|
+
latest = None
|
|
50
|
+
if latest:
|
|
51
|
+
if _ver(latest) <= _ver(__version__) and not args.force:
|
|
52
|
+
ok("already up to date (v%s)" % __version__)
|
|
53
|
+
return 0
|
|
54
|
+
print(" v%s %s v%s" % (__version__, c(sym("arrow"), "bcyan"), c(latest, "bold bgreen")))
|
|
55
|
+
else:
|
|
56
|
+
info(c("could not reach PyPI - trying pip anyway", "yellow"))
|
|
57
|
+
if args.check:
|
|
58
|
+
return 0
|
|
59
|
+
with Spinner("upgrading"):
|
|
60
|
+
r = subprocess.run([sys.executable, "-m", "pip", "install", "--upgrade", PKG],
|
|
61
|
+
stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
|
|
62
|
+
if r.returncode != 0:
|
|
63
|
+
print(r.stdout.decode("utf-8", errors="replace")[-1500:])
|
|
64
|
+
raise DktError("pip upgrade failed. Try: python -m pip install --upgrade %s" % PKG)
|
|
65
|
+
ok("updated! Run 'dkt --version' to confirm.")
|
|
66
|
+
return 0
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def run_config(args) -> int:
|
|
70
|
+
cfg = load_config()
|
|
71
|
+
path = config_dir() / "config.json"
|
|
72
|
+
act = args.action or "list"
|
|
73
|
+
if act == "path":
|
|
74
|
+
print(path)
|
|
75
|
+
elif act == "list":
|
|
76
|
+
if not cfg:
|
|
77
|
+
print(" no settings yet. Try: dkt config set author \"Your Name\"")
|
|
78
|
+
print(c(" known keys: author, model (Ollama model for commit writer)", "gray"))
|
|
79
|
+
else:
|
|
80
|
+
kv(sorted(cfg.items()), key_color="bcyan")
|
|
81
|
+
elif act == "get":
|
|
82
|
+
if not args.key or args.key not in cfg:
|
|
83
|
+
raise DktError("Setting '%s' is not set." % args.key)
|
|
84
|
+
print(cfg[args.key])
|
|
85
|
+
elif act == "set":
|
|
86
|
+
if not args.key or args.value is None:
|
|
87
|
+
raise DktError("Usage: dkt config set KEY VALUE")
|
|
88
|
+
cfg[args.key] = args.value
|
|
89
|
+
save_json_file(path, cfg)
|
|
90
|
+
ok("%s = %s" % (args.key, args.value))
|
|
91
|
+
elif act == "unset":
|
|
92
|
+
if args.key in cfg:
|
|
93
|
+
del cfg[args.key]
|
|
94
|
+
save_json_file(path, cfg)
|
|
95
|
+
ok("removed %s" % args.key)
|
|
96
|
+
return 0
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def run_about(args) -> int:
|
|
100
|
+
from .. import __version__
|
|
101
|
+
banner()
|
|
102
|
+
box("tips", [
|
|
103
|
+
"%s %s" % (c("dkt menu", "bold bcyan"), "interactive launcher"),
|
|
104
|
+
"%s %s" % (c("dkt <tool> -h", "bold bcyan"), "help + examples for any tool"),
|
|
105
|
+
"%s %s" % (c("dkt doctor", "bold bcyan"), "check your dev environment"),
|
|
106
|
+
"%s %s" % (c("dkt completion", "bold bcyan"), "tab-completion for your shell"),
|
|
107
|
+
"%s %s" % (c("dkt update", "bold bcyan"), "get the latest version"),
|
|
108
|
+
])
|
|
109
|
+
return 0
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _names(parser_sub):
|
|
113
|
+
return sorted(parser_sub.choices)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def run_completion(args, sub) -> int:
|
|
117
|
+
names = " ".join(_names(sub))
|
|
118
|
+
shell = args.shell
|
|
119
|
+
if shell == "bash":
|
|
120
|
+
print('_dkt() { COMPREPLY=( $(compgen -W "%s" -- "${COMP_WORDS[1]}") ); }\ncomplete -F _dkt dkt' % names)
|
|
121
|
+
print("# add to ~/.bashrc: eval \"$(dkt completion bash)\"", file=sys.stderr)
|
|
122
|
+
elif shell == "zsh":
|
|
123
|
+
print("_dkt() { reply=(%s) }\ncompctl -K _dkt dkt" % names)
|
|
124
|
+
print("# add to ~/.zshrc: eval \"$(dkt completion zsh)\"", file=sys.stderr)
|
|
125
|
+
else:
|
|
126
|
+
quoted = ",".join("'%s'" % n for n in _names(sub))
|
|
127
|
+
print("Register-ArgumentCompleter -Native -CommandName dkt -ScriptBlock {\n"
|
|
128
|
+
" param($wordToComplete, $commandAst, $cursorPosition)\n"
|
|
129
|
+
" @(%s) | Where-Object { $_ -like \"$wordToComplete*\" } |\n"
|
|
130
|
+
" ForEach-Object { [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_) }\n"
|
|
131
|
+
"}" % quoted)
|
|
132
|
+
print("# add to your profile: dkt completion powershell | Out-String | Add-Content $PROFILE", file=sys.stderr)
|
|
133
|
+
return 0
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def register(sub, common) -> None:
|
|
137
|
+
u = sub.add_parser("update", aliases=["upgrade"], parents=[common], help=META[3],
|
|
138
|
+
description="Upgrade dkt using pip.")
|
|
139
|
+
u.add_argument("--check", action="store_true", help="only check for a newer version")
|
|
140
|
+
u.add_argument("--force", action="store_true", help="reinstall even if up to date / from source")
|
|
141
|
+
u.set_defaults(func=run_update)
|
|
142
|
+
|
|
143
|
+
cf = sub.add_parser("config", parents=[common], help="view or change dkt settings",
|
|
144
|
+
description="Settings live in a small JSON file in your user config folder.")
|
|
145
|
+
cf.add_argument("action", nargs="?", choices=["list", "get", "set", "unset", "path"])
|
|
146
|
+
cf.add_argument("key", nargs="?")
|
|
147
|
+
cf.add_argument("value", nargs="?")
|
|
148
|
+
cf.set_defaults(func=run_config)
|
|
149
|
+
|
|
150
|
+
cp = sub.add_parser("completion", parents=[common], help="print shell tab-completion script",
|
|
151
|
+
description="Print a completion script for your shell.")
|
|
152
|
+
cp.add_argument("shell", nargs="?", choices=["powershell", "bash", "zsh"],
|
|
153
|
+
default="powershell" if sys.platform.startswith("win") else "bash")
|
|
154
|
+
cp.set_defaults(func=lambda a: run_completion(a, sub))
|
|
155
|
+
|
|
156
|
+
ab = sub.add_parser("about", parents=[common], help="logo, version and tips")
|
|
157
|
+
ab.set_defaults(func=run_about)
|