From 4d8963fd2713a02d2bbfa31e7156b27a695e6cb4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 4 Jan 2026 04:40:29 +0000 Subject: [PATCH] feat: add Python library infrastructure and extension scaffolding - Add scripts/lib/common.py with core utilities (logging, validation, JSON, file ops, git) - Add scripts/lib/joomla_manifest.py for manifest parsing and validation - Add scripts/run/scaffold_extension.py to create extension scaffolding - Support all Joomla extension types (component, module, plugin, template, package) - Maintain CLI compatibility with existing bash scripts - Foundation for converting remaining scripts to Python Co-authored-by: jmiller-moko <230051081+jmiller-moko@users.noreply.github.com> --- .../lib/__pycache__/common.cpython-312.pyc | Bin 0 -> 15773 bytes .../joomla_manifest.cpython-312.pyc | Bin 0 -> 14399 bytes scripts/lib/common.py | 452 ++++++++++++++++++ scripts/lib/joomla_manifest.py | 430 +++++++++++++++++ scripts/run/scaffold_extension.py | 448 +++++++++++++++++ 5 files changed, 1330 insertions(+) create mode 100644 scripts/lib/__pycache__/common.cpython-312.pyc create mode 100644 scripts/lib/__pycache__/joomla_manifest.cpython-312.pyc create mode 100755 scripts/lib/common.py create mode 100755 scripts/lib/joomla_manifest.py create mode 100755 scripts/run/scaffold_extension.py diff --git a/scripts/lib/__pycache__/common.cpython-312.pyc b/scripts/lib/__pycache__/common.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..849226b727a322d0b50b77bc8b964c89964f993b GIT binary patch literal 15773 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
H@Dir<-Z#KK)rSF z!|?k*Vx9P}9=^5aL$qdCD;Lc84_ytWaSje5icA6K5Pt%3JlD83+;lJ`^!IloV2O8# z#~z5?)$R`MQYav%$n;|8v~+-OT*t&FNgKi6pcvaow>CKj=}-i!CzGnsRzo4}RmcNX zKA=}Gy;|wTD9dQPo<2o-6?s9sN*hB?(PtN4x)T?2jdPt>sO(jGMd)>bUKBXBv5R6b zEE1r+MLF7+>DA>PzF{cI_cGY~Ddor_GIlDBl%t>;TR7N8_=)$haj*KQ@|UO}qoZzx z(aSq|o_{Q?Ga)3jQ?zn4@=Ibnxpl(L6xcXC-Q}KJOR!Fcl%8A|&}rfj^W{spwD^ zc#%+1BjH6tc#*IoqhX`b5JfJ&jv6ANhDh?&4t_(%Nj+}JwzFjVICZ??v417MIs=qQ zrj2B#f04fO;Q-&9Jv-fsQ<)cL*EQuieBa-)NJ+jSz_(^QrZ-P()1ldhomAI)A0tAN zZwT_cs1qd97zL6Ui~>o1o1foAV?~mUE>iM%O@Kd;k+QAR&g{YWl7#2LjKq@9&^n0+p*P9M)s&en$r>%kd@_2A=8LB2K9k=>ltvZ2|! zEi{PM`NWokIMa2z9pk(+cU4d*F5Xnm?vcUAm^@r;1XwD;$j7TzsOr1KK9k4 zw_sJ?iInw()a;3J%k@O%{vyf%eZR;D`Nj<9gXHo0D!x7of+3j!rI6&e1o@4bcBwhD7e{upxb;25^M*js1dY`BO literal 0 HcmV?d00001 diff --git a/scripts/lib/__pycache__/joomla_manifest.cpython-312.pyc b/scripts/lib/__pycache__/joomla_manifest.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0f80db25d2fc872b45c0b7656967f41f76330ee3 GIT binary patch literal 14399 zcmcgTZBQG@l_O~+jWiMhBq4A(A z(Fzw^yIX-x>I`>3z?WQ^Y+bVCyg#^q-2KWWmrAaZ)JcexW~ZDwr{X{PbH~Q6%9iuz zUbkijfd$@8<weSy`n}grKR26=1f>7`pX=eZR|w*_s30f3^zw8>MG#8_ zOZW*^#i}E!s5+!p5mAkwR3V5aqK)eOI{Z#X^ij%BMGbyK)aW-xX+MqO+K4G?_M26N zfnas4K2jF7_$?|zP4o~f^*+HG9;yq}{1u!TLyZ8XW#~g`DE>;enl*7&*39WfNw(~L zt>30V44lph?Er28xScKMbkMF6$`$XE{wk?#g>og79Ytj;l&g!%HYhu#GR@U+2F{8x zt0nw)sCThWwhErLTpjDUd6Xb{N=(0w0c_INO0wc2wsnQppL+tuM^*T=QeRi zbDg-Ya@PcPBtaPa;_+xC$V7v&@FAG> zgvV&?j89JU;j!@~bF|aVw6(UKW(MLn<4k8fmY9kpQSS@mTqF|zN)*5$1?ow1q4C#@ z#*y)Gf*B1*I0l|TFUiD5ncxdJCi(aT7fJ%b;lZvqoBP8dE|%b$ds!})43CC6zMbhA z>~B8lX>E@4&5>Y|<0ahCbBqs05&BV{UIJQ_@*0rO0J z5`6RMyDbtjKBfr zjoy*VSFepQ9lq<#8y!PK9lnw4=b>eCJPx(oEv|qk9G#4WVRFDqJ{U_*!o9DlyLZIr9Uf-7uMROC%wWgRNN?x0{*EDL@Y>Me)nTuP83wMv^o{#) za}<9Ko?}=p84O3zvbzr7E&(Hou*`Vy76;!j#D#Ccc!Ep_R;0|qy^$J&kvObPG%p~J z)}FLZj(S@9dpo^87+aKe1F7!belOGOgBc8TjPzdh8N0mQJwsQo4Yo7GAwE2r^z?^s z@Iijs*z1$vg?;21koJ*r$UAtoof%IiCll>0En`4)>V_v2kGAaG6U}H(U|4e~n1Hn~ z$%TW)!H$v3?MzDo(@L~N!Z%tb@IDR5`_wZzZTy;dXcz|9&a}SbX>El+Mzl3t#6!OIfC5q=fOaW$**Ygo0PWHo**OZs)J)~{!Eeu~xm4J_q1 zvIalRk(`#(ae9sd`Df(lG0Jb6Ho9ri?7fo&F#y|PWO|ZIIi#s_GL%UIIN=cuFrLYH z3`B*fgIzrp;Y8hJWNIuN6R9Kz3mL?Ps0YTPuPEv#gQ1(jF-|0rSQ4p7Fg7*?WuMzD zQh`7$807*1(HIC6P{F$?5cv93Fe26H0|7Q30cHStpVv1c>ISZMUF-LXy21Wy zJ-t4W8u1Pc_IHeUMSXwo>q8ww*G2tcN9UD}9;EgfN~TsX6QX639vSr#12hgPU9t)@cvZZF>`qu#L` z;IzT%=_7b-FNbSzjQcg{gR|-jPy?S`%W9N2QhC#&H&`fWQQ*TC+UX^jp$JAvFk=zS zAi?M&m{Ee6ieR(^GZ(>360EEUX2!5G&H|&=jTVk(H5~np*2(Mkc)gzLVunEO7Wlaarnifg4)Y)d1BM6{S~z%95g}5};T+ECRvn9aRFfKUM{` zXk@!mQEl0-Dkv7!mhW3zxo@p)-`c9ZYgzSo=wZUGNmYTO1fMaFOHx&6}C_9d_6DVs&nFnPpC_~$q2lbh7n?w!hG#CXBIwA4pdV}y! zaFNjj`hpC92y#)Mj0eyFQ?}ye?2(`&&?NC8WV6IG9bvCtCLb8?8~)xWRM+OKk7uio z3%2T|-o;+Q;aW;BrUh3+-gP|dI=Jxzge+E@y-lCPcKZAL+r1`TbJBYGI zlp#j(N94>4FNp=nJ|KQWzA6~Y-s=-=jvVO_jOO=1td_smFB~{Ldu5?|xpA>BM;?Z` z&vwsUUKpMq$dQb2@W^cc!qMfEi_JOmh`=10?OULht&8Ryc}Qqzob8>vv#gn)$&rn~ zf!Ut9Hy1+lZ{+;{saW9FfL7`4{m)|#|tzj5$Rs0XaO6RS|}BHn_L&X8#m8I;2W zNhHm3YnY=&e&2qwq&#Z*IipQ$dH83w$T%B*IR>5FQ-03q(mKEZO3)X?pqG2f&l!DM z4;b`c7y~8ul%F$HngR@zjA4)28|3!DxjNvS!9b)96BXP1k>6P3C$!vl#>kq|##;nW zrVRx?f2#pFz5g09?_ML*WsO8q5zAoIYE%Rmvt5cYYf1gDk%8152D24eUXy${3PN@F z;VPR%V_-PMK=^_Uk&J`v#hr21!H*@{@f*S?Z52@a!43<7H6r&wvOB>A`Ovu17QYPP zBeVzuQm9d5S#igP@2)<`wh7=8h0TCN( zXP#SS6}L;Wp6*0J1CW7<=Z49fV}WBHfb!@Ne~z@}LixD?R9yLWG9tA~X>sF}b}BjA zd=}Jwii?H7R*sFOj29F=CDrJQzrgS}2l|;&G-}DD_PLD(QLGCmuoOb4yr^NrpxXch z>)+&9Bz{9A!In;nS{@e+psBEdB+wEK-a8?#U=h=NJL*lL&xDnx3VDu-p(L7R5GJ=> zERZrIPRQRT0U8l7qNs?NQB*_>*)CGRzXBFLI61&9NhXlQLF-0Jg=5q_3ae7-$6tds zVfatng`*bE&KjcJw$Ss$GFV{cj0uTE#I=QgQ!(72rk zGL2`m)n_xLUC>wO^-NaJZ0ZjR7A8Y6P;akWekW@?Haj5bZF#*bt9Nbc>x8;vg~Kw_ z>dn=4|DI484$KV*R%XM*Y-ynIj3VsLho1cvsFjenle>Kb5-qgJ%Yuxd^>A7 zA~+iU&a$N;%A2RH`5^eJH*UpX*ON6xCyJIE)pJI7RdUX(@rgmHnME2%fC! zA=1Sl$xk>e)e~R5zjD<5iTyQlKl%A3>A!GXe#!n?nXddq{Xv7)r5+m52E)QW2=1(1 z7F6OdFXfS=1UaB&;Q(&UwwHnVnN7v!n>%$Qk3P99q!g( zvBqCP6$Tk)B7xK{n07&^RAQk5vsVO5g1-(8z7PM2??DEV#a2SH2$qUD!{hSG*aKT+ZIUNuGb9X5~uG-nvP?@@K4gOxrS~4XdT1S)L;n;yZR9 zQpvDN`fs40FA-q(NqYj|N|IGXP(Bsaw3Pk%qVj1VlFH62ZH3Bf4=cVU6iS&kR$oil zq$L$eHLHL+bFmrZhQG!j63@ zHT?_JO5)jX#-LD_9-*SNB83@7sQZ&fXn0|SCGqS#Lhx0B!d!ZkMrE}a(uQ}BfO=el zk9b9~?r)i){??JUBd|#?YplaHKs!5rvIc)~2<~^vEgzrn$1XRoR zplVjATGAGvYT1KoN%+20%YbV69#qQ|suk%9pjxp9)spaisg?uP$~~xB6sp#=6{uSG zpjr~XFV#w*YTJWqxkA;RwgXlB9#l)h_oZq}A{!T{G?CV%!DvlWX=B=yE=!lEE7P`5 zDjrG_c9;Nz4)&)ceG zt&I5#;kA8Hyo!NpPyLq>=`4eAy~;5!IDN5IxTh7m<9KWb94|L1cblOD(!CV&L^JUl z=wcO*2aO584DD}(%Gbi9=rRs>*meqng_t?tJ7X_mVnjsHp>fj5lEYD3&Sr4F#*csEo*nCg~EQP^*;i5Rf+;-!XCpTq-Vs z{J3bXmPiGCxD0bnQHJ?D(BYTxpLh-)j#*->hA=tvG?S&7P5Pi>Ub|PDR~sbrn(R|) zJ5`S?75DDU-&r>2EJx-@!D79ao=<=C?i>laFZi?1FP>kzw0LRd%xYWC>d9MAWv!=j z)-!qQg{<{L&idLM^{K`2$W(c+Z@w?%Y+t{)-jZ?jZJMq?ce@k0!Mw!?D<0rX$G zcBS`Y-$y>B{n_=pbxX!^dDGM@SgP;coxhv6G-fT0Im=-@fa=<%>BZ?4XU=ha&Lq%| z4BfDj&NrRiXgaIlIKMuyektR)vT5oUD(mx=joHe^e5E^E>CROipVPxQ9IiQ9^35+t zbG8%GV3?KbAHV(4+d@s_1J8X=zQ&WS@#Jb+=dL`iu+Qm`!#=ziUZHYTZs<-|W$1$| z@8p|a-DrAMurqo4;jH~|-hMo5Kc2HU&vicrDkcG@e0}ly@(cnQ1=^mW53Edl9RDaT zxDG$KbpKM`)tYs+=3H%a{g3TVXkv9Nom)J&{8u@vdyW!lE1Kcuj|V;)5S#}d)ZMSk zJ5OYtCvr~DT;F47-CP;EA#mPzuAI*~TcAWcGxXtAPyX1&jbj&uy5sq})@)sCzV1x6 z?o6)k?3}MKEVz-_#mB}~Q~v0=jicuTS5w|~BI`PlceQ0*Z8_H|7+Q7hoLS~rEN5%p z#j)DM4}AB1`P$ZOZELQ!ZEoO+#U)hK3$8|?!Y?B2!si{PEmdR5Bm^sLUT#@B~_s9H~CtettPD@%3#@eyr9&0wX?TkZ(9$p4`-|$dFu5n_4*&d!2xZaCl0|L_~$G1l{3W8&sYa)G`}#o`rVpexJ~H& zv}?ew`K4V8?>nq6WJO`C3tcYlGw1>pbZVfQsD zss_o+*ccv*Ta6_GK1qI{mIPxM4#NbRgKSjWK+Gy4Bp``JJ`F97f!Ucr_ej{RHq|UQ z=jfww6;503U6{X+AsyI~{j)Ohn>(0@PegoU{FmUM51qdy*oW^UQ^1BDY^&J8HZ)TH zF0?tjH_y(^pOdZsKP%53?BE&pso?l3q?G*S)*meVLX1j5;VHpKd_DUTK4L{`7WmkW z_=r^C7uH^$uFul-8M6Mx)ki{myoH}Y?-B5_ZoGwn)y_AFBS!Qd(3pxLvh;uOI&O9T`o z%jH2#D0fpbgdlFI`1(CKsDqJ|z_;^Wl+~wIqu}lYLr4iZ0ON#$@CS-Dj6q#UeI4A& ziiu%NeGjkxuN9&;OIR8IpE4JRh9MW|QMkpyfukoFbb%9yj|L!MFb0A+;l|@jxdsXs zRS?^!M6k%peyU2YEU4@X+$qBqy;7-i$W@X`sZ`X;6~&sqQd@m3b~6^g9bT zg%o%R}eq^ZwMb2^p>xkvHyrp5o(jb^@_pZ)g z&D5UTG_^klJ@%as-%((vHch7y?Cytm71*nrrn3l^{xGe;PH&pd04#4guwgl%K-)J> z$VY4{FUX&Nisp}8%)%diQEpOyW-Xb2wJ-Z>U*^n}Ol|+BY2Y#3a*b#3ZPMpYXgktj zP7CGsOm$nvaWZ2$wLzUiI?L(8rOf-C&dz$`$Mt2MEt(&DRFH>Y+DbxF2TQklkaG|o zjzYtU8R8}GfFdcf?e;Ale)Le1lq`9(3%;SHbSsub9*vla)ne%?IWGu^Qjq)4sEfu_ zLi(^E3wG}Nq!M|MR^;W3rU(m??7I@HLnYqMYsDyJ<_vwCBCnS1EuCg7k$x^800U)n(!B`EKP@1v!)t?HKVgRMwf20 zW31?0-jyJ39IZ{9CQ6O94Dgodw~aRm@fC%%y$1do&gdBu9ONYLE5cuDO(~8;IJva^ zKvC;@8SnO!EidAna^~$^lW8r)e85a*bV(%5aAGUcx}B}QRY}4R4~nQAl-q7U*-H64 z$Ujm@9+GRZ>kTduCQuOEu9VhKAf0Jf+QtPgJ@t(Ku6|lwc-~ei=b|-LJA!`MfWks# z-MQ%Z3^)S9mpXvjAPC=enUrkP3AD2=3p z0%8amQM8W zxAyjep+gA5qoEZ#yue)r1{OqQAOMH_T>N{e0m(t^ibz6)2rJJS4dCV*Gai{sB4${{gaD;dTm9{~R=>JY=a~!L8=oIv`1*%8*SxbmTV$Qq`N+zwIIxESZ0I~ylcj1h zb?U>Y37n_nIZM+V`9!IAy|PKQA(PuYZ(bN(X7ly0Wb1dq9)$0QH>sm? ztMH2Lw__jcCzcCIgwxxsGqv8%Sc`&`z2 zZoP56E#rRehxQG3cV;4%pGajVQk$lC0Li`cAD)*{99`-9_{w*|&C~j%&a-+4yc;l$ zIcnkPZ)hvD$(!o4rus}n+otIxa!Hj}Em;;VE5|+=UaNix{(=m1VYB?A(9i@2am9&+ zo<}z4a@UGu#gM5v_AA@*&olsqs1LI>Z>r6jYBP1`er0OkqKWeA&!3eMrYdxPI|Yiq zN6k|U6-!l%Re7p9OI2gXgY$W!0lw3}lili`dg7;gJId<~mra_VHn}eAH9ym9;T?t6 zqi-4rxK;c&pgkDrZSe59>v71YXomnB4|qa3&f_ikf=7@mQ;R_&T?FI*2Ku2e{^U4{ z=||#M)PVs78@dp+i3}MYX$?HO?}gxL`3DSKf=5dYd#t5B(+j^G;FASMD%RkRpawWn z6vgh~YxaT`h&%(>)a7U4O(bu`;}NW=;bmF~Lq$Rmn`d|xKc_AfenapY|9z-HT1KK4 za!`F#s?W$e75RtP2vuW-IQVO#`o9TJj_~}NIIu-(RF%IbtN&10rqX>zK(=*^P}6@< z*Qy}!6|$!qC~l$RXWgoH)mK!@C$|XnTs`#^KR-*U7?piRvqivTja(b~7vod(y5&>( zRjN+a%Aqazc?P_!UU*t~*-EKtRrM;lMZjat`V>93YH2_+fR!8=^>jT&u%~|2VU=@hP(_fYxzvWn`6)?#&<*iBWX + +This file is part of a Moko Consulting project. + +SPDX-License-Identifier: GPL-3.0-or-later + +This program is free software; you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation; either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program (./LICENSE.md). + +FILE INFORMATION +DEFGROUP: Script.Library +INGROUP: Common +REPO: https://github.com/mokoconsulting-tech/moko-cassiopeia +PATH: /scripts/lib/common.py +VERSION: 01.00.00 +BRIEF: Unified shared Python utilities for all CI and local scripts +""" + +import json +import os +import shutil +import sys +from pathlib import Path +from typing import Any, Dict, List, Optional, Union +import subprocess +import traceback + + +# ============================================================================ +# Environment and Detection +# ============================================================================ + +def is_ci() -> bool: + """Check if running in CI environment.""" + return os.environ.get("CI", "").lower() == "true" + + +def require_cmd(command: str) -> None: + """ + Ensure a required command is available. + + Args: + command: Command name to check + + Raises: + SystemExit: If command is not found + """ + if not shutil.which(command): + log_error(f"Required command not found: {command}") + sys.exit(1) + + +# ============================================================================ +# Logging +# ============================================================================ + +class Colors: + """ANSI color codes for terminal output.""" + RED = '\033[0;31m' + GREEN = '\033[0;32m' + YELLOW = '\033[1;33m' + BLUE = '\033[0;34m' + CYAN = '\033[0;36m' + BOLD = '\033[1m' + NC = '\033[0m' # No Color + + @classmethod + def enabled(cls) -> bool: + """Check if colors should be enabled.""" + return sys.stdout.isatty() and os.environ.get("NO_COLOR") is None + + +def log_info(message: str) -> None: + """Log informational message.""" + print(f"INFO: {message}") + + +def log_warn(message: str) -> None: + """Log warning message.""" + color = Colors.YELLOW if Colors.enabled() else "" + nc = Colors.NC if Colors.enabled() else "" + print(f"{color}WARN: {message}{nc}", file=sys.stderr) + + +def log_error(message: str) -> None: + """Log error message.""" + color = Colors.RED if Colors.enabled() else "" + nc = Colors.NC if Colors.enabled() else "" + print(f"{color}ERROR: {message}{nc}", file=sys.stderr) + + +def log_success(message: str) -> None: + """Log success message.""" + color = Colors.GREEN if Colors.enabled() else "" + nc = Colors.NC if Colors.enabled() else "" + print(f"{color}✓ {message}{nc}") + + +def log_step(message: str) -> None: + """Log a step in a process.""" + color = Colors.CYAN if Colors.enabled() else "" + nc = Colors.NC if Colors.enabled() else "" + print(f"{color}➜ {message}{nc}") + + +def log_section(title: str) -> None: + """Log a section header.""" + print() + print("=" * 60) + print(title) + print("=" * 60) + + +def log_kv(key: str, value: str) -> None: + """Log a key-value pair.""" + print(f" {key}: {value}") + + +def die(message: str, exit_code: int = 1) -> None: + """ + Log error and exit. + + Args: + message: Error message + exit_code: Exit code (default: 1) + """ + log_error(message) + + if os.environ.get("VERBOSE_ERRORS", "true").lower() == "true": + print("", file=sys.stderr) + print("Stack trace:", file=sys.stderr) + traceback.print_stack(file=sys.stderr) + print("", file=sys.stderr) + print("Environment:", file=sys.stderr) + print(f" PWD: {os.getcwd()}", file=sys.stderr) + print(f" USER: {os.environ.get('USER', 'unknown')}", file=sys.stderr) + print(f" PYTHON: {sys.version}", file=sys.stderr) + print(f" CI: {is_ci()}", file=sys.stderr) + print("", file=sys.stderr) + + sys.exit(exit_code) + + +# ============================================================================ +# Validation Helpers +# ============================================================================ + +def assert_file_exists(path: Union[str, Path]) -> None: + """ + Assert that a file exists. + + Args: + path: Path to file + + Raises: + SystemExit: If file doesn't exist + """ + if not Path(path).is_file(): + die(f"Required file missing: {path}") + + +def assert_dir_exists(path: Union[str, Path]) -> None: + """ + Assert that a directory exists. + + Args: + path: Path to directory + + Raises: + SystemExit: If directory doesn't exist + """ + if not Path(path).is_dir(): + die(f"Required directory missing: {path}") + + +def assert_not_empty(value: Any, name: str) -> None: + """ + Assert that a value is not empty. + + Args: + value: Value to check + name: Name of the value for error message + + Raises: + SystemExit: If value is empty + """ + if not value: + die(f"Required value is empty: {name}") + + +# ============================================================================ +# JSON Utilities +# ============================================================================ + +def json_escape(text: str) -> str: + """ + Escape text for JSON. + + Args: + text: Text to escape + + Returns: + Escaped text + """ + return json.dumps(text)[1:-1] # Remove surrounding quotes + + +def json_output(data: Dict[str, Any], pretty: bool = False) -> None: + """ + Output data as JSON. + + Args: + data: Dictionary to output + pretty: Whether to pretty-print + """ + if pretty: + print(json.dumps(data, indent=2, sort_keys=True)) + else: + print(json.dumps(data, separators=(',', ':'))) + + +# ============================================================================ +# Path Utilities +# ============================================================================ + +def script_root() -> Path: + """ + Get the root scripts directory. + + Returns: + Path to scripts directory + """ + return Path(__file__).parent.parent + + +def repo_root() -> Path: + """ + Get the repository root directory. + + Returns: + Path to repository root + """ + return script_root().parent + + +def normalize_path(path: Union[str, Path]) -> str: + """ + Normalize a path (resolve, absolute, forward slashes). + + Args: + path: Path to normalize + + Returns: + Normalized path string + """ + return str(Path(path).resolve()).replace("\\", "/") + + +# ============================================================================ +# File Operations +# ============================================================================ + +def read_file(path: Union[str, Path], encoding: str = "utf-8") -> str: + """ + Read a file. + + Args: + path: Path to file + encoding: File encoding + + Returns: + File contents + """ + assert_file_exists(path) + return Path(path).read_text(encoding=encoding) + + +def write_file(path: Union[str, Path], content: str, encoding: str = "utf-8") -> None: + """ + Write a file. + + Args: + path: Path to file + content: Content to write + encoding: File encoding + """ + Path(path).write_text(content, encoding=encoding) + + +def ensure_dir(path: Union[str, Path]) -> None: + """ + Ensure a directory exists. + + Args: + path: Path to directory + """ + Path(path).mkdir(parents=True, exist_ok=True) + + +# ============================================================================ +# Command Execution +# ============================================================================ + +def run_command( + cmd: List[str], + capture_output: bool = True, + check: bool = True, + cwd: Optional[Union[str, Path]] = None, + env: Optional[Dict[str, str]] = None +) -> subprocess.CompletedProcess: + """ + Run a command. + + Args: + cmd: Command and arguments + capture_output: Whether to capture stdout/stderr + check: Whether to raise on non-zero exit + cwd: Working directory + env: Environment variables + + Returns: + CompletedProcess instance + """ + return subprocess.run( + cmd, + capture_output=capture_output, + text=True, + check=check, + cwd=cwd, + env=env + ) + + +def run_shell( + script: str, + capture_output: bool = True, + check: bool = True, + cwd: Optional[Union[str, Path]] = None +) -> subprocess.CompletedProcess: + """ + Run a shell script. + + Args: + script: Shell script + capture_output: Whether to capture stdout/stderr + check: Whether to raise on non-zero exit + cwd: Working directory + + Returns: + CompletedProcess instance + """ + return subprocess.run( + script, + shell=True, + capture_output=capture_output, + text=True, + check=check, + cwd=cwd + ) + + +# ============================================================================ +# Git Utilities +# ============================================================================ + +def git_root() -> Path: + """ + Get git repository root. + + Returns: + Path to git root + """ + result = run_command( + ["git", "rev-parse", "--show-toplevel"], + capture_output=True, + check=True + ) + return Path(result.stdout.strip()) + + +def git_status(porcelain: bool = True) -> str: + """ + Get git status. + + Args: + porcelain: Use porcelain format + + Returns: + Git status output + """ + cmd = ["git", "status"] + if porcelain: + cmd.append("--porcelain") + + result = run_command(cmd, capture_output=True, check=True) + return result.stdout + + +def git_branch() -> str: + """ + Get current git branch. + + Returns: + Branch name + """ + result = run_command( + ["git", "rev-parse", "--abbrev-ref", "HEAD"], + capture_output=True, + check=True + ) + return result.stdout.strip() + + +# ============================================================================ +# Main Entry Point (for testing) +# ============================================================================ + +def main() -> None: + """Test the common utilities.""" + log_section("Testing Common Utilities") + + log_info("This is an info message") + log_warn("This is a warning message") + log_success("This is a success message") + log_step("This is a step message") + + log_section("Environment") + log_kv("CI", str(is_ci())) + log_kv("Script Root", str(script_root())) + log_kv("Repo Root", str(repo_root())) + log_kv("Git Root", str(git_root())) + log_kv("Git Branch", git_branch()) + + log_section("Tests Passed") + + +if __name__ == "__main__": + main() diff --git a/scripts/lib/joomla_manifest.py b/scripts/lib/joomla_manifest.py new file mode 100755 index 0000000..c7322d1 --- /dev/null +++ b/scripts/lib/joomla_manifest.py @@ -0,0 +1,430 @@ +#!/usr/bin/env python3 +""" +Joomla manifest parsing and validation utilities. + +Copyright (C) 2025 Moko Consulting + +This file is part of a Moko Consulting project. + +SPDX-License-Identifier: GPL-3.0-or-later + +This program is free software; you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation; either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program (./LICENSE.md). + +FILE INFORMATION +DEFGROUP: Script.Library +INGROUP: Joomla.Manifest +REPO: https://github.com/mokoconsulting-tech/moko-cassiopeia +PATH: /scripts/lib/joomla_manifest.py +VERSION: 01.00.00 +BRIEF: Joomla manifest parsing and validation utilities +""" + +import xml.etree.ElementTree as ET +from pathlib import Path +from typing import Dict, List, Optional, Tuple +from dataclasses import dataclass + +try: + from . import common +except ImportError: + import common + + +# ============================================================================ +# Joomla Extension Types +# ============================================================================ + +class ExtensionType: + """Joomla extension types.""" + COMPONENT = "component" + MODULE = "module" + PLUGIN = "plugin" + TEMPLATE = "template" + LIBRARY = "library" + PACKAGE = "package" + FILE = "file" + LANGUAGE = "language" + + ALL_TYPES = [ + COMPONENT, MODULE, PLUGIN, TEMPLATE, + LIBRARY, PACKAGE, FILE, LANGUAGE + ] + + +# ============================================================================ +# Manifest Data Class +# ============================================================================ + +@dataclass +class ManifestInfo: + """Information extracted from a Joomla manifest.""" + path: Path + extension_type: str + name: str + version: str + description: Optional[str] = None + author: Optional[str] = None + author_email: Optional[str] = None + author_url: Optional[str] = None + copyright: Optional[str] = None + license: Optional[str] = None + creation_date: Optional[str] = None + + def to_dict(self) -> Dict[str, str]: + """Convert to dictionary.""" + return { + "path": str(self.path), + "ext_type": self.extension_type, + "name": self.name, + "version": self.version, + "description": self.description or "", + "author": self.author or "", + "author_email": self.author_email or "", + "author_url": self.author_url or "", + "copyright": self.copyright or "", + "license": self.license or "", + "creation_date": self.creation_date or "" + } + + +# ============================================================================ +# Manifest Discovery +# ============================================================================ + +def find_manifest(src_dir: str = "src") -> Path: + """ + Find the primary Joomla manifest in the given directory. + + Args: + src_dir: Source directory to search + + Returns: + Path to manifest file + + Raises: + SystemExit: If no manifest found + """ + src_path = Path(src_dir) + + if not src_path.is_dir(): + common.die(f"Source directory missing: {src_dir}") + + # Template manifest (templateDetails.xml) + template_manifest = src_path / "templateDetails.xml" + if template_manifest.is_file(): + return template_manifest + + # Also check in templates subdirectory + templates_dir = src_path / "templates" + if templates_dir.is_dir(): + for template_file in templates_dir.glob("templateDetails.xml"): + return template_file + + # Package manifest (pkg_*.xml) + pkg_manifests = list(src_path.rglob("pkg_*.xml")) + if pkg_manifests: + return pkg_manifests[0] + + # Component manifest (com_*.xml) + com_manifests = list(src_path.rglob("com_*.xml")) + if com_manifests: + return com_manifests[0] + + # Module manifest (mod_*.xml) + mod_manifests = list(src_path.rglob("mod_*.xml")) + if mod_manifests: + return mod_manifests[0] + + # Plugin manifest (plg_*.xml) + plg_manifests = list(src_path.rglob("plg_*.xml")) + if plg_manifests: + return plg_manifests[0] + + # Fallback: any XML with List[Path]: + """ + Find all Joomla manifests in the given directory. + + Args: + src_dir: Source directory to search + + Returns: + List of manifest paths + """ + src_path = Path(src_dir) + + if not src_path.is_dir(): + return [] + + manifests = [] + + # Template manifests + manifests.extend(src_path.rglob("templateDetails.xml")) + + # Package manifests + manifests.extend(src_path.rglob("pkg_*.xml")) + + # Component manifests + manifests.extend(src_path.rglob("com_*.xml")) + + # Module manifests + manifests.extend(src_path.rglob("mod_*.xml")) + + # Plugin manifests + manifests.extend(src_path.rglob("plg_*.xml")) + + return manifests + + +# ============================================================================ +# Manifest Parsing +# ============================================================================ + +def parse_manifest(manifest_path: Path) -> ManifestInfo: + """ + Parse a Joomla manifest file. + + Args: + manifest_path: Path to manifest file + + Returns: + ManifestInfo object + + Raises: + SystemExit: If parsing fails + """ + if not manifest_path.is_file(): + common.die(f"Manifest not found: {manifest_path}") + + try: + tree = ET.parse(manifest_path) + root = tree.getroot() + + # Extract extension type + ext_type = root.attrib.get("type", "").strip().lower() + if not ext_type: + common.die(f"Manifest missing type attribute: {manifest_path}") + + # Extract name + name_elem = root.find("name") + if name_elem is None or not name_elem.text: + common.die(f"Manifest missing name element: {manifest_path}") + name = name_elem.text.strip() + + # Extract version + version_elem = root.find("version") + if version_elem is None or not version_elem.text: + common.die(f"Manifest missing version element: {manifest_path}") + version = version_elem.text.strip() + + # Extract optional fields + description = None + desc_elem = root.find("description") + if desc_elem is not None and desc_elem.text: + description = desc_elem.text.strip() + + author = None + author_elem = root.find("author") + if author_elem is not None and author_elem.text: + author = author_elem.text.strip() + + author_email = None + email_elem = root.find("authorEmail") + if email_elem is not None and email_elem.text: + author_email = email_elem.text.strip() + + author_url = None + url_elem = root.find("authorUrl") + if url_elem is not None and url_elem.text: + author_url = url_elem.text.strip() + + copyright_text = None + copyright_elem = root.find("copyright") + if copyright_elem is not None and copyright_elem.text: + copyright_text = copyright_elem.text.strip() + + license_text = None + license_elem = root.find("license") + if license_elem is not None and license_elem.text: + license_text = license_elem.text.strip() + + creation_date = None + date_elem = root.find("creationDate") + if date_elem is not None and date_elem.text: + creation_date = date_elem.text.strip() + + return ManifestInfo( + path=manifest_path, + extension_type=ext_type, + name=name, + version=version, + description=description, + author=author, + author_email=author_email, + author_url=author_url, + copyright=copyright_text, + license=license_text, + creation_date=creation_date + ) + + except ET.ParseError as e: + common.die(f"Failed to parse manifest {manifest_path}: {e}") + except Exception as e: + common.die(f"Error reading manifest {manifest_path}: {e}") + + +def get_manifest_version(manifest_path: Path) -> str: + """ + Extract version from manifest. + + Args: + manifest_path: Path to manifest file + + Returns: + Version string + """ + info = parse_manifest(manifest_path) + return info.version + + +def get_manifest_name(manifest_path: Path) -> str: + """ + Extract name from manifest. + + Args: + manifest_path: Path to manifest file + + Returns: + Name string + """ + info = parse_manifest(manifest_path) + return info.name + + +def get_manifest_type(manifest_path: Path) -> str: + """ + Extract extension type from manifest. + + Args: + manifest_path: Path to manifest file + + Returns: + Extension type string + """ + info = parse_manifest(manifest_path) + return info.extension_type + + +# ============================================================================ +# Manifest Validation +# ============================================================================ + +def validate_manifest(manifest_path: Path) -> Tuple[bool, List[str]]: + """ + Validate a Joomla manifest. + + Args: + manifest_path: Path to manifest file + + Returns: + Tuple of (is_valid, list_of_warnings) + """ + warnings = [] + + try: + info = parse_manifest(manifest_path) + + # Check for recommended fields + if not info.description: + warnings.append("Missing description element") + + if not info.author: + warnings.append("Missing author element") + + if not info.copyright: + warnings.append("Missing copyright element") + + if not info.license: + warnings.append("Missing license element") + + if not info.creation_date: + warnings.append("Missing creationDate element") + + # Validate extension type + if info.extension_type not in ExtensionType.ALL_TYPES: + warnings.append(f"Unknown extension type: {info.extension_type}") + + return (True, warnings) + + except SystemExit: + return (False, ["Failed to parse manifest"]) + + +# ============================================================================ +# Main Entry Point (for testing) +# ============================================================================ + +def main() -> None: + """Test the manifest utilities.""" + import sys + + common.log_section("Testing Joomla Manifest Utilities") + + src_dir = sys.argv[1] if len(sys.argv) > 1 else "src" + + try: + manifest = find_manifest(src_dir) + common.log_success(f"Found manifest: {manifest}") + + info = parse_manifest(manifest) + + common.log_section("Manifest Information") + common.log_kv("Type", info.extension_type) + common.log_kv("Name", info.name) + common.log_kv("Version", info.version) + + if info.description: + common.log_kv("Description", info.description[:60] + "..." if len(info.description) > 60 else info.description) + + if info.author: + common.log_kv("Author", info.author) + + is_valid, warnings = validate_manifest(manifest) + + if is_valid: + common.log_success("Manifest is valid") + if warnings: + common.log_warn(f"Warnings: {len(warnings)}") + for warning in warnings: + print(f" - {warning}") + else: + common.log_error("Manifest validation failed") + + except SystemExit as e: + sys.exit(e.code) + + +if __name__ == "__main__": + main() diff --git a/scripts/run/scaffold_extension.py b/scripts/run/scaffold_extension.py new file mode 100755 index 0000000..19306f6 --- /dev/null +++ b/scripts/run/scaffold_extension.py @@ -0,0 +1,448 @@ +#!/usr/bin/env python3 +""" +Create Joomla extension scaffolding. + +Copyright (C) 2025 Moko Consulting + +This file is part of a Moko Consulting project. + +SPDX-License-Identifier: GPL-3.0-or-later + +This program is free software; you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation; either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program (./LICENSE.md). + +FILE INFORMATION +DEFGROUP: Moko-Cassiopeia.Scripts +INGROUP: Scripts.Run +REPO: https://github.com/mokoconsulting-tech/moko-cassiopeia +PATH: /scripts/run/scaffold_extension.py +VERSION: 01.00.00 +BRIEF: Create scaffolding for different Joomla extension types +""" + +import argparse +import sys +from datetime import datetime +from pathlib import Path +from typing import Dict + +# Add lib directory to path +sys.path.insert(0, str(Path(__file__).parent.parent / "lib")) + +try: + import common + import joomla_manifest +except ImportError: + print("ERROR: Cannot import required libraries", file=sys.stderr) + sys.exit(1) + + +# ============================================================================ +# Templates for Extension Scaffolding +# ============================================================================ + +def get_component_structure(name: str, description: str, author: str) -> Dict[str, str]: + """Get directory structure and files for a component.""" + safe_name = name.lower().replace(" ", "_") + com_name = f"com_{safe_name}" + + manifest = f""" + + {name} + {author} + {datetime.now().strftime("%Y-%m-%d")} + Copyright (C) {datetime.now().year} {author} + GPL-3.0-or-later + hello@example.com + https://example.com + 1.0.0 + {description} + + + src + + + + {name} + + services + sql + src + + + +""" + + return { + f"{com_name}.xml": manifest, + "site/src/.gitkeep": "", + "admin/services/provider.php": f" Dict[str, str]: + """Get directory structure and files for a module.""" + safe_name = name.lower().replace(" ", "_") + mod_name = f"mod_{safe_name}" + + manifest = f""" + + {name} + {author} + {datetime.now().strftime("%Y-%m-%d")} + Copyright (C) {datetime.now().year} {author} + GPL-3.0-or-later + hello@example.com + https://example.com + 1.0.0 + {description} + + + {mod_name}.php + {mod_name}.xml + tmpl + + +""" + + module_php = f"""get('layout', 'default')); +""" + + default_tmpl = f""" +
+

