domma-cms 0.36.15 → 0.36.16
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.
- package/CLAUDE.md +1 -0
- package/admin/js/api.js +1 -1
- package/admin/js/templates/plugins.html +8 -3
- package/admin/js/views/plugins.js +16 -16
- package/package.json +1 -1
- package/server/routes/api/plugins.js +20 -3
- package/server/services/pluginScaffold.js +136 -0
package/CLAUDE.md
CHANGED
|
@@ -168,6 +168,7 @@ Add custom public-site JS to `public/js/site.js` or new files loaded from `publi
|
|
|
168
168
|
| `email.js` | SMTP mail sending (uses `config/site.json` SMTP settings) |
|
|
169
169
|
| `images.js` | Image processing / thumbnail generation |
|
|
170
170
|
| `plugins.js` | Plugin discovery, validation, registration, injection snippets |
|
|
171
|
+
| `pluginScaffold.js` | Scaffold new plugins from `plugins/_template` (`POST /api/plugins/scaffold`, pre-enabled, restart to activate) |
|
|
171
172
|
| `cache/index.js` | Pluggable response cache (Memory/None/Redis drivers) with tag-based invalidation. See `docs/cache.md`. |
|
|
172
173
|
|
|
173
174
|
## Storage Adapters
|
package/admin/js/api.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
const a="/api";function d(){return S.get("auth_token")}function c(){return S.get("auth_refresh_token")}function l(e){S.set("auth_token",e)}function h(){S.remove("auth_token"),S.remove("auth_refresh_token"),S.remove("auth_user")}async function u(){const e=c();if(!e)throw new Error("No refresh token");const o=await fetch(`${a}/auth/refresh`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({refreshToken:e})});if(!o.ok)throw h(),R.navigate("/login"),new Error("Token refresh failed");const{token:s}=await o.json();return l(s),s}async function t(e,o={}){let s=d();const r=i=>({...o.body!==void 0?{"Content-Type":"application/json"}:{},...o.headers,...i?{Authorization:`Bearer ${i}`}:{}});let n=await fetch(`${a}${e}`,{...o,headers:r(s)});if(n.status===401&&c())try{s=await u(),n=await fetch(`${a}${e}`,{...o,headers:r(s)})}catch{return}if(!n.ok){const i=await n.json().catch(()=>({error:"Request failed"}));throw new Error(i.error||i.message||`HTTP ${n.status}`)}return n.status===204?null:n.json()}async function p(e,o){const s=d(),r=s?{Authorization:`Bearer ${s}`}:{},n=await fetch(`${a}${e}`,{method:"POST",headers:r,body:o});if(!n.ok){const i=await n.json().catch(()=>({error:"Upload failed"}));throw new Error(i.error||i.message||`HTTP ${n.status}`)}return n.json()}export const api={auth:{setupStatus:()=>t("/auth/setup-status",{method:"GET"}),setup:e=>t("/auth/setup",{method:"POST",body:JSON.stringify(e)}),login:e=>t("/auth/login",{method:"POST",body:JSON.stringify(e)}),me:()=>t("/auth/me",{method:"GET"}),updateMe:e=>t("/auth/me",{method:"PUT",body:JSON.stringify(e)}),refresh:e=>t("/auth/refresh",{method:"POST",body:JSON.stringify({refreshToken:e})}),forgotPassword:e=>t("/auth/forgot-password",{method:"POST",body:JSON.stringify({email:e})}),resetPassword:(e,o)=>t("/auth/reset-password",{method:"POST",body:JSON.stringify({token:e,password:o})})},pages:{list:()=>t("/pages",{method:"GET"}),get:e=>t(`/pages${e}`,{method:"GET"}),create:e=>t("/pages",{method:"POST",body:JSON.stringify(e)}),update:(e,o)=>t(`/pages${e}`,{method:"PUT",body:JSON.stringify(o)}),delete:e=>t(`/pages${e}`,{method:"DELETE"}),preview:e=>t("/pages/preview",{method:"POST",body:JSON.stringify({markdown:e})}),tags:()=>t("/pages/tags",{method:"GET"}).then(e=>e.tags||[])},settings:{get:()=>t("/settings",{method:"GET"}),save:e=>t("/settings",{method:"PUT",body:JSON.stringify(e)}),getCustomCss:()=>t("/settings/custom-css",{method:"GET"}),saveCustomCss:e=>t("/settings/custom-css",{method:"PUT",body:JSON.stringify({css:e})})},navigation:{get:()=>t("/navigation",{method:"GET"}),save:e=>t("/navigation",{method:"PUT",body:JSON.stringify(e)})},menus:{list:()=>t("/menus",{method:"GET"}),get:e=>t(`/menus/${encodeURIComponent(e)}`,{method:"GET"}),create:e=>t("/menus",{method:"POST",body:JSON.stringify(e)}),update:(e,o)=>t(`/menus/${encodeURIComponent(e)}`,{method:"PUT",body:JSON.stringify(o)}),remove:e=>t(`/menus/${encodeURIComponent(e)}`,{method:"DELETE"}),duplicate:e=>t(`/menus/${encodeURIComponent(e)}/duplicate`,{method:"POST"})},menuLocations:{get:()=>t("/menu-locations",{method:"GET"}),save:e=>t("/menu-locations",{method:"PUT",body:JSON.stringify(e)}),registry:()=>t("/menu-locations/registry",{method:"GET"})},projects:{list:()=>t("/projects",{method:"GET"}),get:e=>t(`/projects/${encodeURIComponent(e)}`,{method:"GET"}),create:e=>t("/projects",{method:"POST",body:JSON.stringify(e)}),update:(e,o)=>t(`/projects/${encodeURIComponent(e)}`,{method:"PUT",body:JSON.stringify(o)}),remove:e=>t(`/projects/${encodeURIComponent(e)}`,{method:"DELETE"}),artefacts:e=>t(`/projects/${encodeURIComponent(e)}/artefacts`,{method:"GET"}),untagAll:e=>t(`/projects/${encodeURIComponent(e)}/untag-all`,{method:"POST"})},apiTokens:{list:()=>t("/api-tokens",{method:"GET"}),create:e=>t("/api-tokens",{method:"POST",body:JSON.stringify(e)}),update:(e,o)=>t(`/api-tokens/${encodeURIComponent(e)}`,{method:"PUT",body:JSON.stringify(o)}),revoke:e=>t(`/api-tokens/${encodeURIComponent(e)}`,{method:"DELETE"})},apiEndpoints:{list:()=>t("/api-endpoints",{method:"GET"}),get:e=>t(`/api-endpoints/${encodeURIComponent(e)}`,{method:"GET"}),create:e=>t("/api-endpoints",{method:"POST",body:JSON.stringify(e)}),update:(e,o)=>t(`/api-endpoints/${encodeURIComponent(e)}`,{method:"PUT",body:JSON.stringify(o)}),remove:e=>t(`/api-endpoints/${encodeURIComponent(e)}`,{method:"DELETE"})},layouts:{get:()=>t("/layouts",{method:"GET"}),save:e=>t("/layouts",{method:"PUT",body:JSON.stringify(e)}),create:e=>t("/layouts",{method:"POST",body:JSON.stringify(e)}),update:(e,o)=>t(`/layouts/${e}`,{method:"PUT",body:JSON.stringify(o)}),remove:e=>t(`/layouts/${e}`,{method:"DELETE"}),getOptions:()=>t("/layouts/options",{method:"GET"}),saveOptions:e=>t("/layouts/options",{method:"PUT",body:JSON.stringify(e)})},media:{list:()=>t("/media",{method:"GET"}),upload:e=>p("/media",e),delete:e=>t(`/media/${encodeURIComponent(e)}`,{method:"DELETE"}),rename:(e,o)=>t(`/media/${encodeURIComponent(e)}`,{method:"PATCH",body:JSON.stringify({newName:o})}),info:e=>t(`/media/${encodeURIComponent(e)}/info`,{method:"GET"}),transform:(e,o)=>t(`/media/${encodeURIComponent(e)}/transform`,{method:"POST",body:JSON.stringify(o)})},users:{list:()=>t("/users",{method:"GET"}),get:e=>t(`/users/${e}`,{method:"GET"}),create:e=>t("/users",{method:"POST",body:JSON.stringify(e)}),update:(e,o)=>t(`/users/${e}`,{method:"PUT",body:JSON.stringify(o)}),delete:e=>t(`/users/${e}`,{method:"DELETE"})},plugins:{list:()=>t("/plugins",{method:"GET"}),update:(e,o)=>t(`/plugins/${e}`,{method:"PUT",body:JSON.stringify(o)}),adminConfig:()=>t("/plugins/admin-config",{method:"GET"})},marketplace:{catalogue:()=>t("/plugins/marketplace",{method:"GET"}),install:(e,o)=>t("/plugins/marketplace/install",{method:"POST",body:JSON.stringify({slug:e,version:o})}),uninstall:e=>t(`/plugins/marketplace/${encodeURIComponent(e)}`,{method:"DELETE"})},collections:{list:()=>t("/collections",{method:"GET"}),proStatus:()=>t("/collections/pro-status",{method:"GET"}),get:e=>t(`/collections/${e}`,{method:"GET"}),create:e=>t("/collections",{method:"POST",body:JSON.stringify(e)}),update:(e,o)=>t(`/collections/${e}`,{method:"PUT",body:JSON.stringify(o)}),delete:e=>t(`/collections/${e}`,{method:"DELETE"}),listEntries:(e,o={})=>{const s=new URLSearchParams(o).toString();return t(`/collections/${e}/entries${s?"?"+s:""}`,{method:"GET"})},getEntry:(e,o)=>t(`/collections/${e}/entries/${o}`,{method:"GET"}),createEntry:(e,o)=>t(`/collections/${e}/entries`,{method:"POST",body:JSON.stringify({data:o})}),updateEntry:(e,o,s)=>t(`/collections/${e}/entries/${o}`,{method:"PUT",body:JSON.stringify({data:s})}),deleteEntry:(e,o)=>t(`/collections/${e}/entries/${o}`,{method:"DELETE"}),clearEntries:e=>t(`/collections/${e}/entries`,{method:"DELETE"}),import:(e,o)=>t(`/collections/${e}/import`,{method:"POST",body:JSON.stringify({entries:o})}),publicList:(e,o={})=>{const s=new URLSearchParams(o).toString();return t(`/collections/${e}/public${s?"?"+s:""}`,{method:"GET"})},getConnections:()=>t("/collections/connections",{method:"GET"}),saveConnections:e=>t("/collections/connections",{method:"PUT",body:JSON.stringify(e)}),migrateStorage:(e,o)=>t(`/collections/${e}/migrate-storage`,{method:"POST",body:JSON.stringify({storage:o})})},forms:{list:()=>t("/forms",{method:"GET"}),create:e=>t("/forms",{method:"POST",body:JSON.stringify(e)}),get:e=>t(`/forms/${e}`,{method:"GET"}),update:(e,o)=>t(`/forms/${e}`,{method:"PUT",body:JSON.stringify(o)}),delete:e=>t(`/forms/${e}`,{method:"DELETE"}),listSubmissions:e=>t(`/forms/${e}/submissions`,{method:"GET"}),clearSubmissions:e=>t(`/forms/${e}/submissions`,{method:"DELETE"}),deleteSubmission:(e,o)=>t(`/forms/${e}/submissions/${o}`,{method:"DELETE"}),testEmail:e=>t("/forms/test-email",{method:"POST",body:JSON.stringify({to:e})})},views:{list:()=>t("/views",{method:"GET"}),get:e=>t(`/views/${e}`,{method:"GET"}),create:e=>t("/views",{method:"POST",body:JSON.stringify(e)}),update:(e,o)=>t(`/views/${e}`,{method:"PUT",body:JSON.stringify(o)}),delete:e=>t(`/views/${e}`,{method:"DELETE"}),execute:(e,o={})=>{const s=new URLSearchParams(o).toString();return t(`/views/${e}/execute${s?"?"+s:""}`,{method:"GET"})},forCollection:e=>t(`/views/collection/${e}`,{method:"GET"})},actions:{list:()=>t("/actions",{method:"GET"}),get:e=>t(`/actions/${e}`,{method:"GET"}),create:e=>t("/actions",{method:"POST",body:JSON.stringify(e)}),update:(e,o)=>t(`/actions/${e}`,{method:"PUT",body:JSON.stringify(o)}),delete:e=>t(`/actions/${e}`,{method:"DELETE"}),execute:(e,o)=>t(`/actions/${e}/execute`,{method:"POST",body:JSON.stringify({entryId:o})}),forCollection:e=>t(`/actions/collection/${e}`,{method:"GET"}),checkAccess:(e,o)=>t(`/actions/${e}/check-access`,{method:"POST",body:JSON.stringify({entryIds:o})})},versions:{list:e=>t(`/versions/list${e}`),get:(e,o)=>t(`/versions/get/${encodeURIComponent(o)}${e}`),create:(e,o)=>t(`/versions/create${e}`,{method:"POST",body:JSON.stringify({label:o})}),restore:(e,o)=>t(`/versions/restore/${encodeURIComponent(o)}${e}`,{method:"POST"}),delete:(e,o)=>t(`/versions/delete/${encodeURIComponent(o)}${e}`,{method:"DELETE"}),bulkDelete:(e,o)=>t(`/versions/bulk-delete${e}`,{method:"POST",body:JSON.stringify({filenames:o})}),prune:(e,o)=>t(`/versions/prune${e}`,{method:"POST",body:JSON.stringify({keep:o})})},blocks:{list:()=>t("/blocks",{method:"GET"}),get:e=>t(`/blocks/${encodeURIComponent(e)}`,{method:"GET"}),put:(e,o)=>t(`/blocks/${encodeURIComponent(e)}`,{method:"PUT",body:JSON.stringify(o)}),delete:e=>t(`/blocks/${encodeURIComponent(e)}`,{method:"DELETE"}),async exportBundle(e){const o=d(),s=await fetch(`${a}/blocks/${encodeURIComponent(e)}/export`,{headers:o?{Authorization:`Bearer ${o}`}:{}});if(!s.ok){const m=await s.json().catch(()=>({}));throw new Error(m.error||`Export failed (${s.status})`)}const r=await s.blob(),n=URL.createObjectURL(r),i=document.createElement("a");i.href=n,i.download=`${e}.dmblock.json`,document.body.appendChild(i),i.click(),i.remove(),URL.revokeObjectURL(n)},async importBundle(e,{overwrite:o=!1}={}){const s=await fetch(`${a}/blocks/import`,{method:"POST",headers:{"Content-Type":"application/json",...d()?{Authorization:`Bearer ${d()}`}:{}},body:JSON.stringify({...e,overwrite:o})});if(s.status===409){const r=await s.json().catch(()=>({})),n=new Error(r.error||"Block already exists");throw n.code="CONFLICT",n.name=r.name,n}if(!s.ok){const r=await s.json().catch(()=>({}));throw new Error(r.error||`Import failed (${s.status})`)}return s.json()}},components:{list:()=>t("/components",{method:"GET"}),get:e=>t(`/components/${encodeURIComponent(e)}`,{method:"GET"}),compile:(e,o)=>t("/components/compile",{method:"POST",body:JSON.stringify({name:e,source:o})}),put:(e,o)=>t(`/components/${encodeURIComponent(e)}`,{method:"PUT",body:JSON.stringify(o)}),delete:e=>t(`/components/${encodeURIComponent(e)}`,{method:"DELETE"}),async exportBundle(e){const o=d(),s=await fetch(`${a}/components/${encodeURIComponent(e)}/export`,{headers:o?{Authorization:`Bearer ${o}`}:{}});if(!s.ok){const m=await s.json().catch(()=>({}));throw new Error(m.error||`Export failed (${s.status})`)}const r=await s.blob(),n=URL.createObjectURL(r),i=document.createElement("a");i.href=n,i.download=`${e}.dmcomponent.json`,document.body.appendChild(i),i.click(),i.remove(),URL.revokeObjectURL(n)},async importBundle(e,{overwrite:o=!1}={}){const s=await fetch(`${a}/components/import`,{method:"POST",headers:{"Content-Type":"application/json",...d()?{Authorization:`Bearer ${d()}`}:{}},body:JSON.stringify({...e,overwrite:o})});if(s.status===409){const r=await s.json().catch(()=>({})),n=new Error(r.error||"Component already exists");throw n.code="CONFLICT",n.name=r.name,n}if(!s.ok){const r=await s.json().catch(()=>({}));throw new Error(r.error||`Import failed (${s.status})`)}return s.json()}},get:e=>t(e,{method:"GET"}),post:(e,o)=>t(e,{method:"POST",body:JSON.stringify(o)}),put:(e,o)=>t(e,{method:"PUT",body:JSON.stringify(o)}),delete:e=>t(e,{method:"DELETE"}),settingsExt:{testEmail:e=>t("/settings/test-email",{method:"POST",body:JSON.stringify({to:e})})},system:{notifications:{list:()=>t("/system/notifications",{method:"GET"}),unreadCount:()=>t("/system/notifications/unread-count",{method:"GET"}),markRead:e=>t(`/system/notifications/${e}/read`,{method:"POST"}),dismiss:e=>t(`/system/notifications/${e}/dismiss`,{method:"POST"}),remove:e=>t(`/system/notifications/${e}`,{method:"DELETE"})}},dashboard:{summary:()=>t("/dashboard/summary",{method:"GET"}),summaryLite:()=>t("/dashboard/summary?lite=1",{method:"GET"})}};export function isAuthenticated(){return!!d()}export function getUser(){return S.get("auth_user")}export function setAuthData({token:e,refreshToken:o,user:s}){e&&l(e),o&&S.set("auth_refresh_token",o),s&&S.set("auth_user",s)}export function logout(){const e=c();e&&fetch(`${a}/auth/logout`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({refreshToken:e})}).catch(()=>{}),h(),R.navigate("/login")}export{t as apiRequest,u as refreshAccessToken,d as getToken,h as clearAuth};
|
|
1
|
+
const a="/api";function d(){return S.get("auth_token")}function c(){return S.get("auth_refresh_token")}function l(e){S.set("auth_token",e)}function h(){S.remove("auth_token"),S.remove("auth_refresh_token"),S.remove("auth_user")}async function u(){const e=c();if(!e)throw new Error("No refresh token");const o=await fetch(`${a}/auth/refresh`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({refreshToken:e})});if(!o.ok)throw h(),R.navigate("/login"),new Error("Token refresh failed");const{token:s}=await o.json();return l(s),s}async function t(e,o={}){let s=d();const r=i=>({...o.body!==void 0?{"Content-Type":"application/json"}:{},...o.headers,...i?{Authorization:`Bearer ${i}`}:{}});let n=await fetch(`${a}${e}`,{...o,headers:r(s)});if(n.status===401&&c())try{s=await u(),n=await fetch(`${a}${e}`,{...o,headers:r(s)})}catch{return}if(!n.ok){const i=await n.json().catch(()=>({error:"Request failed"}));throw new Error(i.error||i.message||`HTTP ${n.status}`)}return n.status===204?null:n.json()}async function y(e,o){const s=d(),r=s?{Authorization:`Bearer ${s}`}:{},n=await fetch(`${a}${e}`,{method:"POST",headers:r,body:o});if(!n.ok){const i=await n.json().catch(()=>({error:"Upload failed"}));throw new Error(i.error||i.message||`HTTP ${n.status}`)}return n.json()}export const api={auth:{setupStatus:()=>t("/auth/setup-status",{method:"GET"}),setup:e=>t("/auth/setup",{method:"POST",body:JSON.stringify(e)}),login:e=>t("/auth/login",{method:"POST",body:JSON.stringify(e)}),me:()=>t("/auth/me",{method:"GET"}),updateMe:e=>t("/auth/me",{method:"PUT",body:JSON.stringify(e)}),refresh:e=>t("/auth/refresh",{method:"POST",body:JSON.stringify({refreshToken:e})}),forgotPassword:e=>t("/auth/forgot-password",{method:"POST",body:JSON.stringify({email:e})}),resetPassword:(e,o)=>t("/auth/reset-password",{method:"POST",body:JSON.stringify({token:e,password:o})})},pages:{list:()=>t("/pages",{method:"GET"}),get:e=>t(`/pages${e}`,{method:"GET"}),create:e=>t("/pages",{method:"POST",body:JSON.stringify(e)}),update:(e,o)=>t(`/pages${e}`,{method:"PUT",body:JSON.stringify(o)}),delete:e=>t(`/pages${e}`,{method:"DELETE"}),preview:e=>t("/pages/preview",{method:"POST",body:JSON.stringify({markdown:e})}),tags:()=>t("/pages/tags",{method:"GET"}).then(e=>e.tags||[])},settings:{get:()=>t("/settings",{method:"GET"}),save:e=>t("/settings",{method:"PUT",body:JSON.stringify(e)}),getCustomCss:()=>t("/settings/custom-css",{method:"GET"}),saveCustomCss:e=>t("/settings/custom-css",{method:"PUT",body:JSON.stringify({css:e})})},navigation:{get:()=>t("/navigation",{method:"GET"}),save:e=>t("/navigation",{method:"PUT",body:JSON.stringify(e)})},menus:{list:()=>t("/menus",{method:"GET"}),get:e=>t(`/menus/${encodeURIComponent(e)}`,{method:"GET"}),create:e=>t("/menus",{method:"POST",body:JSON.stringify(e)}),update:(e,o)=>t(`/menus/${encodeURIComponent(e)}`,{method:"PUT",body:JSON.stringify(o)}),remove:e=>t(`/menus/${encodeURIComponent(e)}`,{method:"DELETE"}),duplicate:e=>t(`/menus/${encodeURIComponent(e)}/duplicate`,{method:"POST"})},menuLocations:{get:()=>t("/menu-locations",{method:"GET"}),save:e=>t("/menu-locations",{method:"PUT",body:JSON.stringify(e)}),registry:()=>t("/menu-locations/registry",{method:"GET"})},projects:{list:()=>t("/projects",{method:"GET"}),get:e=>t(`/projects/${encodeURIComponent(e)}`,{method:"GET"}),create:e=>t("/projects",{method:"POST",body:JSON.stringify(e)}),update:(e,o)=>t(`/projects/${encodeURIComponent(e)}`,{method:"PUT",body:JSON.stringify(o)}),remove:e=>t(`/projects/${encodeURIComponent(e)}`,{method:"DELETE"}),artefacts:e=>t(`/projects/${encodeURIComponent(e)}/artefacts`,{method:"GET"}),untagAll:e=>t(`/projects/${encodeURIComponent(e)}/untag-all`,{method:"POST"})},apiTokens:{list:()=>t("/api-tokens",{method:"GET"}),create:e=>t("/api-tokens",{method:"POST",body:JSON.stringify(e)}),update:(e,o)=>t(`/api-tokens/${encodeURIComponent(e)}`,{method:"PUT",body:JSON.stringify(o)}),revoke:e=>t(`/api-tokens/${encodeURIComponent(e)}`,{method:"DELETE"})},apiEndpoints:{list:()=>t("/api-endpoints",{method:"GET"}),get:e=>t(`/api-endpoints/${encodeURIComponent(e)}`,{method:"GET"}),create:e=>t("/api-endpoints",{method:"POST",body:JSON.stringify(e)}),update:(e,o)=>t(`/api-endpoints/${encodeURIComponent(e)}`,{method:"PUT",body:JSON.stringify(o)}),remove:e=>t(`/api-endpoints/${encodeURIComponent(e)}`,{method:"DELETE"})},layouts:{get:()=>t("/layouts",{method:"GET"}),save:e=>t("/layouts",{method:"PUT",body:JSON.stringify(e)}),create:e=>t("/layouts",{method:"POST",body:JSON.stringify(e)}),update:(e,o)=>t(`/layouts/${e}`,{method:"PUT",body:JSON.stringify(o)}),remove:e=>t(`/layouts/${e}`,{method:"DELETE"}),getOptions:()=>t("/layouts/options",{method:"GET"}),saveOptions:e=>t("/layouts/options",{method:"PUT",body:JSON.stringify(e)})},media:{list:()=>t("/media",{method:"GET"}),upload:e=>y("/media",e),delete:e=>t(`/media/${encodeURIComponent(e)}`,{method:"DELETE"}),rename:(e,o)=>t(`/media/${encodeURIComponent(e)}`,{method:"PATCH",body:JSON.stringify({newName:o})}),info:e=>t(`/media/${encodeURIComponent(e)}/info`,{method:"GET"}),transform:(e,o)=>t(`/media/${encodeURIComponent(e)}/transform`,{method:"POST",body:JSON.stringify(o)})},users:{list:()=>t("/users",{method:"GET"}),get:e=>t(`/users/${e}`,{method:"GET"}),create:e=>t("/users",{method:"POST",body:JSON.stringify(e)}),update:(e,o)=>t(`/users/${e}`,{method:"PUT",body:JSON.stringify(o)}),delete:e=>t(`/users/${e}`,{method:"DELETE"})},plugins:{list:()=>t("/plugins",{method:"GET"}),scaffold:e=>t("/plugins/scaffold",{method:"POST",body:JSON.stringify(e)}),update:(e,o)=>t(`/plugins/${e}`,{method:"PUT",body:JSON.stringify(o)}),adminConfig:()=>t("/plugins/admin-config",{method:"GET"})},marketplace:{catalogue:()=>t("/plugins/marketplace",{method:"GET"}),install:(e,o)=>t("/plugins/marketplace/install",{method:"POST",body:JSON.stringify({slug:e,version:o})}),uninstall:e=>t(`/plugins/marketplace/${encodeURIComponent(e)}`,{method:"DELETE"})},collections:{list:()=>t("/collections",{method:"GET"}),proStatus:()=>t("/collections/pro-status",{method:"GET"}),get:e=>t(`/collections/${e}`,{method:"GET"}),create:e=>t("/collections",{method:"POST",body:JSON.stringify(e)}),update:(e,o)=>t(`/collections/${e}`,{method:"PUT",body:JSON.stringify(o)}),delete:e=>t(`/collections/${e}`,{method:"DELETE"}),listEntries:(e,o={})=>{const s=new URLSearchParams(o).toString();return t(`/collections/${e}/entries${s?"?"+s:""}`,{method:"GET"})},getEntry:(e,o)=>t(`/collections/${e}/entries/${o}`,{method:"GET"}),createEntry:(e,o)=>t(`/collections/${e}/entries`,{method:"POST",body:JSON.stringify({data:o})}),updateEntry:(e,o,s)=>t(`/collections/${e}/entries/${o}`,{method:"PUT",body:JSON.stringify({data:s})}),deleteEntry:(e,o)=>t(`/collections/${e}/entries/${o}`,{method:"DELETE"}),clearEntries:e=>t(`/collections/${e}/entries`,{method:"DELETE"}),import:(e,o)=>t(`/collections/${e}/import`,{method:"POST",body:JSON.stringify({entries:o})}),publicList:(e,o={})=>{const s=new URLSearchParams(o).toString();return t(`/collections/${e}/public${s?"?"+s:""}`,{method:"GET"})},getConnections:()=>t("/collections/connections",{method:"GET"}),saveConnections:e=>t("/collections/connections",{method:"PUT",body:JSON.stringify(e)}),migrateStorage:(e,o)=>t(`/collections/${e}/migrate-storage`,{method:"POST",body:JSON.stringify({storage:o})})},forms:{list:()=>t("/forms",{method:"GET"}),create:e=>t("/forms",{method:"POST",body:JSON.stringify(e)}),get:e=>t(`/forms/${e}`,{method:"GET"}),update:(e,o)=>t(`/forms/${e}`,{method:"PUT",body:JSON.stringify(o)}),delete:e=>t(`/forms/${e}`,{method:"DELETE"}),listSubmissions:e=>t(`/forms/${e}/submissions`,{method:"GET"}),clearSubmissions:e=>t(`/forms/${e}/submissions`,{method:"DELETE"}),deleteSubmission:(e,o)=>t(`/forms/${e}/submissions/${o}`,{method:"DELETE"}),testEmail:e=>t("/forms/test-email",{method:"POST",body:JSON.stringify({to:e})})},views:{list:()=>t("/views",{method:"GET"}),get:e=>t(`/views/${e}`,{method:"GET"}),create:e=>t("/views",{method:"POST",body:JSON.stringify(e)}),update:(e,o)=>t(`/views/${e}`,{method:"PUT",body:JSON.stringify(o)}),delete:e=>t(`/views/${e}`,{method:"DELETE"}),execute:(e,o={})=>{const s=new URLSearchParams(o).toString();return t(`/views/${e}/execute${s?"?"+s:""}`,{method:"GET"})},forCollection:e=>t(`/views/collection/${e}`,{method:"GET"})},actions:{list:()=>t("/actions",{method:"GET"}),get:e=>t(`/actions/${e}`,{method:"GET"}),create:e=>t("/actions",{method:"POST",body:JSON.stringify(e)}),update:(e,o)=>t(`/actions/${e}`,{method:"PUT",body:JSON.stringify(o)}),delete:e=>t(`/actions/${e}`,{method:"DELETE"}),execute:(e,o)=>t(`/actions/${e}/execute`,{method:"POST",body:JSON.stringify({entryId:o})}),forCollection:e=>t(`/actions/collection/${e}`,{method:"GET"}),checkAccess:(e,o)=>t(`/actions/${e}/check-access`,{method:"POST",body:JSON.stringify({entryIds:o})})},versions:{list:e=>t(`/versions/list${e}`),get:(e,o)=>t(`/versions/get/${encodeURIComponent(o)}${e}`),create:(e,o)=>t(`/versions/create${e}`,{method:"POST",body:JSON.stringify({label:o})}),restore:(e,o)=>t(`/versions/restore/${encodeURIComponent(o)}${e}`,{method:"POST"}),delete:(e,o)=>t(`/versions/delete/${encodeURIComponent(o)}${e}`,{method:"DELETE"}),bulkDelete:(e,o)=>t(`/versions/bulk-delete${e}`,{method:"POST",body:JSON.stringify({filenames:o})}),prune:(e,o)=>t(`/versions/prune${e}`,{method:"POST",body:JSON.stringify({keep:o})})},blocks:{list:()=>t("/blocks",{method:"GET"}),get:e=>t(`/blocks/${encodeURIComponent(e)}`,{method:"GET"}),put:(e,o)=>t(`/blocks/${encodeURIComponent(e)}`,{method:"PUT",body:JSON.stringify(o)}),delete:e=>t(`/blocks/${encodeURIComponent(e)}`,{method:"DELETE"}),async exportBundle(e){const o=d(),s=await fetch(`${a}/blocks/${encodeURIComponent(e)}/export`,{headers:o?{Authorization:`Bearer ${o}`}:{}});if(!s.ok){const m=await s.json().catch(()=>({}));throw new Error(m.error||`Export failed (${s.status})`)}const r=await s.blob(),n=URL.createObjectURL(r),i=document.createElement("a");i.href=n,i.download=`${e}.dmblock.json`,document.body.appendChild(i),i.click(),i.remove(),URL.revokeObjectURL(n)},async importBundle(e,{overwrite:o=!1}={}){const s=await fetch(`${a}/blocks/import`,{method:"POST",headers:{"Content-Type":"application/json",...d()?{Authorization:`Bearer ${d()}`}:{}},body:JSON.stringify({...e,overwrite:o})});if(s.status===409){const r=await s.json().catch(()=>({})),n=new Error(r.error||"Block already exists");throw n.code="CONFLICT",n.name=r.name,n}if(!s.ok){const r=await s.json().catch(()=>({}));throw new Error(r.error||`Import failed (${s.status})`)}return s.json()}},components:{list:()=>t("/components",{method:"GET"}),get:e=>t(`/components/${encodeURIComponent(e)}`,{method:"GET"}),compile:(e,o)=>t("/components/compile",{method:"POST",body:JSON.stringify({name:e,source:o})}),put:(e,o)=>t(`/components/${encodeURIComponent(e)}`,{method:"PUT",body:JSON.stringify(o)}),delete:e=>t(`/components/${encodeURIComponent(e)}`,{method:"DELETE"}),async exportBundle(e){const o=d(),s=await fetch(`${a}/components/${encodeURIComponent(e)}/export`,{headers:o?{Authorization:`Bearer ${o}`}:{}});if(!s.ok){const m=await s.json().catch(()=>({}));throw new Error(m.error||`Export failed (${s.status})`)}const r=await s.blob(),n=URL.createObjectURL(r),i=document.createElement("a");i.href=n,i.download=`${e}.dmcomponent.json`,document.body.appendChild(i),i.click(),i.remove(),URL.revokeObjectURL(n)},async importBundle(e,{overwrite:o=!1}={}){const s=await fetch(`${a}/components/import`,{method:"POST",headers:{"Content-Type":"application/json",...d()?{Authorization:`Bearer ${d()}`}:{}},body:JSON.stringify({...e,overwrite:o})});if(s.status===409){const r=await s.json().catch(()=>({})),n=new Error(r.error||"Component already exists");throw n.code="CONFLICT",n.name=r.name,n}if(!s.ok){const r=await s.json().catch(()=>({}));throw new Error(r.error||`Import failed (${s.status})`)}return s.json()}},get:e=>t(e,{method:"GET"}),post:(e,o)=>t(e,{method:"POST",body:JSON.stringify(o)}),put:(e,o)=>t(e,{method:"PUT",body:JSON.stringify(o)}),delete:e=>t(e,{method:"DELETE"}),settingsExt:{testEmail:e=>t("/settings/test-email",{method:"POST",body:JSON.stringify({to:e})})},system:{notifications:{list:()=>t("/system/notifications",{method:"GET"}),unreadCount:()=>t("/system/notifications/unread-count",{method:"GET"}),markRead:e=>t(`/system/notifications/${e}/read`,{method:"POST"}),dismiss:e=>t(`/system/notifications/${e}/dismiss`,{method:"POST"}),remove:e=>t(`/system/notifications/${e}`,{method:"DELETE"})}},dashboard:{summary:()=>t("/dashboard/summary",{method:"GET"}),summaryLite:()=>t("/dashboard/summary?lite=1",{method:"GET"})}};export function isAuthenticated(){return!!d()}export function getUser(){return S.get("auth_user")}export function setAuthData({token:e,refreshToken:o,user:s}){e&&l(e),o&&S.set("auth_refresh_token",o),s&&S.set("auth_user",s)}export function logout(){const e=c();e&&fetch(`${a}/auth/logout`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({refreshToken:e})}).catch(()=>{}),h(),R.navigate("/login")}export{t as apiRequest,u as refreshAccessToken,d as getToken,h as clearAuth};
|
|
@@ -1,6 +1,11 @@
|
|
|
1
|
-
<div class="view-header">
|
|
2
|
-
<
|
|
3
|
-
|
|
1
|
+
<div class="view-header d-flex justify-content-between align-items-center">
|
|
2
|
+
<div>
|
|
3
|
+
<h1><span data-icon="package"></span> Plugins</h1>
|
|
4
|
+
<p class="text-muted" style="margin:0;font-size:.875rem">Plugins extend the CMS with new features. Changes take effect after a server restart.</p>
|
|
5
|
+
</div>
|
|
6
|
+
<button id="new-plugin-btn" class="btn btn-primary">
|
|
7
|
+
<span data-icon="plus"></span> New Plugin
|
|
8
|
+
</button>
|
|
4
9
|
</div>
|
|
5
10
|
|
|
6
11
|
<div id="plugin-tabs" class="tabs">
|
|
@@ -1,38 +1,38 @@
|
|
|
1
|
-
import{api as
|
|
1
|
+
import{api as g,getUser as h}from"../api.js";import{pluginMarketplaceView as v}from"./plugin-marketplace.js";export const pluginsView={templateUrl:"/admin/js/templates/plugins.html",async onMount(s){s.find("#new-plugin-btn").on("click",()=>x(s));let u=!1;s.find('[data-tab="marketplace"]').on("click",async function(){if(u)return;const a=s.find("#marketplace-mount");try{const n=await H.get(v.templateUrl),p=new DOMParser().parseFromString(n,"text/html");for(;p.body.firstChild;)a.get(0).appendChild(p.body.firstChild);await v.onMount(a),u=!0}catch{E.toast("Failed to load marketplace.",{type:"error"})}}),E.tabs(s.find("#plugin-tabs").get(0));const c=E.loader(s.get(0),{type:"dots"});let e=[];try{e=await g.plugins.list()}catch(a){E.toast(`Failed to load plugins: ${a.message}`,{type:"error"})}if(c.destroy(),!e.length){s.find("#plugins-empty").show();return}f(s,e),Domma.icons.scan()}};function x(s){const u=E.slideover({title:"New Plugin",size:"sm",position:"right"}),c=document.createElement("div");c.style.cssText="padding:.25rem 0 .5rem;";const e=h(),a=document.createElement("div");F.create({displayName:{type:"string",label:"Display Name",placeholder:"e.g. Event Manager",required:!0},slug:{type:"string",label:"Slug",placeholder:"event-manager",required:!0,help:"Folder name under plugins/ \u2014 lowercase letters, digits, hyphens."},description:{type:"string",label:"Description",placeholder:"What does this plugin do?"},author:{type:"string",label:"Author"},icon:{type:"string",label:"Icon",placeholder:"box",help:"A Domma icon name (as used in data-icon)."}},{author:e?.name||e?.username||"",icon:"box"},{showSubmitButton:!1}).renderTo(a),c.appendChild(a);const n=document.createElement("p");n.className="text-muted",n.style.cssText="font-size:.8rem;margin:.5rem 0 0;",n.textContent="The plugin is scaffolded from the built-in template with server routes, an admin view and a CLAUDE.md, and enabled automatically. Restart the server to activate it.",c.appendChild(n);const l=document.createElement("div");l.style.cssText="display:flex;justify-content:flex-end;gap:.5rem;margin-top:.75rem;";const p=document.createElement("button");p.className="btn btn-ghost",p.textContent="Cancel";const i=document.createElement("button");i.className="btn btn-primary",i.textContent="Create Plugin",l.appendChild(p),l.appendChild(i),c.appendChild(l),u.element.appendChild(c),u.open();const m=a.querySelector('input[name="displayName"]'),t=a.querySelector('input[name="slug"]');setTimeout(()=>m?.focus(),50);let o=!1;t?.addEventListener("input",()=>{o=!0}),m?.addEventListener("input",()=>{o||!t||(t.value=m.value.toLowerCase().trim().replace(/[^a-z0-9]+/g,"-").replace(/^-+|-+$/g,"").slice(0,40))});async function b(){const d={};for(const y of["displayName","slug","description","author","icon"])d[y]=a.querySelector(`input[name="${y}"]`)?.value.trim()||"";if(!d.displayName||!d.slug){E.toast("Display name and slug are required.",{type:"error"});return}i.disabled=!0,i.textContent="Creating\u2026";try{await g.plugins.scaffold(d),u.close(),E.toast(`Plugin "${d.displayName}" created. Restart the server to activate it.`,{type:"success"});const y=await g.plugins.list();y.length&&(s.find("#plugins-empty").hide(),f(s,y))}catch(y){E.toast(y.message||"Failed to create plugin.",{type:"error"}),i.disabled=!1,i.textContent="Create Plugin"}}p.addEventListener("click",()=>u.close()),i.addEventListener("click",b),t?.addEventListener("keydown",d=>{d.key==="Enter"&&b()})}function f(s,u){const c=s.find("#plugins-grid").empty();u.forEach(e=>{const a=e.enabled?"badge-success":"badge-secondary",n=e.enabled?"Enabled":"Disabled",l=e.enabled?"Disable":"Enable",p=e.enabled?"btn-ghost":"btn-primary",i=e.bundled?"var(--dm-info,#2563eb)":"var(--dm-secondary,#6b7280)",m=e.bundled?"Bundled \u2014 click to unbundle":"Not bundled \u2014 click to bundle",t=`<button class="badge badge-outline btn-bundle-plugin" data-name="${e.name}" data-bundled="${e.bundled?"1":"0"}" title="${m}" style="font-size:0.65rem;padding:1px 6px;cursor:pointer;background:none;border:1px solid;color:${i};border-color:${i};">${e.bundled?"Bundled":"Unbundled"}</button>`,o=e.author?`by ${r(e.author)}`:"",b=e.date?r(e.date):"",d=`
|
|
2
2
|
<div class="card plugin-card" data-plugin="${e.name}">
|
|
3
3
|
<div class="card-body">
|
|
4
4
|
<div class="plugin-header">
|
|
5
5
|
<div class="plugin-icon"><span data-icon="${e.icon||"package"}"></span></div>
|
|
6
6
|
<div class="plugin-meta">
|
|
7
|
-
<div class="plugin-name">${
|
|
8
|
-
<div class="plugin-version">v${
|
|
7
|
+
<div class="plugin-name">${r(e.displayName)}</div>
|
|
8
|
+
<div class="plugin-version">v${r(e.version)}${o?` · ${o}`:""}</div>
|
|
9
9
|
</div>
|
|
10
10
|
<div style="display:flex;gap:.35rem;align-items:center;">
|
|
11
11
|
${t}
|
|
12
|
-
<span class="badge ${a}">${
|
|
12
|
+
<span class="badge ${a}">${n}</span>
|
|
13
13
|
</div>
|
|
14
14
|
</div>
|
|
15
|
-
<p class="plugin-desc">${
|
|
16
|
-
${
|
|
15
|
+
<p class="plugin-desc">${r(e.description)}</p>
|
|
16
|
+
${b?`<p class="plugin-date text-muted" style="font-size:.8rem;margin:0">Released ${b}</p>`:""}
|
|
17
17
|
</div>
|
|
18
18
|
<div class="plugin-footer">
|
|
19
19
|
<span class="text-muted" style="font-size:.8rem">Restart server after changes</span>
|
|
20
20
|
<div class="plugin-footer-actions">
|
|
21
21
|
${e.settingsSchema?`<button class="btn btn-sm btn-ghost btn-plugin-settings" data-name="${e.name}"><span data-icon="settings"></span> Settings</button>`:""}
|
|
22
|
-
<button class="btn btn-sm ${
|
|
22
|
+
<button class="btn btn-sm ${p} btn-toggle-plugin"
|
|
23
23
|
data-name="${e.name}"
|
|
24
24
|
data-enabled="${e.enabled?"1":"0"}">
|
|
25
|
-
<span data-icon="${e.enabled?"pause":"play"}"></span> ${
|
|
25
|
+
<span data-icon="${e.enabled?"pause":"play"}"></span> ${l}
|
|
26
26
|
</button>
|
|
27
27
|
</div>
|
|
28
28
|
</div>
|
|
29
|
-
</div>`;
|
|
30
|
-
<label class="form-label">${
|
|
31
|
-
<select class="form-control" name="${
|
|
29
|
+
</div>`;c.append(d)}),Domma.icons.scan(),Domma.effects.reveal(".plugin-card",{animation:"fade",stagger:60,duration:400}),Domma.effects.ripple(".btn-toggle-plugin"),c.on("click",".btn-plugin-settings",async function(){const e=$(this).data("name"),a=u.find(t=>t.name===e);if(!a?.settingsSchema)return;const l=`<form id="plugin-settings-form">${a.settingsSchema.map(t=>{const o=a.settings?.[t.key]??t.default??"";if(t.type==="select"){const b=t.options.map(d=>`<option value="${r(d.value)}"${String(o)===String(d.value)?" selected":""}>${r(d.label)}</option>`).join("");return`<div class="form-group">
|
|
30
|
+
<label class="form-label">${r(t.label)}</label>
|
|
31
|
+
<select class="form-control" name="${r(t.key)}">${b}</select>
|
|
32
32
|
</div>`}return t.type==="number"?`<div class="form-group">
|
|
33
|
-
<label class="form-label">${
|
|
34
|
-
<input class="form-control" type="number" name="${
|
|
33
|
+
<label class="form-label">${r(t.label)}</label>
|
|
34
|
+
<input class="form-control" type="number" name="${r(t.key)}" value="${r(String(o))}"${t.min!=null?` min="${t.min}"`:""}${t.max!=null?` max="${t.max}"`:""}>
|
|
35
35
|
</div>`:`<div class="form-group">
|
|
36
|
-
<label class="form-label">${
|
|
37
|
-
<input class="form-control" type="text" name="${
|
|
38
|
-
</div>`}).join("")}</form>`;if(!await E.confirm(
|
|
36
|
+
<label class="form-label">${r(t.label)}</label>
|
|
37
|
+
<input class="form-control" type="text" name="${r(t.key)}" value="${r(String(o))}">
|
|
38
|
+
</div>`}).join("")}</form>`;if(!await E.confirm(l,{title:`${r(a.displayName)} Settings`,confirmText:"Save",cancelText:"Cancel",html:!0}))return;const i=document.getElementById("plugin-settings-form");if(!i)return;const m={};a.settingsSchema.forEach(t=>{const o=i.elements[t.key];o&&(m[t.key]=t.type==="number"?Number(o.value):o.value)});try{await g.plugins.update(e,{settings:m}),a.settings={...a.settings,...m},E.toast("Settings saved.",{type:"success"})}catch(t){E.toast(`Failed to save: ${t.message}`,{type:"error"})}}),c.on("click",".btn-bundle-plugin",async function(){const e=$(this),a=e.data("name"),n=e.data("bundled")!==1&&e.data("bundled")!=="1";e.prop("disabled",!0).text(n?"Bundling\u2026":"Unbundling\u2026");try{await g.plugins.update(a,{bundled:n}),E.toast(`Plugin ${n?"marked as bundled":"unbundled"}.`,{type:"success"});const l=await g.plugins.list();f(s,l)}catch(l){E.toast(`Failed: ${l.message}`,{type:"error"}),e.prop("disabled",!1)}}),c.on("click",".btn-toggle-plugin",async function(){const e=$(this),a=e.data("name"),n=e.data("enabled")!==1&&e.data("enabled")!=="1";e.prop("disabled",!0).text(n?"Enabling\u2026":"Disabling\u2026");try{await g.plugins.update(a,{enabled:n}),E.toast(`Plugin ${n?"enabled":"disabled"}. Restart the server to apply changes.`,{type:"success"});const l=await g.plugins.list();f(s,l)}catch(l){E.toast(`Failed: ${l.message}`,{type:"error"}),e.prop("disabled",!1)}})}function r(s){return String(s).replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""")}
|
package/package.json
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Plugins API
|
|
3
|
-
* GET
|
|
4
|
-
*
|
|
5
|
-
*
|
|
3
|
+
* GET /api/plugins - list all discovered plugins + state (admin only)
|
|
4
|
+
* POST /api/plugins/scaffold - create a new plugin from plugins/_template (admin only)
|
|
5
|
+
* PUT /api/plugins/:name - enable/disable, update settings (admin only)
|
|
6
|
+
* GET /api/plugins/admin-config - sidebar/routes/views for enabled plugins (authenticated)
|
|
6
7
|
*/
|
|
7
8
|
import {authenticate, requirePermission} from '../../middleware/auth.js';
|
|
8
9
|
import {
|
|
@@ -12,9 +13,11 @@ import {
|
|
|
12
13
|
runLifecycleHook,
|
|
13
14
|
savePluginState
|
|
14
15
|
} from '../../services/plugins.js';
|
|
16
|
+
import {scaffoldPlugin} from '../../services/pluginScaffold.js';
|
|
15
17
|
|
|
16
18
|
export async function pluginsRoutes(fastify) {
|
|
17
19
|
const canRead = { preHandler: [authenticate, requirePermission('plugins', 'read')] };
|
|
20
|
+
const canCreate = { preHandler: [authenticate, requirePermission('plugins', 'create')] };
|
|
18
21
|
const canUpdate = { preHandler: [authenticate, requirePermission('plugins', 'update')] };
|
|
19
22
|
|
|
20
23
|
// List all plugins with their current state
|
|
@@ -36,6 +39,20 @@ export async function pluginsRoutes(fastify) {
|
|
|
36
39
|
}));
|
|
37
40
|
});
|
|
38
41
|
|
|
42
|
+
// Scaffold a new plugin from plugins/_template. The plugin is created
|
|
43
|
+
// pre-enabled so it goes live on the next server restart (its routes and
|
|
44
|
+
// admin views only register at boot — same restart rule as enable/disable).
|
|
45
|
+
fastify.post('/plugins/scaffold', canCreate, async (request, reply) => {
|
|
46
|
+
const {slug, displayName, description, author, icon} = request.body || {};
|
|
47
|
+
try {
|
|
48
|
+
const result = await scaffoldPlugin({slug, displayName, description, author, icon});
|
|
49
|
+
savePluginState(result.name, { enabled: true });
|
|
50
|
+
return { success: true, name: result.name, files: result.files, restartRequired: true };
|
|
51
|
+
} catch (err) {
|
|
52
|
+
return reply.status(err.statusCode || 500).send({ error: err.message });
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
|
|
39
56
|
// Enable/disable or update settings for a plugin
|
|
40
57
|
fastify.put('/plugins/:name', canUpdate, async (request, reply) => {
|
|
41
58
|
const { name } = request.params;
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin Scaffold Service
|
|
3
|
+
* Instantiates plugins/_template into a new plugin directory, replacing the
|
|
4
|
+
* PLUGIN_* token placeholders in every text file.
|
|
5
|
+
*
|
|
6
|
+
* Tokens replaced: PLUGIN_SLUG, PLUGIN_DISPLAY_NAME, PLUGIN_DESCRIPTION,
|
|
7
|
+
* PLUGIN_AUTHOR, PLUGIN_DATE.
|
|
8
|
+
*
|
|
9
|
+
* Thrown errors carry a `statusCode` (400 invalid input, 409 already exists,
|
|
10
|
+
* 500 template missing) so the route layer can map them directly.
|
|
11
|
+
*/
|
|
12
|
+
import fs from 'fs/promises';
|
|
13
|
+
import path from 'path';
|
|
14
|
+
|
|
15
|
+
const PLUGINS_DIR = path.resolve('plugins');
|
|
16
|
+
const TEMPLATE_DIR = path.join(PLUGINS_DIR, '_template');
|
|
17
|
+
|
|
18
|
+
const SLUG_PATTERN = /^[a-z][a-z0-9-]{1,39}$/;
|
|
19
|
+
const ICON_PATTERN = /^[a-z][a-z0-9-]{0,39}$/;
|
|
20
|
+
|
|
21
|
+
// Free-text values land inside JSON strings, JS block comments, and HTML
|
|
22
|
+
// text nodes in the template, so any character that could escape one of
|
|
23
|
+
// those contexts is refused rather than escaped per-file.
|
|
24
|
+
const UNSAFE_TEXT = /[<>"\\]|\*\//;
|
|
25
|
+
|
|
26
|
+
// Extensions that get token replacement; anything else is copied verbatim.
|
|
27
|
+
const TEXT_EXTENSIONS = new Set(['.js', '.json', '.md', '.html', '.css', '.txt']);
|
|
28
|
+
|
|
29
|
+
function fail(statusCode, message) {
|
|
30
|
+
const err = new Error(message);
|
|
31
|
+
err.statusCode = statusCode;
|
|
32
|
+
return err;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Validate scaffold input. Returns the normalised values or throws a 400.
|
|
37
|
+
*
|
|
38
|
+
* @param {object} input
|
|
39
|
+
* @returns {{slug: string, displayName: string, description: string, author: string, icon: string}}
|
|
40
|
+
*/
|
|
41
|
+
export function validateScaffoldInput(input = {}) {
|
|
42
|
+
const slug = String(input.slug || '').trim();
|
|
43
|
+
const displayName = String(input.displayName || '').trim();
|
|
44
|
+
const description = String(input.description || '').trim();
|
|
45
|
+
const author = String(input.author || '').trim();
|
|
46
|
+
const icon = String(input.icon || 'box').trim();
|
|
47
|
+
|
|
48
|
+
if (!SLUG_PATTERN.test(slug)) {
|
|
49
|
+
throw fail(400, 'Slug must be 2-40 characters: lowercase letters, digits and hyphens, starting with a letter.');
|
|
50
|
+
}
|
|
51
|
+
if (!displayName) throw fail(400, 'Display name is required.');
|
|
52
|
+
if (displayName.length > 60) throw fail(400, 'Display name must be 60 characters or fewer.');
|
|
53
|
+
if (description.length > 300) throw fail(400, 'Description must be 300 characters or fewer.');
|
|
54
|
+
if (author.length > 80) throw fail(400, 'Author must be 80 characters or fewer.');
|
|
55
|
+
if (!ICON_PATTERN.test(icon)) throw fail(400, 'Icon must be a lowercase icon name (letters, digits, hyphens).');
|
|
56
|
+
|
|
57
|
+
for (const [label, value] of [['Display name', displayName], ['Description', description], ['Author', author]]) {
|
|
58
|
+
if (UNSAFE_TEXT.test(value)) {
|
|
59
|
+
throw fail(400, `${label} may not contain <, >, ", backslash or */.`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return { slug, displayName, description, author, icon };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Copy plugins/_template to plugins/<slug>, replacing PLUGIN_* tokens.
|
|
68
|
+
*
|
|
69
|
+
* @param {object} input - { slug, displayName, description?, author?, icon? }
|
|
70
|
+
* @returns {Promise<{name: string, files: string[]}>} Created plugin name and relative file list
|
|
71
|
+
*/
|
|
72
|
+
export async function scaffoldPlugin(input) {
|
|
73
|
+
const { slug, displayName, description, author, icon } = validateScaffoldInput(input);
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
await fs.access(TEMPLATE_DIR);
|
|
77
|
+
} catch {
|
|
78
|
+
throw fail(500, 'Plugin template (plugins/_template) is missing.');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const targetDir = path.join(PLUGINS_DIR, slug);
|
|
82
|
+
try {
|
|
83
|
+
await fs.access(targetDir);
|
|
84
|
+
throw fail(409, `A plugin named "${slug}" already exists.`);
|
|
85
|
+
} catch (err) {
|
|
86
|
+
if (err.statusCode) throw err;
|
|
87
|
+
// ENOENT — target is free
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const replacements = [
|
|
91
|
+
['PLUGIN_SLUG', slug],
|
|
92
|
+
['PLUGIN_DISPLAY_NAME', displayName],
|
|
93
|
+
['PLUGIN_DESCRIPTION', description || `${displayName} plugin.`],
|
|
94
|
+
['PLUGIN_AUTHOR', author],
|
|
95
|
+
['PLUGIN_DATE', new Date().toISOString().slice(0, 10)],
|
|
96
|
+
];
|
|
97
|
+
|
|
98
|
+
const files = [];
|
|
99
|
+
|
|
100
|
+
async function copyDir(srcDir, destDir) {
|
|
101
|
+
await fs.mkdir(destDir, { recursive: true });
|
|
102
|
+
const entries = await fs.readdir(srcDir, { withFileTypes: true });
|
|
103
|
+
for (const entry of entries) {
|
|
104
|
+
const src = path.join(srcDir, entry.name);
|
|
105
|
+
const dest = path.join(destDir, entry.name);
|
|
106
|
+
if (entry.isDirectory()) {
|
|
107
|
+
await copyDir(src, dest);
|
|
108
|
+
} else if (entry.isFile()) {
|
|
109
|
+
if (TEXT_EXTENSIONS.has(path.extname(entry.name).toLowerCase())) {
|
|
110
|
+
let content = await fs.readFile(src, 'utf8');
|
|
111
|
+
for (const [token, value] of replacements) {
|
|
112
|
+
content = content.split(token).join(value);
|
|
113
|
+
}
|
|
114
|
+
await fs.writeFile(dest, content, 'utf8');
|
|
115
|
+
} else {
|
|
116
|
+
await fs.copyFile(src, dest);
|
|
117
|
+
}
|
|
118
|
+
files.push(path.relative(targetDir, dest));
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
await copyDir(TEMPLATE_DIR, targetDir);
|
|
124
|
+
|
|
125
|
+
// The template hard-codes "box" as the icon; set the requested one via
|
|
126
|
+
// a proper JSON parse rather than string replacement.
|
|
127
|
+
if (icon !== 'box') {
|
|
128
|
+
const manifestPath = path.join(targetDir, 'plugin.json');
|
|
129
|
+
const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf8'));
|
|
130
|
+
manifest.icon = icon;
|
|
131
|
+
for (const item of manifest.admin?.sidebar || []) item.icon = icon;
|
|
132
|
+
await fs.writeFile(manifestPath, JSON.stringify(manifest, null, 2) + '\n', 'utf8');
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return { name: slug, files };
|
|
136
|
+
}
|