refactor: restructure mytoolkit with new subcommands and md-to-pdf improvements
- Add new subcommands: convert, preflight, server, templates, webpage - Migrate config from bin/config.json to ~/.config/mytoolkit - Fix expand_bookmarks to modify writer objects instead of reader - Improve md_to_pdf with CJK bookmark support - Update README and project metadata
This commit is contained in:
@@ -4,6 +4,8 @@ from . import pdf, image, latex, video, bib, utils
|
||||
from .env import env_cmd
|
||||
from .ssh import ssh_cmd
|
||||
from .git import git_cmd
|
||||
from .server import server_cmd
|
||||
from .webpage import webpage_cmd
|
||||
from .self_mgmt import self_cmd, update_cmd, uninstall_cmd
|
||||
|
||||
__all__ = ["pdf", "image", "latex", "video", "bib", "utils", "env_cmd", "ssh_cmd", "git_cmd", "self_cmd", "update_cmd", "uninstall_cmd"]
|
||||
__all__ = ["pdf", "image", "latex", "video", "bib", "utils", "env_cmd", "ssh_cmd", "git_cmd", "server_cmd", "webpage_cmd", "self_cmd", "update_cmd", "uninstall_cmd"]
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
"""Universal format conversion."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
from pypdf import PdfReader, PdfWriter
|
||||
from PIL import Image
|
||||
|
||||
from bin.md_to_pdf import build_pdf
|
||||
from bin import templates_registry
|
||||
from bin.utils import handle_errors, run_command
|
||||
|
||||
|
||||
@click.command("convert")
|
||||
@click.argument("inputs", nargs=-1, required=True)
|
||||
@click.option("-o", "--output", required=True, help="Output file path")
|
||||
@click.option(
|
||||
"-t",
|
||||
"--template",
|
||||
type=click.Choice(["default", "cv", "textbook", "manual", "review"]),
|
||||
default="default",
|
||||
help="Template for md→pdf / md→docx",
|
||||
)
|
||||
@click.option(
|
||||
"-q",
|
||||
"--quality",
|
||||
type=click.Choice(["screen", "ebook", "printer", "prepress"]),
|
||||
default="screen",
|
||||
help="PDF compression quality",
|
||||
)
|
||||
@click.option("-d", "--density", default="300", help="DPI for PDF→TIFF")
|
||||
@click.option("--reference-doc", help="Reference docx template for md→docx")
|
||||
@click.option(
|
||||
"--bookmark-depth",
|
||||
type=int,
|
||||
default=1,
|
||||
help="PDF bookmark expansion depth (md→pdf only). 1=top-level only, 2=expand to second level, etc.",
|
||||
)
|
||||
@click.option("--dry-run", "-n", is_flag=True, help="Show what would be done")
|
||||
@handle_errors
|
||||
def convert_cmd(inputs, output, template, quality, density, reference_doc, bookmark_depth, dry_run):
|
||||
"""Universal format conversion. Auto-detects from file extensions.
|
||||
|
||||
\b
|
||||
Examples:
|
||||
mytoolkit convert doc.md -o doc.pdf
|
||||
mytoolkit convert doc.md -o doc.pdf -t cv
|
||||
mytoolkit convert doc.md -o doc.docx
|
||||
mytoolkit convert doc.md -o doc.docx -t review
|
||||
mytoolkit convert *.jpg -o album.pdf
|
||||
mytoolkit convert fig.eps -o fig.pdf
|
||||
mytoolkit convert scan.tiff -o scan.pdf
|
||||
mytoolkit convert scan.pdf -o scan.tiff
|
||||
mytoolkit convert video.avi -o video.mp4
|
||||
"""
|
||||
out_path = Path(output)
|
||||
out_ext = out_path.suffix.lower()
|
||||
|
||||
# Resolve input globs
|
||||
input_paths = []
|
||||
for pattern in inputs:
|
||||
p = Path(pattern)
|
||||
if p.exists():
|
||||
input_paths.append(p)
|
||||
else:
|
||||
# Try glob
|
||||
matches = list(Path(".").glob(pattern))
|
||||
input_paths.extend(sorted(matches))
|
||||
|
||||
if not input_paths:
|
||||
click.secho("No input files found", fg="red", err=True)
|
||||
raise SystemExit(1)
|
||||
|
||||
# --- pdf → pdf (compress) ---
|
||||
if len(input_paths) == 1 and input_paths[0].suffix.lower() == ".pdf" and out_ext == ".pdf":
|
||||
if dry_run:
|
||||
click.echo(f"Would compress {input_paths[0]} to {output} (quality={quality})")
|
||||
return
|
||||
run_command(
|
||||
[
|
||||
"gs",
|
||||
"-sDEVICE=pdfwrite",
|
||||
"-dNOPAUSE",
|
||||
"-dQUIET",
|
||||
"-dBATCH",
|
||||
f"-dPDFSETTINGS=/{quality}",
|
||||
"-dCompatibilityLevel=1.4",
|
||||
f"-sOutputFile={out_path}",
|
||||
str(input_paths[0]),
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
click.secho(f"Compressed PDF: {output}", fg="green")
|
||||
return
|
||||
|
||||
# --- md → docx ---
|
||||
if all(p.suffix.lower() == ".md" for p in input_paths) and out_ext == ".docx":
|
||||
ref_doc = reference_doc
|
||||
if not ref_doc and template != "default":
|
||||
ref_path = templates_registry.get_md_to_docx_root() / template / "template.docx"
|
||||
if ref_path.exists():
|
||||
ref_doc = str(ref_path)
|
||||
else:
|
||||
click.secho(f"Docx template not found for '{template}': {ref_path}", fg="red", err=True)
|
||||
raise SystemExit(1)
|
||||
if dry_run:
|
||||
msg = f"Would convert {len(input_paths)} markdown file(s) to {output}"
|
||||
if ref_doc:
|
||||
msg += f" (template={ref_doc})"
|
||||
click.echo(msg)
|
||||
return
|
||||
cmd = ["pandoc"] + [str(p) for p in input_paths] + ["-o", str(out_path)]
|
||||
if ref_doc:
|
||||
cmd.extend(["--reference-doc", ref_doc])
|
||||
run_command(cmd, check=True)
|
||||
click.secho(f"Word document generated: {output}", fg="green")
|
||||
return
|
||||
|
||||
# --- md → pdf ---
|
||||
if all(p.suffix.lower() == ".md" for p in input_paths) and out_ext == ".pdf":
|
||||
if template == "review":
|
||||
click.secho("Template 'review' is only available for md→docx", fg="red", err=True)
|
||||
raise SystemExit(1)
|
||||
if dry_run:
|
||||
click.echo(f"Would convert {len(input_paths)} markdown file(s) to {output}")
|
||||
return
|
||||
build_pdf(template, input_paths, out_path, bookmark_depth=bookmark_depth)
|
||||
click.secho(f"PDF generated: {output}", fg="green")
|
||||
return
|
||||
|
||||
# --- images → pdf ---
|
||||
img_exts = (".jpg", ".jpeg", ".png", ".tif", ".tiff")
|
||||
if all(p.suffix.lower() in img_exts for p in input_paths) and out_ext == ".pdf":
|
||||
if dry_run:
|
||||
click.echo(f"Would convert {len(input_paths)} image(s) to {output}")
|
||||
return
|
||||
_images_to_pdf(input_paths, out_path)
|
||||
click.secho(f"PDF generated: {output}", fg="green")
|
||||
return
|
||||
|
||||
# --- eps → pdf ---
|
||||
if len(input_paths) == 1 and input_paths[0].suffix.lower() in (".eps", ".ps") and out_ext == ".pdf":
|
||||
if dry_run:
|
||||
click.echo(f"Would convert {input_paths[0]} to {output}")
|
||||
return
|
||||
run_command(["epstopdf", str(input_paths[0]), "--outfile", str(out_path)], check=True)
|
||||
click.secho(f"PDF generated: {output}", fg="green")
|
||||
return
|
||||
|
||||
# --- pdf → tiff ---
|
||||
if len(input_paths) == 1 and input_paths[0].suffix.lower() == ".pdf" and out_ext in (".tiff", ".tif"):
|
||||
if dry_run:
|
||||
click.echo(f"Would convert {input_paths[0]} to {output}")
|
||||
return
|
||||
run_command(
|
||||
["convert", "-density", density, str(input_paths[0]), "-compress", "zip", str(out_path)],
|
||||
check=False,
|
||||
)
|
||||
click.secho(f"TIFF generated: {output}", fg="green")
|
||||
return
|
||||
|
||||
# --- avi → mp4 ---
|
||||
if len(input_paths) == 1 and input_paths[0].suffix.lower() == ".avi" and out_ext == ".mp4":
|
||||
if dry_run:
|
||||
click.echo(f"Would convert {input_paths[0]} to {output}")
|
||||
return
|
||||
run_command(
|
||||
["ffmpeg", "-i", str(input_paths[0]), "-c:v", "libx264", "-c:a", "aac", str(out_path)],
|
||||
check=False,
|
||||
)
|
||||
click.secho(f"MP4 generated: {output}", fg="green")
|
||||
return
|
||||
|
||||
# --- bib → md ---
|
||||
if len(input_paths) == 1 and input_paths[0].suffix.lower() == ".bib" and out_ext == ".md":
|
||||
if dry_run:
|
||||
click.echo(f"Would convert {input_paths[0]} to {output}")
|
||||
return
|
||||
content = input_paths[0].read_text()
|
||||
md_content = f"# Bibliography\n\n```bibtex\n{content}\n```\n"
|
||||
out_path.write_text(md_content)
|
||||
click.secho(f"Markdown generated: {output}", fg="green")
|
||||
return
|
||||
|
||||
click.secho(
|
||||
f"Unsupported conversion: {[p.suffix for p in input_paths]} → {out_ext}",
|
||||
fg="red",
|
||||
err=True,
|
||||
)
|
||||
raise SystemExit(1)
|
||||
|
||||
|
||||
def _images_to_pdf(images: list[Path], output: Path) -> None:
|
||||
"""Merge images into a single PDF."""
|
||||
writer = PdfWriter()
|
||||
for img_path in images:
|
||||
img = Image.open(img_path)
|
||||
if img.mode in ("RGBA", "P"):
|
||||
img = img.convert("RGB")
|
||||
temp_pdf = img_path.with_suffix(".temp.pdf")
|
||||
img.save(temp_pdf, "PDF", resolution=150.0)
|
||||
reader = PdfReader(str(temp_pdf))
|
||||
writer.add_page(reader.pages[0])
|
||||
temp_pdf.unlink()
|
||||
with open(output, "wb") as f:
|
||||
writer.write(f)
|
||||
+3
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
import click
|
||||
|
||||
from bin.config import config
|
||||
from bin.config import CONFIG_PATH, config
|
||||
|
||||
|
||||
@click.group(name="env")
|
||||
@@ -14,10 +14,12 @@ def env_cmd():
|
||||
@env_cmd.command("list")
|
||||
def env_list():
|
||||
"""List all vars."""
|
||||
click.echo(f"Storage: {CONFIG_PATH}")
|
||||
vars = config.get_all()
|
||||
if not vars:
|
||||
click.echo("No vars configured.")
|
||||
return
|
||||
click.echo()
|
||||
for name, value in sorted(vars.items()):
|
||||
masked = "***" if any(x in name.lower() for x in ["key", "secret", "token", "pass"]) else value
|
||||
click.echo(f"{name:20} = {masked}")
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
"""Image utilities."""
|
||||
|
||||
import base64
|
||||
import os
|
||||
import subprocess
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
|
||||
from bin.config import config
|
||||
from bin.utils import handle_errors, run_command
|
||||
|
||||
|
||||
@@ -181,3 +185,52 @@ def compress_images(files, target_mb, max_width, quality, suffix, dry_run):
|
||||
size_mb = output.stat().st_size / (1024 * 1024)
|
||||
|
||||
click.echo(f"Compressed: {img_file} -> {output} ({size_mb:.2f} MB)")
|
||||
|
||||
|
||||
@image.command("generate")
|
||||
@click.argument("prompt", required=False)
|
||||
@click.option("-f", "--file", type=click.Path(exists=True), help="从文件读取 prompt")
|
||||
@click.option("-s", "--size", default="2K", type=click.Choice(["1K", "2K"]), help="图片尺寸")
|
||||
@click.option("-o", "--output", help="保存路径(默认只输出 URL)")
|
||||
@click.option("--no-watermark", is_flag=True, help="不添加豆包水印")
|
||||
@click.option("--b64", is_flag=True, help="使用 b64_json 格式获取图片数据")
|
||||
@handle_errors
|
||||
def generate_image_cmd(prompt, file, size, output, no_watermark, b64):
|
||||
"""使用豆包/Seedream 模型生成图片。"""
|
||||
from openai import OpenAI
|
||||
|
||||
if file:
|
||||
prompt = Path(file).read_text(encoding="utf-8").strip()
|
||||
elif not prompt:
|
||||
raise click.UsageError("必须提供 prompt 或使用 -f/--file 从文件读取")
|
||||
|
||||
api_key = config.get("apikey_ark")
|
||||
if not api_key:
|
||||
click.echo("Error: apikey_ark not set. Run: mytoolkit env set apikey_ark <value>", err=True)
|
||||
raise click.Abort()
|
||||
|
||||
client = OpenAI(
|
||||
base_url="https://ark.cn-beijing.volces.com/api/v3",
|
||||
api_key=api_key,
|
||||
)
|
||||
|
||||
resp = client.images.generate(
|
||||
model="doubao-seedream-5-0-260128",
|
||||
prompt=prompt,
|
||||
size=size,
|
||||
response_format="b64_json" if b64 else "url",
|
||||
extra_body={"watermark": not no_watermark},
|
||||
)
|
||||
|
||||
if b64:
|
||||
image_bytes = base64.b64decode(resp.data[0].b64_json)
|
||||
save_path = output or "generated_image.png"
|
||||
Path(save_path).write_bytes(image_bytes)
|
||||
click.echo(os.path.abspath(save_path))
|
||||
else:
|
||||
url = resp.data[0].url
|
||||
if output:
|
||||
urllib.request.urlretrieve(url, output)
|
||||
click.echo(os.path.abspath(output))
|
||||
else:
|
||||
click.echo(url)
|
||||
|
||||
+44
-2
@@ -1,11 +1,13 @@
|
||||
"""PDF utilities."""
|
||||
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
from pypdf import PdfReader, PdfWriter
|
||||
from PIL import Image
|
||||
|
||||
from bin.md_to_pdf import expand_bookmarks
|
||||
from bin.utils import handle_errors, run_command
|
||||
|
||||
|
||||
@@ -24,9 +26,15 @@ def pdf():
|
||||
default="screen",
|
||||
help="Compression quality level",
|
||||
)
|
||||
@click.option(
|
||||
"--bookmark-depth",
|
||||
type=int,
|
||||
default=1,
|
||||
help="Expand PDF bookmarks to this depth after compression (1=no expansion).",
|
||||
)
|
||||
@click.option("--dry-run", "-n", is_flag=True, help="Show what would be done")
|
||||
@handle_errors
|
||||
def compress(files, quality, dry_run):
|
||||
def compress(files, quality, bookmark_depth, dry_run):
|
||||
"""Compress PDF files using ghostscript."""
|
||||
for pattern in files:
|
||||
for pdf_file in Path(".").glob(pattern):
|
||||
@@ -53,6 +61,8 @@ def compress(files, quality, dry_run):
|
||||
click.echo(f"Compressing: {pdf_file}")
|
||||
run_command(cmd, check=True)
|
||||
output.replace(pdf_file)
|
||||
if bookmark_depth > 1:
|
||||
expand_bookmarks(pdf_file, bookmark_depth)
|
||||
click.echo(f" Done: {pdf_file}")
|
||||
|
||||
|
||||
@@ -102,9 +112,15 @@ def to_tiff(files, density, dry_run):
|
||||
@pdf.command("merge")
|
||||
@click.argument("files", nargs=-1, required=True)
|
||||
@click.option("--output", "-o", required=True, help="Output PDF file")
|
||||
@click.option(
|
||||
"--bookmark-depth",
|
||||
type=int,
|
||||
default=1,
|
||||
help="Expand PDF bookmarks to this depth after merge (1=no expansion).",
|
||||
)
|
||||
@click.option("--dry-run", "-n", is_flag=True, help="Show what would be done")
|
||||
@handle_errors
|
||||
def merge_pdfs(files, output, dry_run):
|
||||
def merge_pdfs(files, output, bookmark_depth, dry_run):
|
||||
"""Merge PDFs and/or images into a single PDF."""
|
||||
writer = PdfWriter()
|
||||
processed = []
|
||||
@@ -144,4 +160,30 @@ def merge_pdfs(files, output, dry_run):
|
||||
|
||||
with open(output, "wb") as f:
|
||||
writer.write(f)
|
||||
if bookmark_depth > 1:
|
||||
expand_bookmarks(Path(output), bookmark_depth)
|
||||
click.echo(f"Merged {len(processed)} files into {output}")
|
||||
|
||||
|
||||
@pdf.command("bookmark")
|
||||
@click.argument("input_file", type=click.Path(exists=True, dir_okay=False, path_type=Path))
|
||||
@click.option(
|
||||
"--depth",
|
||||
type=int,
|
||||
required=True,
|
||||
help="Bookmark expansion depth. 1=top-level only, 2=expand to second level, etc.",
|
||||
)
|
||||
@click.option(
|
||||
"-o",
|
||||
"--output",
|
||||
type=click.Path(dir_okay=False, path_type=Path),
|
||||
help="Output PDF path. Defaults to overwriting the input file.",
|
||||
)
|
||||
@handle_errors
|
||||
def bookmark(input_file, depth, output):
|
||||
"""Set PDF bookmark outline expansion depth."""
|
||||
target = output if output else input_file
|
||||
if output and output.resolve() != input_file.resolve():
|
||||
shutil.copy2(str(input_file), str(target))
|
||||
expand_bookmarks(target, depth)
|
||||
click.echo(f"Bookmarks expanded to depth {depth}: {target}")
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
"""Pre-flight check for work-order consistency and system health."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
|
||||
|
||||
@click.command(name="preflight")
|
||||
def preflight_cmd():
|
||||
"""Run pre-flight checks before executing work orders."""
|
||||
issues = []
|
||||
warnings = []
|
||||
|
||||
click.secho("Running pre-flight checks...", fg="cyan")
|
||||
click.echo("")
|
||||
|
||||
# === Check 1: work-order.md vs checkpoint JSON consistency ===
|
||||
work_order = Path.home() / "workspace/assistant/work-order.md"
|
||||
|
||||
if work_order.exists():
|
||||
content = work_order.read_text()
|
||||
|
||||
# Find progress numbers like "进度: X/Y 步骤"
|
||||
progress_match = re.search(r"进度[::]\s*(\d+)/(\d+)\s*步骤", content)
|
||||
if progress_match:
|
||||
wo_current = int(progress_match.group(1))
|
||||
wo_total = int(progress_match.group(2))
|
||||
|
||||
# Find checkpoint files referenced in work-order
|
||||
cp_match = re.search(r"进度档[::]\s*(\S+)", content)
|
||||
if cp_match:
|
||||
cp_path = Path.home() / "workspace" / cp_match.group(1)
|
||||
if cp_path.exists():
|
||||
try:
|
||||
cp = json.loads(cp_path.read_text())
|
||||
cp_current = cp.get("currentStep", 0)
|
||||
cp_total = cp.get("totalSteps", 0)
|
||||
cp_completed = len(cp.get("completedSteps", []))
|
||||
|
||||
if wo_current != cp_current or wo_total != cp_total:
|
||||
issues.append(
|
||||
f"进度不一致: 工作单 {wo_current}/{wo_total} vs 进度档 {cp_current}/{cp_total}"
|
||||
)
|
||||
elif cp_current != cp_completed + 1 and cp_current <= cp_total:
|
||||
# currentStep should be next step to do (completed + 1)
|
||||
# unless all completed
|
||||
pass
|
||||
except json.JSONDecodeError:
|
||||
warnings.append(f"进度档 JSON 损坏: {cp_path}")
|
||||
|
||||
# Check for duplicate step numbers in step list
|
||||
step_lines = re.findall(r"-\s*\[[x ]\]\s*\d+\.", content)
|
||||
step_nums = []
|
||||
for line in step_lines:
|
||||
m = re.search(r"(\d+)\.", line)
|
||||
if m:
|
||||
step_nums.append(int(m.group(1)))
|
||||
|
||||
seen = set()
|
||||
for num in step_nums:
|
||||
if num in seen:
|
||||
issues.append(f"步骤 {num} 在清单中重复出现")
|
||||
seen.add(num)
|
||||
|
||||
# Check for gaps in step numbering
|
||||
if step_nums:
|
||||
expected = list(range(1, max(step_nums) + 1))
|
||||
missing = [n for n in expected if n not in seen]
|
||||
if missing:
|
||||
warnings.append(f"步骤编号缺失: {missing}")
|
||||
else:
|
||||
warnings.append("工作单文件不存在")
|
||||
|
||||
# === Check 2: .zshrc PATH references ===
|
||||
zshrc = Path.home() / ".zshrc"
|
||||
if zshrc.exists():
|
||||
zshrc_content = zshrc.read_text()
|
||||
# Find paths in PATH exports (lines starting with export PATH=)
|
||||
for line in zshrc_content.splitlines():
|
||||
if not line.strip().startswith("export PATH="):
|
||||
continue
|
||||
refs = re.findall(r"/Users/\S+", line)
|
||||
for ref in refs:
|
||||
full_path = Path(ref).expanduser()
|
||||
if not full_path.exists():
|
||||
issues.append(f".zshrc PATH 引用失效: {ref}")
|
||||
else:
|
||||
warnings.append(".zshrc 不存在")
|
||||
|
||||
# === Check 3: Workspace symlink health ===
|
||||
workspace = Path.home() / "workspace"
|
||||
if workspace.exists():
|
||||
for item in workspace.iterdir():
|
||||
if item.is_symlink():
|
||||
if not item.exists():
|
||||
issues.append(f"符号链接失效: {item.name} -> {os.readlink(item)}")
|
||||
|
||||
# === Report ===
|
||||
click.echo("-" * 50)
|
||||
if issues:
|
||||
click.secho(f"发现 {len(issues)} 个问题:", fg="red")
|
||||
for issue in issues:
|
||||
click.secho(f" [x] {issue}", fg="red")
|
||||
else:
|
||||
click.secho("未发现严重问题", fg="green")
|
||||
|
||||
if warnings:
|
||||
click.secho(f"\n{len(warnings)} 个警告:", fg="yellow")
|
||||
for w in warnings:
|
||||
click.secho(f" [!] {w}", fg="yellow")
|
||||
|
||||
click.echo("-" * 50)
|
||||
|
||||
if issues:
|
||||
sys.exit(1)
|
||||
+86
-37
@@ -1,60 +1,110 @@
|
||||
"""Self-management commands for bin."""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
|
||||
|
||||
def _get_project_root() -> Path:
|
||||
"""Get mytoolkit project root."""
|
||||
# This file is at bin/commands/self_mgmt.py
|
||||
"""Get mytoolkit project root from source tree."""
|
||||
return Path(__file__).parent.parent.parent
|
||||
|
||||
|
||||
def _run(cmd: list[str], cwd: Path | None = None, check: bool = True) -> None:
|
||||
result = subprocess.run(cmd, cwd=str(cwd) if cwd else None, check=False)
|
||||
if check and result.returncode != 0:
|
||||
raise click.Exit(result.returncode)
|
||||
|
||||
|
||||
def _has_uv() -> bool:
|
||||
return shutil.which("uv") is not None
|
||||
|
||||
|
||||
def _install(root: Path) -> None:
|
||||
venv_toolkit = root / ".venv" / "bin" / "mytoolkit"
|
||||
user_local = Path.home() / ".local" / "bin" / "mytoolkit"
|
||||
comp_dir = Path.home() / ".local" / "bin" / "completions"
|
||||
|
||||
# 1. Sync venv / install package
|
||||
click.secho("Installing package…", fg="cyan")
|
||||
if _has_uv():
|
||||
_run(["uv", "sync"], cwd=root)
|
||||
else:
|
||||
venv_pip = root / ".venv" / "bin" / "pip"
|
||||
if not venv_pip.exists():
|
||||
_run([sys.executable, "-m", "venv", str(root / ".venv")])
|
||||
_run([str(venv_pip), "install", "-e", str(root)], cwd=root)
|
||||
|
||||
if not venv_toolkit.exists():
|
||||
click.secho(f"error: missing {venv_toolkit}", fg="red", err=True)
|
||||
raise click.Exit(1)
|
||||
|
||||
# 2. Symlink to ~/.local/bin
|
||||
click.secho(f"Linking {user_local} → {venv_toolkit}…", fg="cyan")
|
||||
user_local.parent.mkdir(parents=True, exist_ok=True)
|
||||
if user_local.exists() or user_local.is_symlink():
|
||||
user_local.unlink()
|
||||
user_local.symlink_to(venv_toolkit)
|
||||
|
||||
# 3. Install completions
|
||||
comp_dir.mkdir(parents=True, exist_ok=True)
|
||||
shell = os.environ.get("SHELL", "")
|
||||
if "zsh" in shell:
|
||||
comp_file = comp_dir / "_mytoolkit"
|
||||
result = subprocess.run(
|
||||
[str(venv_toolkit)],
|
||||
env={**os.environ, "_MYTOOLKIT_COMPLETE": "zsh_source"},
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
comp_file.write_text(result.stdout)
|
||||
click.secho(f"Installed zsh completion: {comp_file}", fg="green")
|
||||
else:
|
||||
click.secho("Warning: failed to generate zsh completion", fg="yellow")
|
||||
elif "bash" in shell:
|
||||
comp_file = comp_dir / "mytoolkit.bash"
|
||||
result = subprocess.run(
|
||||
[str(venv_toolkit)],
|
||||
env={**os.environ, "_MYTOOLKIT_COMPLETE": "bash_source"},
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
comp_file.write_text(result.stdout)
|
||||
click.secho(f"Installed bash completion: {comp_file}", fg="green")
|
||||
else:
|
||||
click.secho("Warning: failed to generate bash completion", fg="yellow")
|
||||
|
||||
|
||||
def _uninstall() -> None:
|
||||
user_local = Path.home() / ".local" / "bin" / "mytoolkit"
|
||||
comp_dir = Path.home() / ".local" / "bin" / "completions"
|
||||
for f in (comp_dir / "_mytoolkit", comp_dir / "mytoolkit.bash", user_local):
|
||||
if f.exists() or f.is_symlink():
|
||||
f.unlink()
|
||||
click.secho(f"Removed {f}", fg="green")
|
||||
|
||||
|
||||
@click.command(name="update")
|
||||
def update_cmd():
|
||||
"""Update mytoolkit from the local repository."""
|
||||
root = _get_project_root()
|
||||
|
||||
click.secho("Updating mytoolkit...", fg="cyan")
|
||||
|
||||
# Run make install
|
||||
result = subprocess.run(
|
||||
["make", "install"],
|
||||
cwd=str(root),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
click.secho("mytoolkit updated successfully.", fg="green")
|
||||
else:
|
||||
click.secho("Update failed:", fg="red", err=True)
|
||||
click.echo(result.stderr, err=True)
|
||||
raise click.Exit(1)
|
||||
click.secho("Updating mytoolkit…", fg="cyan")
|
||||
_install(root)
|
||||
click.secho("mytoolkit updated.", fg="green")
|
||||
|
||||
|
||||
@click.command(name="uninstall")
|
||||
def uninstall_cmd():
|
||||
"""Uninstall mytoolkit."""
|
||||
root = _get_project_root()
|
||||
|
||||
click.secho("Uninstalling mytoolkit...", fg="cyan")
|
||||
|
||||
result = subprocess.run(
|
||||
["make", "uninstall"],
|
||||
cwd=str(root),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
click.secho("mytoolkit uninstalled.", fg="green")
|
||||
else:
|
||||
click.secho("Uninstall failed:", fg="red", err=True)
|
||||
click.echo(result.stderr, err=True)
|
||||
raise click.Exit(1)
|
||||
click.secho("Uninstalling mytoolkit…", fg="cyan")
|
||||
_uninstall()
|
||||
click.secho("mytoolkit removed.", fg="green")
|
||||
|
||||
|
||||
# Backward compatibility: keep self_cmd group for any existing scripts
|
||||
@@ -64,6 +114,5 @@ def self_cmd():
|
||||
pass
|
||||
|
||||
|
||||
# Register commands in the group for backward compatibility
|
||||
self_cmd.add_command(update_cmd, name="update")
|
||||
self_cmd.add_command(uninstall_cmd, name="uninstall")
|
||||
|
||||
@@ -0,0 +1,403 @@
|
||||
"""Server management for workspace projects."""
|
||||
|
||||
import os
|
||||
import signal
|
||||
import subprocess
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Literal, Optional
|
||||
|
||||
import click
|
||||
|
||||
|
||||
Mode = Literal["dev", "serve"]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Service:
|
||||
name: str
|
||||
serve_port: int
|
||||
dev_port: Optional[int] = None
|
||||
description: str = ""
|
||||
project_dir: Optional[Path] = None
|
||||
serve_cmd: Optional[list[str]] = None
|
||||
dev_cmd: Optional[list[str]] = None
|
||||
|
||||
@property
|
||||
def manageable(self) -> bool:
|
||||
return self.project_dir is not None and self.serve_cmd is not None
|
||||
|
||||
@property
|
||||
def has_dev(self) -> bool:
|
||||
return self.dev_port is not None and self.dev_cmd is not None
|
||||
|
||||
def port_for(self, dev: bool) -> Optional[int]:
|
||||
return self.dev_port if dev else self.serve_port
|
||||
|
||||
def cmd_for(self, dev: bool) -> Optional[list[str]]:
|
||||
return self.dev_cmd if dev else self.serve_cmd
|
||||
|
||||
|
||||
KNOWN_SERVICES: list[Service] = [
|
||||
Service("metabot-api", 9100, description="MetaBot HTTP API"),
|
||||
Service("metabot-memory", 8100, description="MetaBot MetaMemory"),
|
||||
Service(
|
||||
"mywebpage",
|
||||
serve_port=3010,
|
||||
dev_port=3000,
|
||||
description="APAAM Lab website (Docusaurus)",
|
||||
project_dir=Path.home() / "Documents" / "myWork" / "sysu" / "04-学术积累" / "mywebpage",
|
||||
serve_cmd=["npm", "run", "serve"],
|
||||
dev_cmd=["npm", "run", "dev"],
|
||||
),
|
||||
Service(
|
||||
"apaam-webpage",
|
||||
serve_port=3011,
|
||||
dev_port=3001,
|
||||
description="Phynexis docs (Docusaurus)",
|
||||
project_dir=Path.home() / "Documents" / "myResearch" / "myProjects" / "apaam" / "repo" / "webpage",
|
||||
serve_cmd=["npm", "run", "serve"],
|
||||
dev_cmd=["npm", "run", "dev"],
|
||||
),
|
||||
Service(
|
||||
"xiaohe-webpage",
|
||||
serve_port=3012,
|
||||
dev_port=3002,
|
||||
description="Xiaohe company website (Docusaurus)",
|
||||
project_dir=Path.home() / "Documents" / "myResearch" / "myProjects" / "apaam" / "repo" / "xiaohe-webpage",
|
||||
serve_cmd=["npm", "run", "serve"],
|
||||
dev_cmd=["npm", "run", "dev"],
|
||||
),
|
||||
Service("myslides", 3456, description="Slidev dev server"),
|
||||
]
|
||||
|
||||
_BY_NAME: dict[str, Service] = {s.name: s for s in KNOWN_SERVICES}
|
||||
_ALL_NAMES = list(_BY_NAME.keys())
|
||||
_MANAGEABLE_NAMES = [s.name for s in KNOWN_SERVICES if s.manageable]
|
||||
|
||||
|
||||
def manageable_services() -> list[Service]:
|
||||
return [s for s in KNOWN_SERVICES if s.manageable]
|
||||
|
||||
|
||||
def get_service(name: str) -> Service:
|
||||
return _BY_NAME[name]
|
||||
|
||||
|
||||
def no_color_env() -> dict:
|
||||
env = os.environ.copy()
|
||||
env["NO_COLOR"] = "1"
|
||||
return env
|
||||
|
||||
|
||||
def _listening_map() -> dict[int, dict]:
|
||||
"""Single batched lsof query for all TCP listening sockets, keyed by port."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["lsof", "-nP", "-iTCP", "-sTCP:LISTEN"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
errors="replace",
|
||||
timeout=5,
|
||||
)
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError):
|
||||
return {}
|
||||
if result.returncode != 0 or not result.stdout.strip():
|
||||
return {}
|
||||
|
||||
by_port: dict[int, dict] = {}
|
||||
for line in result.stdout.strip().split("\n")[1:]:
|
||||
parts = line.split()
|
||||
if len(parts) < 9:
|
||||
continue
|
||||
listen = next((t for t in reversed(parts) if ":" in t and not t.startswith("(")), None)
|
||||
if not listen:
|
||||
continue
|
||||
try:
|
||||
port = int(listen.rsplit(":", 1)[1])
|
||||
except (ValueError, IndexError):
|
||||
continue
|
||||
by_port[port] = {
|
||||
"command": parts[0],
|
||||
"pid": parts[1],
|
||||
"user": parts[2],
|
||||
"listen": listen,
|
||||
}
|
||||
return by_port
|
||||
|
||||
|
||||
def _port_status(port: int) -> Optional[dict]:
|
||||
return _listening_map().get(port)
|
||||
|
||||
|
||||
def _pid_command(pid: str) -> str:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["ps", "-p", pid, "-o", "comm="],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=2,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return result.stdout.strip()
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError):
|
||||
pass
|
||||
return ""
|
||||
|
||||
|
||||
def _resolve(svc: Service, dev: bool) -> tuple[Optional[int], Optional[list[str]], Mode]:
|
||||
return svc.port_for(dev), svc.cmd_for(dev), "dev" if dev else "serve"
|
||||
|
||||
|
||||
@click.group(name="server")
|
||||
def server_cmd():
|
||||
"""Manage workspace dev/serve servers."""
|
||||
pass
|
||||
|
||||
|
||||
def _print_row(name: str, mode: str, port: int, desc: str, info: Optional[dict]) -> bool:
|
||||
if info:
|
||||
pid = info.get("pid", "?")
|
||||
click.secho(
|
||||
f"{name:<16} {mode:<6} {port:<6} {'running':<10} {pid:<8} {desc}",
|
||||
fg="green",
|
||||
)
|
||||
return True
|
||||
click.secho(
|
||||
f"{name:<16} {mode:<6} {port:<6} {'stopped':<10} {'-':<8} {desc}",
|
||||
fg="black",
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
@server_cmd.command("list")
|
||||
def server_list():
|
||||
"""List all known services (serve + dev modes) and their status."""
|
||||
click.echo("")
|
||||
click.secho(
|
||||
f"{'Service':<16} {'Mode':<6} {'Port':<6} {'Status':<10} {'PID':<8} {'Description'}",
|
||||
fg="bright_blue",
|
||||
bold=True,
|
||||
)
|
||||
click.echo("-" * 90)
|
||||
|
||||
listening = _listening_map()
|
||||
running = 0
|
||||
total = 0
|
||||
for svc in KNOWN_SERVICES:
|
||||
primary_label = "serve" if svc.serve_cmd is not None else "-"
|
||||
if _print_row(svc.name, primary_label, svc.serve_port, svc.description, listening.get(svc.serve_port)):
|
||||
running += 1
|
||||
total += 1
|
||||
|
||||
if svc.has_dev:
|
||||
assert svc.dev_port is not None
|
||||
if _print_row("", "dev", svc.dev_port, "(hot reload)", listening.get(svc.dev_port)):
|
||||
running += 1
|
||||
total += 1
|
||||
|
||||
click.echo("-" * 90)
|
||||
click.echo(f"Total: {total} ports, {running} running, {total - running} stopped")
|
||||
click.echo("")
|
||||
|
||||
|
||||
@server_cmd.command("check")
|
||||
@click.argument("port", type=int)
|
||||
def server_check(port):
|
||||
"""Check if a specific port is open."""
|
||||
info = _port_status(port)
|
||||
if info:
|
||||
cmd = _pid_command(info.get("pid", ""))
|
||||
click.secho(
|
||||
f"Port {port} is open (PID {info.get('pid', '?')}, {cmd or info.get('command', '')})",
|
||||
fg="green",
|
||||
)
|
||||
else:
|
||||
click.secho(f"Port {port} is not listening", fg="red")
|
||||
|
||||
|
||||
@server_cmd.command("scan")
|
||||
@click.argument("start", type=int, default=3000)
|
||||
@click.argument("end", type=int, default=4000)
|
||||
def server_scan(start, end):
|
||||
"""Scan a port range for listening services."""
|
||||
click.echo(f"Scanning ports {start}-{end} ...")
|
||||
listening = _listening_map()
|
||||
found = 0
|
||||
for port in range(start, end + 1):
|
||||
info = listening.get(port)
|
||||
if info:
|
||||
cmd = _pid_command(info.get("pid", ""))
|
||||
click.secho(
|
||||
f" {port:<5} {cmd or info.get('command', '')} (PID {info.get('pid', '?')})",
|
||||
fg="green",
|
||||
)
|
||||
found += 1
|
||||
if found == 0:
|
||||
click.echo("No listening ports found.")
|
||||
else:
|
||||
click.echo(f"\nFound {found} open port(s).")
|
||||
|
||||
|
||||
@server_cmd.command("start")
|
||||
@click.argument("name", type=click.Choice(_MANAGEABLE_NAMES, case_sensitive=False))
|
||||
@click.option("--dev", is_flag=True, help="Start hot-reload dev server on the dev port (default: serve)")
|
||||
@click.option("--rebuild", is_flag=True, help="Force npm run build before serve (no effect with --dev)")
|
||||
def server_start(name, dev, rebuild):
|
||||
"""Start a server in the background.
|
||||
|
||||
By default, serve mode is used (production build) on the serve port.
|
||||
Use --dev to start the hot-reload dev server on the dev port instead.
|
||||
"""
|
||||
svc = get_service(name)
|
||||
port, cmd, mode = _resolve(svc, dev)
|
||||
|
||||
if port is None or cmd is None:
|
||||
click.secho(f"{name} has no {mode} mode configured", fg="red")
|
||||
return
|
||||
|
||||
assert svc.project_dir is not None # _MANAGEABLE_NAMES choice guarantees this
|
||||
|
||||
if _port_status(port) is not None:
|
||||
click.secho(f"{name} ({mode}) is already running on port {port}", fg="yellow")
|
||||
return
|
||||
|
||||
if not svc.project_dir.exists():
|
||||
click.secho(f"Project directory not found: {svc.project_dir}", fg="red")
|
||||
return
|
||||
|
||||
env = no_color_env()
|
||||
|
||||
if not dev:
|
||||
build_dir = svc.project_dir / "build"
|
||||
if rebuild or not build_dir.exists():
|
||||
reason = "forced rebuild" if rebuild else "build directory missing"
|
||||
click.secho(f"Running npm run build ({reason}) ...", fg="cyan")
|
||||
build_proc = subprocess.run(
|
||||
["npm", "run", "build"],
|
||||
cwd=svc.project_dir,
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if build_proc.returncode != 0:
|
||||
click.secho(f"Build failed for {name}", fg="red")
|
||||
click.echo(build_proc.stderr[-500:] if build_proc.stderr else "")
|
||||
return
|
||||
|
||||
log_file = svc.project_dir / "logs" / f"{svc.name}-{mode}.log"
|
||||
log_file.parent.mkdir(exist_ok=True)
|
||||
full_cmd = ["nohup"] + cmd
|
||||
click.secho(f"Starting {name} ({svc.description}) on port {port} ({mode}) ...", fg="cyan")
|
||||
|
||||
with open(log_file, "a") as log:
|
||||
proc = subprocess.Popen(
|
||||
full_cmd,
|
||||
cwd=svc.project_dir,
|
||||
stdout=log,
|
||||
stderr=subprocess.STDOUT,
|
||||
start_new_session=True,
|
||||
env=env,
|
||||
)
|
||||
|
||||
max_wait = 20 if dev else 30
|
||||
for _ in range(max_wait * 2):
|
||||
time.sleep(0.5)
|
||||
if _port_status(port) is not None:
|
||||
click.secho(f"{name} ({mode}) started successfully (PID {proc.pid})", fg="green")
|
||||
return
|
||||
|
||||
click.secho(f"{name} may have failed to start (check {log_file})", fg="yellow")
|
||||
|
||||
|
||||
def _kill_port(name: str, mode: Mode, port: int):
|
||||
info = _port_status(port)
|
||||
if info is None:
|
||||
click.secho(f"{name} ({mode}) is not running on port {port}", fg="yellow")
|
||||
return
|
||||
|
||||
pid = info["pid"]
|
||||
try:
|
||||
os.kill(int(pid), signal.SIGTERM)
|
||||
click.secho(f"Sent SIGTERM to {name} ({mode}, PID {pid})", fg="green")
|
||||
except ProcessLookupError:
|
||||
click.secho(f"Process {pid} not found", fg="yellow")
|
||||
return
|
||||
|
||||
for _ in range(10):
|
||||
time.sleep(0.5)
|
||||
if _port_status(port) is None:
|
||||
click.secho(f"{name} ({mode}) stopped", fg="green")
|
||||
return
|
||||
|
||||
try:
|
||||
os.kill(int(pid), signal.SIGKILL)
|
||||
click.secho(f"Sent SIGKILL to {name} ({mode}, PID {pid})", fg="red")
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
|
||||
|
||||
@server_cmd.command("stop")
|
||||
@click.argument("name", type=click.Choice(_MANAGEABLE_NAMES, case_sensitive=False))
|
||||
@click.option("--dev", is_flag=True, help="Stop the dev-port instance instead of serve")
|
||||
@click.option("--all", "all_modes", is_flag=True, help="Stop both dev and serve instances")
|
||||
def server_stop(name, dev, all_modes):
|
||||
"""Stop a running server."""
|
||||
svc = get_service(name)
|
||||
|
||||
if all_modes:
|
||||
targets: list[tuple[Mode, int]] = [("serve", svc.serve_port)]
|
||||
if svc.has_dev:
|
||||
assert svc.dev_port is not None
|
||||
targets.append(("dev", svc.dev_port))
|
||||
else:
|
||||
port, _, mode = _resolve(svc, dev)
|
||||
if port is None:
|
||||
click.secho(f"{name} has no {mode} mode configured", fg="red")
|
||||
return
|
||||
targets = [(mode, port)]
|
||||
|
||||
for target_mode, target_port in targets:
|
||||
_kill_port(name, target_mode, target_port)
|
||||
|
||||
|
||||
@server_cmd.command("restart")
|
||||
@click.argument("name", type=click.Choice(_MANAGEABLE_NAMES, case_sensitive=False))
|
||||
@click.option("--dev", is_flag=True, help="Restart the dev instance (default: serve)")
|
||||
@click.option("--rebuild", is_flag=True, help="Force npm run build before serve (no effect with --dev)")
|
||||
def server_restart(name, dev, rebuild):
|
||||
"""Restart a server."""
|
||||
ctx = click.get_current_context()
|
||||
ctx.invoke(server_stop, name=name, dev=dev, all_modes=False)
|
||||
ctx.invoke(server_start, name=name, dev=dev, rebuild=rebuild)
|
||||
|
||||
|
||||
@server_cmd.command("status")
|
||||
@click.argument("name", type=click.Choice(_ALL_NAMES, case_sensitive=False))
|
||||
def server_status(name):
|
||||
"""Show detailed status of a service (both serve and dev ports)."""
|
||||
svc = get_service(name)
|
||||
|
||||
click.echo("")
|
||||
click.secho(f"Service: {svc.name}", bold=True)
|
||||
click.echo(f"Desc: {svc.description}")
|
||||
if svc.project_dir:
|
||||
click.echo(f"Project: {svc.project_dir}")
|
||||
|
||||
def _show(label: str, port: Optional[int]):
|
||||
if port is None:
|
||||
return
|
||||
info = _port_status(port)
|
||||
if info:
|
||||
cmd = _pid_command(info.get("pid", ""))
|
||||
click.secho(
|
||||
f"{label:<8} {port} running (PID {info.get('pid', '?')}, {cmd or info.get('command', '')})",
|
||||
fg="green",
|
||||
)
|
||||
else:
|
||||
click.secho(f"{label:<8} {port} stopped", fg="black")
|
||||
|
||||
_show("Serve:", svc.serve_port)
|
||||
_show("Dev:", svc.dev_port)
|
||||
click.echo("")
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Templates registry CLI."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
|
||||
from bin import templates_registry
|
||||
|
||||
|
||||
@click.group(name="templates")
|
||||
def templates_cmd():
|
||||
"""Manage external templates registry."""
|
||||
pass
|
||||
|
||||
|
||||
@templates_cmd.command("register")
|
||||
@click.argument("path", type=click.Path(exists=True, file_okay=False, dir_okay=True))
|
||||
def templates_register(path):
|
||||
"""Register a directory as the templates root."""
|
||||
resolved = templates_registry.set_root(Path(path))
|
||||
click.secho(f"Registered: {resolved}", fg="green")
|
||||
|
||||
|
||||
@templates_cmd.command("show")
|
||||
def templates_show():
|
||||
"""Show the current registry and available templates."""
|
||||
if not templates_registry.CONFIG_PATH.exists():
|
||||
click.echo("No registry. Run: mytoolkit templates register <path>")
|
||||
return
|
||||
|
||||
import json
|
||||
|
||||
with open(templates_registry.CONFIG_PATH, encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
raw = data.get("dir")
|
||||
click.echo(f"Registry: {templates_registry.CONFIG_PATH}")
|
||||
click.echo(f"Root: {raw}")
|
||||
|
||||
if not raw or not Path(raw).is_dir():
|
||||
click.secho("(root path missing)", fg="red")
|
||||
return
|
||||
|
||||
root = Path(raw)
|
||||
pdf_dir = root / "md-to-pdf"
|
||||
docx_dir = root / "md-to-docx"
|
||||
|
||||
click.echo("\nmd-to-pdf:")
|
||||
if pdf_dir.is_dir():
|
||||
entries = sorted(
|
||||
(p.name, p / "template.typ")
|
||||
for p in pdf_dir.iterdir()
|
||||
if (p / "template.typ").exists()
|
||||
)
|
||||
if not entries:
|
||||
click.echo(" (none)")
|
||||
else:
|
||||
width = max(len(n) for n, _ in entries)
|
||||
for name, path in entries:
|
||||
click.echo(f" - {name:<{width}} {path}")
|
||||
else:
|
||||
click.secho(" (subdirectory missing)", fg="yellow")
|
||||
|
||||
click.echo("\nmd-to-docx:")
|
||||
if docx_dir.is_dir():
|
||||
entries = sorted(
|
||||
(p.name, p / "template.docx")
|
||||
for p in docx_dir.iterdir()
|
||||
if (p / "template.docx").exists()
|
||||
)
|
||||
if not entries:
|
||||
click.echo(" (none)")
|
||||
else:
|
||||
width = max(len(n) for n, _ in entries)
|
||||
for name, path in entries:
|
||||
click.echo(f" - {name:<{width}} {path}")
|
||||
else:
|
||||
click.secho(" (subdirectory missing)", fg="yellow")
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Webpage project utilities (Docusaurus build / clear / etc.).
|
||||
|
||||
Project metadata lives in `server.py` so that adding a new webpage there
|
||||
automatically wires it up here.
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
import click
|
||||
|
||||
from bin.commands.server import manageable_services, get_service, no_color_env
|
||||
|
||||
|
||||
_PROJECTS = manageable_services()
|
||||
_NAMES = [s.name for s in _PROJECTS]
|
||||
|
||||
|
||||
def _run_npm(name: str, script: str, label: str) -> None:
|
||||
"""Run `npm run <script>` in the project's directory, streaming output."""
|
||||
svc = get_service(name)
|
||||
assert svc.project_dir is not None # _NAMES is manageable services only
|
||||
|
||||
if not svc.project_dir.exists():
|
||||
click.secho(f"Project directory not found: {svc.project_dir}", fg="red")
|
||||
raise click.Abort()
|
||||
|
||||
click.secho(f"{label} {svc.name} ({svc.description}) ...", fg="cyan")
|
||||
started = time.time()
|
||||
proc = subprocess.run(["npm", "run", script], cwd=svc.project_dir, env=no_color_env())
|
||||
elapsed = time.time() - started
|
||||
if proc.returncode != 0:
|
||||
click.secho(f"{script} failed for {svc.name} (exit {proc.returncode})", fg="red")
|
||||
raise click.Abort()
|
||||
click.secho(f"{svc.name} {script} done in {elapsed:.1f}s", fg="green")
|
||||
|
||||
|
||||
@click.group(name="webpage")
|
||||
def webpage_cmd():
|
||||
"""Manage Docusaurus webpage projects."""
|
||||
pass
|
||||
|
||||
|
||||
@webpage_cmd.command("list")
|
||||
def webpage_list():
|
||||
"""List managed webpage projects and their build status."""
|
||||
click.echo("")
|
||||
click.secho(
|
||||
f"{'Name':<16} {'Build':<12} {'Description':<36} {'Project'}",
|
||||
fg="bright_blue",
|
||||
bold=True,
|
||||
)
|
||||
click.echo("-" * 100)
|
||||
for svc in _PROJECTS:
|
||||
assert svc.project_dir is not None # _PROJECTS is manageable services only
|
||||
build_dir = svc.project_dir / "build"
|
||||
if build_dir.exists():
|
||||
build_status = time.strftime("%m-%d %H:%M", time.localtime(build_dir.stat().st_mtime))
|
||||
color = "green"
|
||||
else:
|
||||
build_status = "missing"
|
||||
color = "yellow"
|
||||
click.secho(
|
||||
f"{svc.name:<16} {build_status:<12} {svc.description:<36} {svc.project_dir}",
|
||||
fg=color,
|
||||
)
|
||||
click.echo("")
|
||||
|
||||
|
||||
@webpage_cmd.command("build")
|
||||
@click.argument("name", type=click.Choice(_NAMES, case_sensitive=False))
|
||||
def webpage_build(name):
|
||||
"""Build a webpage project (`npm run build`)."""
|
||||
_run_npm(name, "build", "Building")
|
||||
|
||||
|
||||
@webpage_cmd.command("clear")
|
||||
@click.argument("name", type=click.Choice(_NAMES, case_sensitive=False))
|
||||
def webpage_clear(name):
|
||||
"""Clear Docusaurus build artifacts and cache (`npm run clear`)."""
|
||||
_run_npm(name, "clear", "Clearing")
|
||||
|
||||
|
||||
@webpage_cmd.command("rebuild")
|
||||
@click.argument("name", type=click.Choice(_NAMES, case_sensitive=False))
|
||||
def webpage_rebuild(name):
|
||||
"""Clear cache then build (`npm run clear && npm run build`)."""
|
||||
_run_npm(name, "clear", "Clearing")
|
||||
_run_npm(name, "build", "Building")
|
||||
Reference in New Issue
Block a user