+
+""" + + return { + f"{mod_name}.xml": manifest, + f"{mod_name}.php": module_php, + "tmpl/default.php": default_tmpl, + } + + +def get_plugin_structure(name: str, description: str, author: str, group: str = "system") -> Dict[str, str]: + """Get directory structure and files for a plugin.""" + safe_name = name.lower().replace(" ", "_") + plg_name = f"{safe_name}" + + manifest = f""" + + plg_{group}_{safe_name} + {author} + {datetime.now().strftime("%Y-%m-%d")} + Copyright (C) {datetime.now().year} {author} + GPL-3.0-or-later + hello@example.com + https://example.com + 1.0.0 + {description} + + + {plg_name}.php + + +""" + + plugin_php = f""" Dict[str, str]: + """Get directory structure and files for a template.""" + safe_name = name.lower().replace(" ", "_") + + manifest = f""" + + {safe_name} + {datetime.now().strftime("%Y-%m-%d")} + {author} + hello@example.com + https://example.com + Copyright (C) {datetime.now().year} {author} + GPL-3.0-or-later + 1.0.0 + {description} + + + index.php + templateDetails.xml + css + js + images + + + + header + main + footer + + +""" + + index_php = f"""getDocument()->getWebAssetManager(); + +// Load template assets +$wa->useStyle('template.{safe_name}')->useScript('template.{safe_name}'); +?> + + + + + + + + +
+ +
+
+ +
+
+ +
+ + +""" + + return { + "templateDetails.xml": manifest, + "index.php": index_php, + "css/template.css": "/* Template styles */\n", + "js/template.js": "// Template JavaScript\n", + "images/.gitkeep": "", + } + + +def get_package_structure(name: str, description: str, author: str) -> Dict[str, str]: + """Get directory structure and files for a package.""" + safe_name = name.lower().replace(" ", "_") + pkg_name = f"pkg_{safe_name}" + + manifest = f""" + + {name} + {safe_name} + {author} + {datetime.now().strftime("%Y-%m-%d")} + Copyright (C) {datetime.now().year} {author} + GPL-3.0-or-later + hello@example.com + https://example.com + 1.0.0 + {description} + + + + + +""" + + return { + f"{pkg_name}.xml": manifest, + "packages/.gitkeep": "", + } + + +# ============================================================================ +# Scaffolding Functions +# ============================================================================ + +def create_extension( + ext_type: str, + name: str, + description: str, + author: str, + output_dir: str = "src", + **kwargs +) -> None: + """ + Create extension scaffolding. + + Args: + ext_type: Extension type (component, module, plugin, template, package) + name: Extension name + description: Extension description + author: Author name + output_dir: Output directory + **kwargs: Additional type-specific options + """ + output_path = Path(output_dir) + + # Get structure based on type + if ext_type == "component": + structure = get_component_structure(name, description, author) + elif ext_type == "module": + client = kwargs.get("client", "site") + structure = get_module_structure(name, description, author, client) + elif ext_type == "plugin": + group = kwargs.get("group", "system") + structure = get_plugin_structure(name, description, author, group) + elif ext_type == "template": + structure = get_template_structure(name, description, author) + elif ext_type == "package": + structure = get_package_structure(name, description, author) + else: + common.die(f"Unknown extension type: {ext_type}") + + # Create files + common.log_section(f"Creating {ext_type}: {name}") + + for file_path, content in structure.items(): + full_path = output_path / file_path + + # Create parent directories + full_path.parent.mkdir(parents=True, exist_ok=True) + + # Write file + full_path.write_text(content, encoding="utf-8") + common.log_success(f"Created: {file_path}") + + common.log_section("Scaffolding Complete") + common.log_info(f"Extension files created in: {output_path}") + common.log_info(f"Extension type: {ext_type}") + common.log_info(f"Extension name: {name}") + + +# ============================================================================ +# Command Line Interface +# ============================================================================ + +def main() -> None: + """Main entry point.""" + parser = argparse.ArgumentParser( + description="Create Joomla extension scaffolding", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + # Create a component + %(prog)s component MyComponent "My Component Description" "John Doe" + + # Create a module + %(prog)s module MyModule "My Module Description" "John Doe" --client site + + # Create a plugin + %(prog)s plugin MyPlugin "My Plugin Description" "John Doe" --group system + + # Create a template + %(prog)s template mytheme "My Theme Description" "John Doe" + + # Create a package + %(prog)s package mypackage "My Package Description" "John Doe" +""" + ) + + parser.add_argument( + "type", + choices=["component", "module", "plugin", "template", "package"], + help="Extension type to create" + ) + parser.add_argument("name", help="Extension name") + parser.add_argument("description", help="Extension description") + parser.add_argument("author", help="Author name") + parser.add_argument( + "-o", "--output", + default="src", + help="Output directory (default: src)" + ) + parser.add_argument( + "--client", + choices=["site", "administrator"], + default="site", + help="Module client (site or administrator)" + ) + parser.add_argument( + "--group", + default="system", + help="Plugin group (system, content, user, etc.)" + ) + + args = parser.parse_args() + + try: + create_extension( + ext_type=args.type, + name=args.name, + description=args.description, + author=args.author, + output_dir=args.output, + client=args.client, + group=args.group + ) + except Exception as e: + common.log_error(f"Failed to create extension: {e}") + sys.exit(1) + + +if __name__ == "__main__": + main()