@acms-subir/core 0.1.0 → 0.3.0

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.
Files changed (237) hide show
  1. package/CHANGELOG.md +106 -0
  2. package/cli/common.mjs +35 -0
  3. package/cli/legacy.mjs +317 -0
  4. package/cli/main.mjs +66 -0
  5. package/cli/palette.mjs +4 -0
  6. package/cli/preview.mjs +50 -0
  7. package/cli/remote.mjs +55 -0
  8. package/cli/themes.mjs +127 -0
  9. package/cli.mjs +2 -315
  10. package/deploy/Caddyfile +8 -0
  11. package/deploy/Dockerfile +34 -0
  12. package/deploy/compose.https.yml +21 -0
  13. package/deploy/compose.yml +46 -0
  14. package/docs/theme-authoring.md +103 -0
  15. package/email-runner.mjs +32 -0
  16. package/integration.mjs +193 -2
  17. package/ops-check.mjs +27 -0
  18. package/package.json +35 -12
  19. package/src/actions/index.ts +744 -38
  20. package/src/components/AiCleaner.tsx +291 -0
  21. package/src/components/AiSettings.tsx +575 -0
  22. package/src/components/CommandPalette.tsx +200 -0
  23. package/src/components/ContentList.astro +246 -0
  24. package/src/components/DesignAccess.tsx +182 -0
  25. package/src/components/DesignManager.tsx +455 -47
  26. package/src/components/EmailSettings.tsx +521 -0
  27. package/src/components/EventPage.astro +178 -0
  28. package/src/components/LegalPage.astro +27 -0
  29. package/src/components/LinksManager.tsx +216 -97
  30. package/src/components/NavManager.tsx +83 -38
  31. package/src/components/ThemeScript.astro +50 -0
  32. package/src/components/ThemeSections.astro +57 -0
  33. package/src/components/ThemeSectionsEditor.tsx +513 -0
  34. package/src/components/booking/BookingFlow.astro +150 -0
  35. package/src/components/booking/HoursEditor.astro +63 -0
  36. package/src/components/builder/PageBuilder.tsx +10 -2
  37. package/src/components/editor/EventPanel.tsx +401 -0
  38. package/src/components/editor/FormatDock.tsx +81 -0
  39. package/src/components/editor/LivePane.tsx +122 -0
  40. package/src/components/editor/MobileToolbar.tsx +93 -0
  41. package/src/components/editor/PostEditor.tsx +826 -271
  42. package/src/components/editor/PreviewOverlay.tsx +242 -43
  43. package/src/components/editor/extensions/calendar.tsx +51 -7
  44. package/src/components/editor/extensions/cards.tsx +17 -1
  45. package/src/components/editor/extensions/codeBlock.tsx +1 -1
  46. package/src/components/editor/extensions/events.tsx +153 -0
  47. package/src/components/editor/extensions/infoCards.tsx +16 -1
  48. package/src/components/editor/extensions/mediaCards.tsx +36 -2
  49. package/src/components/editor/extensions/slash.tsx +77 -9
  50. package/src/components/editor/extensions/toc.tsx +89 -0
  51. package/src/components/editor/extensions/toggle.tsx +70 -0
  52. package/src/components/site/Pager.astro +24 -0
  53. package/src/components/site/PostCard.astro +64 -0
  54. package/src/components/site/TagLabel.astro +11 -0
  55. package/src/layouts/Admin.astro +289 -49
  56. package/src/layouts/Site.astro +331 -57
  57. package/src/lib/agent-api.ts +13 -0
  58. package/src/lib/agent-discovery.ts +40 -0
  59. package/src/lib/agent.ts +114 -0
  60. package/src/lib/ai-config.ts +58 -0
  61. package/src/lib/ai-schema.ts +22 -0
  62. package/src/lib/ai.ts +90 -0
  63. package/src/lib/analytics.ts +121 -0
  64. package/src/lib/api-keys.ts +65 -0
  65. package/src/lib/api.ts +52 -0
  66. package/src/lib/auth.ts +25 -5
  67. package/src/lib/backup/content.ts +36 -0
  68. package/src/lib/backup/unzip.ts +12 -1
  69. package/src/lib/backup/zip.ts +1 -1
  70. package/src/lib/booking/agent.ts +108 -0
  71. package/src/lib/booking/availability.ts +221 -0
  72. package/src/lib/booking/emails.ts +115 -0
  73. package/src/lib/booking/forms.ts +77 -0
  74. package/src/lib/booking/service.ts +277 -0
  75. package/src/lib/booking/view.ts +54 -0
  76. package/src/lib/calendar/agent-schema.ts +48 -0
  77. package/src/lib/calendar/agent.ts +136 -0
  78. package/src/lib/calendar/api.ts +81 -0
  79. package/src/lib/calendar/cards.ts +102 -0
  80. package/src/lib/calendar/config.ts +52 -0
  81. package/src/lib/calendar/data.ts +130 -0
  82. package/src/lib/calendar/events.ts +261 -0
  83. package/src/lib/calendar/feeds.ts +43 -0
  84. package/src/lib/calendar/hooks.ts +38 -0
  85. package/src/lib/calendar/http.ts +30 -0
  86. package/src/lib/calendar/ics-write.ts +120 -0
  87. package/src/lib/calendar/ics.ts +15 -5
  88. package/src/lib/calendar/import.ts +203 -0
  89. package/src/lib/calendar/render.ts +120 -37
  90. package/src/lib/calendar/rrule.ts +149 -0
  91. package/src/lib/calendar/seo.ts +48 -0
  92. package/src/lib/design-service.ts +58 -0
  93. package/src/lib/designDiff.ts +145 -0
  94. package/src/lib/designPreview.ts +112 -0
  95. package/src/lib/documents/cleanup.ts +31 -0
  96. package/src/lib/documents/extract.ts +154 -0
  97. package/src/lib/documents/vendor.d.ts +4 -0
  98. package/src/lib/drafts.ts +131 -0
  99. package/src/lib/dynamicBlocks.ts +79 -7
  100. package/src/lib/email/accounts.ts +55 -0
  101. package/src/lib/email/config.ts +86 -0
  102. package/src/lib/email/migration.ts +24 -0
  103. package/src/lib/email/providers.ts +89 -0
  104. package/src/lib/email/service.ts +78 -0
  105. package/src/lib/email/store.ts +167 -0
  106. package/src/lib/email/templates.ts +18 -0
  107. package/src/lib/email/types.ts +32 -0
  108. package/src/lib/email/webhooks.ts +87 -0
  109. package/src/lib/email.ts +15 -0
  110. package/src/lib/env.ts +1 -1
  111. package/src/lib/legal/index.ts +25 -0
  112. package/src/lib/mobile/bookings.ts +73 -0
  113. package/src/lib/mobile/calendar.ts +85 -0
  114. package/src/lib/mobile/content.ts +85 -0
  115. package/src/lib/mobile/http.ts +63 -0
  116. package/src/lib/mobile/route.ts +20 -0
  117. package/src/lib/ops.ts +8 -0
  118. package/src/lib/preview.ts +21 -1
  119. package/src/lib/repo/migrations.ts +254 -0
  120. package/src/lib/repo/sql.ts +956 -46
  121. package/src/lib/repo/types.ts +140 -3
  122. package/src/lib/scheduler.ts +25 -0
  123. package/src/lib/schema.ts +362 -0
  124. package/src/lib/search-i18n.ts +3 -0
  125. package/src/lib/socialIcons.ts +32 -0
  126. package/src/lib/tagColor.ts +11 -0
  127. package/src/lib/theme.ts +59 -40
  128. package/src/lib/themeTokens.ts +174 -0
  129. package/src/lib/umami.ts +108 -0
  130. package/src/lib/webhooks.ts +139 -0
  131. package/src/middleware.ts +36 -3
  132. package/src/pages/404.astro +23 -0
  133. package/src/pages/[...slug].astro +10 -5
  134. package/src/pages/account/[action].astro +53 -0
  135. package/src/pages/admin/ai.astro +12 -0
  136. package/src/pages/admin/c/[collection]/[id].astro +14 -4
  137. package/src/pages/admin/c/[collection]/index.astro +127 -91
  138. package/src/pages/admin/calendar/bookings.astro +400 -0
  139. package/src/pages/admin/calendar/calendars.astro +265 -0
  140. package/src/pages/admin/calendar/events/[id].astro +56 -0
  141. package/src/pages/admin/calendar/events/index.astro +408 -0
  142. package/src/pages/admin/calendar/index.ts +4 -0
  143. package/src/pages/admin/calendar/integrations.astro +221 -0
  144. package/src/pages/admin/calendar/resources/[id].astro +180 -0
  145. package/src/pages/admin/calendar/resources/index.astro +106 -0
  146. package/src/pages/admin/calendar/resources/link.astro +196 -0
  147. package/src/pages/admin/design.astro +16 -4
  148. package/src/pages/admin/email.astro +13 -0
  149. package/src/pages/admin/index.astro +237 -87
  150. package/src/pages/admin/links.astro +3 -3
  151. package/src/pages/admin/login.astro +14 -10
  152. package/src/pages/admin/navigation.astro +8 -2
  153. package/src/pages/admin/pages/[id].astro +14 -3
  154. package/src/pages/admin/pages/index.astro +41 -47
  155. package/src/pages/admin/settings.astro +475 -276
  156. package/src/pages/admin/tags.astro +4 -4
  157. package/src/pages/admin/users.astro +38 -28
  158. package/src/pages/api/agent/docs.ts +33 -0
  159. package/src/pages/api/agent/index.ts +21 -0
  160. package/src/pages/api/ai/clean.ts +13 -0
  161. package/src/pages/api/ai/draft.ts +14 -0
  162. package/src/pages/api/ai/settings.ts +53 -0
  163. package/src/pages/api/ai/test.ts +12 -0
  164. package/src/pages/api/backup.ts +8 -21
  165. package/src/pages/api/calendar/availability.ts +35 -0
  166. package/src/pages/api/calendar/bookings/[id].ts +49 -0
  167. package/src/pages/api/calendar/bookings/index.ts +73 -0
  168. package/src/pages/api/calendar/calendars.ts +35 -0
  169. package/src/pages/api/calendar/docs.ts +64 -0
  170. package/src/pages/api/calendar/events/[id].ts +63 -0
  171. package/src/pages/api/calendar/events/index.ts +112 -0
  172. package/src/pages/api/calendar/resources.ts +34 -0
  173. package/src/pages/api/design/capabilities.ts +10 -0
  174. package/src/pages/api/design/index.ts +58 -0
  175. package/src/pages/api/design/keys.ts +24 -0
  176. package/src/pages/api/design/media.ts +32 -0
  177. package/src/pages/api/email/run.ts +11 -0
  178. package/src/pages/api/email/settings.ts +61 -0
  179. package/src/pages/api/email/webhooks/[provider].ts +4 -0
  180. package/src/pages/api/mcp.ts +59 -0
  181. package/src/pages/api/media/upload.ts +2 -2
  182. package/src/pages/api/ops/status.ts +39 -0
  183. package/src/pages/api/restore.ts +27 -37
  184. package/src/pages/api/search.ts +18 -4
  185. package/src/pages/api/v1/availability.ts +7 -0
  186. package/src/pages/api/v1/bookings.ts +17 -0
  187. package/src/pages/api/v1/calendars.ts +7 -0
  188. package/src/pages/api/v1/content/[id].ts +7 -0
  189. package/src/pages/api/v1/content/index.ts +7 -0
  190. package/src/pages/api/v1/events.ts +7 -0
  191. package/src/pages/api/v1/resources.ts +7 -0
  192. package/src/pages/author/[slug].astro +43 -35
  193. package/src/pages/blog/[slug].astro +140 -66
  194. package/src/pages/blog/index.astro +95 -30
  195. package/src/pages/blog/tag/[slug].astro +45 -40
  196. package/src/pages/book/[resource]/[link].astro +70 -0
  197. package/src/pages/book/[resource]/index.astro +63 -0
  198. package/src/pages/book/[resource].ics.ts +38 -0
  199. package/src/pages/book/index.astro +47 -0
  200. package/src/pages/book/manage/[token].astro +108 -0
  201. package/src/pages/calendar/[slug].ics.ts +27 -0
  202. package/src/pages/calendar/feed.ics.ts +15 -0
  203. package/src/pages/calendar/index.astro +191 -0
  204. package/src/pages/embed/book/[resource]/[link].astro +33 -0
  205. package/src/pages/embed/calendar/[slug].astro +43 -0
  206. package/src/pages/events/[slug].astro +74 -0
  207. package/src/pages/events/[slug].ics.ts +25 -0
  208. package/src/pages/index.astro +9 -3
  209. package/src/pages/legal/privacy.astro +4 -0
  210. package/src/pages/legal/terms.astro +4 -0
  211. package/src/pages/links.astro +33 -7
  212. package/src/pages/llms.txt.ts +4 -0
  213. package/src/pages/preview/[type]/[id].astro +34 -8
  214. package/src/pages/search.astro +40 -20
  215. package/src/pages/sitemap.xml.ts +16 -1
  216. package/src/styles/admin.css +928 -105
  217. package/src/styles/cards.css +101 -60
  218. package/src/styles/editor.css +532 -102
  219. package/src/styles/email-settings.css +117 -0
  220. package/src/styles/sections.css +12 -0
  221. package/src/styles/site.css +938 -278
  222. package/src/styles/theme-editor.css +135 -0
  223. package/src/theme-sdk/contract.d.mts +619 -0
  224. package/src/theme-sdk/contract.mjs +80 -0
  225. package/src/theme-sdk/index.ts +15 -0
  226. package/src/theme-sdk/tokens.d.mts +148 -0
  227. package/src/theme-sdk/tokens.mjs +52 -0
  228. package/src/themes/editorial/templates/home.astro +4 -4
  229. package/src/themes/editorial/templates/post-minimal.astro +2 -2
  230. package/src/themes/editorial/theme.css +22 -37
  231. package/src/themes/editorial/theme.json +23 -3
  232. package/src/themes/field/theme.json +26 -9
  233. package/src/themes/ink/theme.json +30 -12
  234. package/src/themes/night/theme.css +4 -7
  235. package/src/themes/night/theme.json +27 -10
  236. package/src/virtual.d.ts +14 -0
  237. package/src/worker.ts +5 -1
package/cli/remote.mjs ADDED
@@ -0,0 +1,55 @@
1
+ import { homedir } from 'node:os';
2
+ import { resolve,join,basename,extname } from 'node:path';
3
+ import { readFile } from 'node:fs/promises';
4
+ import { spawnSync } from 'node:child_process';
5
+ import { fail, readJson, atomicJson, inputJson } from './common.mjs';
6
+ const configRoot=()=>process.env.ACMS_CONFIG_DIR??join(homedir(),'.config/acms');
7
+ function siteURL(raw){let u;try{u=new URL(raw);}catch{fail('USAGE','Supply a valid site URL.');}if(u.username||u.password||u.search||u.hash||!['http:','https:'].includes(u.protocol))fail('USAGE','URL must have no credentials, query, or fragment.');if(u.protocol==='http:'&&!['localhost','127.0.0.1','[::1]'].includes(u.hostname))fail('USAGE','Use HTTPS except for a local development server.');return u.origin;}
8
+ export async function api(url,token,path,body,method='GET',extra={}){
9
+ let response;try{response=await fetch(new URL(path,url),{method,headers:{Authorization:`Bearer ${token}`,...(body&&!(body instanceof Uint8Array)?{'Content-Type':'application/json'}:{}),...extra},body:body instanceof Uint8Array?body:body?JSON.stringify(body):undefined,redirect:'error',signal:AbortSignal.timeout(20000)});}catch{fail('NETWORK','Could not reach the site. No mutation was retried.');}
10
+ const cap=5*1024*1024;let text='';const reader=response.body?.getReader();if(reader)try{const dec=new TextDecoder();for(;;){const {done,value}=await reader.read();if(done)break;text+=dec.decode(value,{stream:true});if(text.length>cap){await reader.cancel();fail('NETWORK','Response exceeds 5 MB.');}}}finally{reader.releaseLock();}
11
+ let data;try{data=JSON.parse(text);}catch{fail('NETWORK','Site did not return JSON. Check its URL and deployment.');}
12
+ if(!response.ok)fail(response.status===409?'CONFLICT':[401,403].includes(response.status)?'AUTH':'NETWORK',data.error??`Request failed (${response.status}).`,{status:response.status});
13
+ return data;
14
+ }
15
+ export async function remoteCommand(group,command,args,flags){
16
+ const path=join(configRoot(),'sites.json'),profiles=await readJson(path).catch(()=>({version:1,sites:{}}));const name=flags.site??'default';
17
+ if(!/^[a-zA-Z0-9_-]+$/.test(name))fail('USAGE','Invalid site profile name.');
18
+ if(group==='site'&&command==='connect'){
19
+ const url=siteURL(args[0]??flags.url),tokenEnv=flags['token-env']??'ACMS_TOKEN';if(!/^[A-Z_][A-Z0-9_]*$/.test(tokenEnv))fail('USAGE','Invalid token environment variable.');
20
+ const token=process.env[tokenEnv];if(!token)fail('AUTH',`Set ${tokenEnv} in your environment. No token is stored in the project.`);
21
+ if(flags['dry-run'])return {dryRun:true,url,profile:name,tokenEnv};
22
+ let capabilities;try{capabilities=await api(url,token,'/api/design/capabilities');}catch(e){if(e.code!=='AUTH'&&e.details?.status!==404)throw e;capabilities=await api(url,token,'/api/agent');}
23
+ profiles.sites[name]={url,tokenEnv};await atomicJson(path,profiles);return {profile:name,url,scopes:capabilities.scopes??[],capabilities:capabilities.capabilities??{design:true}};
24
+ }
25
+ const profile=profiles.sites[name];if(!profile)fail('USAGE',`Unknown profile ${name}. Run site connect first.`);const token=process.env[profile.tokenEnv];if(!token)fail('AUTH',`Set ${profile.tokenEnv}.`);
26
+ const url=siteURL(profile.url);
27
+ if(group==='design'){
28
+ if(command==='pull'||command==='history'){const state=await api(url,token,'/api/design');if(command==='history')return {versions:state.versions,audit:state.audit};if(flags.output){if(flags['dry-run'])return {dryRun:true,output:resolve(flags.output),revision:state.revision};await atomicJson(resolve(flags.output),{revision:state.revision,patch:state.draft??state.live});return {output:resolve(flags.output),revision:state.revision};}return state;}
29
+ let body;
30
+ if(command==='push'){if(!flags.draft)fail('USAGE','Use design push --draft; publishing is separate.');const input=await inputJson(flags.file);const source=input.patch??input;const unknown=Object.keys(source).filter(k=>!['activeTheme','designOverrides','sectionPages','siteLogo','siteFavicon','siteCover','navItems','savedAt','shareEpoch','revision'].includes(k));if(unknown.length)fail('USAGE','Unknown draft fields.',unknown);const patch=Object.fromEntries(Object.entries(source).filter(([k])=>['activeTheme','designOverrides','sectionPages','siteLogo','siteFavicon','siteCover','navItems'].includes(k)));body={operation:'stage',revision:Number(flags.revision??input.revision),patch};}
31
+ else if(['publish','discard','share','revoke','rollback'].includes(command))body={operation:command==='rollback'?'restore':command,revision:Number(flags.revision),...(command==='rollback'?{id:args[0]}:{}),...(command==='share'?{days:Number(flags.days??7)}:{})};
32
+ else fail('USAGE','Unknown design command.');
33
+ if(!Number.isInteger(body.revision)||body.revision<0)fail('USAGE','Supply the revision from design pull.');
34
+ if(flags['dry-run'])return {dryRun:true,request:body};return api(url,token,'/api/design',body,'POST');
35
+ }
36
+ if(group==='content'){
37
+ if(command==='capabilities')return api(url,token,'/api/agent');
38
+ const tools={list:'list_content',get:'get_content',create:'create_draft',update:'update_content',publish:'publish_content'};
39
+ const tool=tools[command];if(!tool)fail('USAGE','Unknown content command.');const arguments_=flags.file?await inputJson(flags.file):command==='list'?{kind:'posts'}:await inputJson();
40
+ if(flags['dry-run'])return {dryRun:true,tool,arguments:arguments_};return api(url,token,'/api/agent',{tool,arguments:arguments_},'POST');
41
+ }
42
+ if(group==='media'){
43
+ if(command==='list')return api(url,token,'/api/design/media');
44
+ if(command!=='upload'||!args[0])fail('USAGE','Use media upload <image>.');const source=resolve(args[0]);
45
+ if(flags['dry-run'])return {dryRun:true,file:source,compression:'img-subir@latest',action:'Upload compressed image'};
46
+ const r=spawnSync('npx',['--yes','img-subir@latest',source,'--json'],{encoding:'utf8',timeout:120000,maxBuffer:1000000});if(r.status!==0)fail('OPTIMIZE_FAILED','Image compression failed. Upload was not attempted.');
47
+ let result;try{result=JSON.parse(r.stdout);}catch{fail('OPTIMIZE_FAILED','Compression returned an unexpected result.');}
48
+ const item=Array.isArray(result)?result[0]:result.results?.[0]??result.files?.[0]??result;
49
+ const output=item.output?.path??item.copiedTo??item.input?.path??source;
50
+ if(typeof output!=='string')fail('OPTIMIZE_FAILED','Compression did not return an output path.');
51
+ const bytes=await readFile(resolve(output)),mime=({'.png':'image/png','.jpg':'image/jpeg','.jpeg':'image/jpeg','.webp':'image/webp','.gif':'image/gif'})[extname(output).toLowerCase()];if(!mime)fail('USAGE','Unsupported image format.');
52
+ return api(url,token,'/api/design/media',bytes,'POST',{'Content-Type':mime,'X-Filename':basename(output)});
53
+ }
54
+ fail('USAGE','Unknown remote command.');
55
+ }
package/cli/themes.mjs ADDED
@@ -0,0 +1,127 @@
1
+ import { readdir, readFile, writeFile, mkdir, cp, lstat, rm, rename, mkdtemp } from 'node:fs/promises';
2
+ import { resolve, join, relative, dirname, extname } from 'node:path';
3
+ import { tmpdir } from 'node:os';
4
+ import { createHash } from 'node:crypto';
5
+ import { spawnSync } from 'node:child_process';
6
+ import { pathToFileURL } from 'node:url';
7
+ import { zodToJsonSchema } from 'zod-to-json-schema';
8
+ import semver from 'semver';
9
+ import * as tar from 'tar';
10
+ import { ThemeManifest, SectionDefinition, SectionPages, Slug, sectionDefinitions, BUILTIN_SECTIONS } from '../src/theme-sdk/contract.mjs';
11
+ import { CORE, project, fail, exists, readJson, atomicJson } from './common.mjs';
12
+ export async function schemaData(){return {apiVersion:1,manifest:zodToJsonSchema(ThemeManifest,{$refStrategy:'none'}),sections:zodToJsonSchema(SectionDefinition,{$refStrategy:'none'}),pages:zodToJsonSchema(SectionPages,{$refStrategy:'none'})};}
13
+ export async function listLocalThemes(p){
14
+ const out=new Map();for(const [origin,base] of [['builtin',join(p.core,'src/themes')],['project',join(p.cwd,'src/themes')]]){
15
+ for(const name of await readdir(base).catch(()=>[])){const file=join(base,name,'theme.json');if(await exists(file)){const raw=await readJson(file);out.set(name,{slug:name,name:raw.name??name,version:raw.version??'1.0.0',origin});}}
16
+ }return [...out.values()];
17
+ }
18
+ async function themeDir(p,slug){Slug.parse(slug);const local=join(p.cwd,'src/themes',slug);return await exists(join(local,'theme.json'))?local:join(p.core,'src/themes',slug);}
19
+ export async function filesIn(root){
20
+ const files=[];
21
+ async function walk(dir){for(const ent of await readdir(dir,{withFileTypes:true})){const file=join(dir,ent.name),rel=relative(root,file).replaceAll('\\','/');
22
+ if(ent.isSymbolicLink())fail('UNSAFE_THEME',`Symbolic links are not allowed: ${rel}`);
23
+ if(ent.name.startsWith('.')||['node_modules','dist'].includes(ent.name))continue;
24
+ if(ent.isDirectory())await walk(file);else files.push(rel);
25
+ }}await walk(root);return files.sort();
26
+ }
27
+ function allowedFile(file){return file==='theme.json'||file==='theme.css'||file==='README.md'||/^(templates|sections)\/[a-zA-Z0-9-]+\.astro$/.test(file)||/^assets\/[a-zA-Z0-9_./-]+\.(png|jpe?g|webp|gif|svg|avif|woff2?)$/i.test(file);}
28
+ async function validate(dir,p){
29
+ const manifest=ThemeManifest.parse(await readJson(join(dir,'theme.json')));
30
+ if(!semver.validRange(manifest.core)||!semver.satisfies(p.version,manifest.core,{includePrerelease:true}))fail('INCOMPATIBLE',`Theme requires core ${manifest.core}; installed ${p.version}.`);
31
+ const files=await filesIn(dir),issues=[];
32
+ if(!semver.valid(manifest.version))issues.push({file:'theme.json',message:'Invalid semantic version.'});
33
+ for(const [type,def] of Object.entries(manifest.sections)){
34
+ if(!BUILTIN_SECTIONS[type]&&!def.component)issues.push({file:'theme.json',message:`Custom section ${type} must declare a component.`});
35
+ for(const [key,f] of Object.entries(def.fields))if(f.type==='select'&&(!f.options?.length || (f.default!==undefined&&!f.options.includes(f.default))))issues.push({file:'theme.json',message:`Section ${type}.${key} needs options and a matching default.`});
36
+ }
37
+ for(const file of files){if(!allowedFile(file))issues.push({file,message:'File is outside the theme package allowlist.'});
38
+ const stat=await lstat(join(dir,file));if(stat.size>20*1024*1024)issues.push({file,message:'File exceeds 20 MB.'});
39
+ if(file.endsWith('.astro')||file.endsWith('.css')){const text=await readFile(join(dir,file),'utf8');if(/(?:from\s*|import\s*)['"]\.\.\/.*(?:lib|components)\//.test(text))issues.push({file,message:'Use public @acms-subir/core/theme exports instead of relative core internals.'});
40
+ for(const match of text.matchAll(/(?:from\s*|import\s*)['"](\.[^'"]+)['"]/g)){const path=resolve(dirname(join(dir,file)),match[1]);if(!path.startsWith(dir+'/'))issues.push({file,message:'Relative import escapes theme directory.'});else if(!await exists(path))issues.push({file,message:`Missing import: ${match[1]}`});}
41
+ for(const match of text.matchAll(/url\(\s*["']?((?:\.\/)?assets\/[^"')\s]+)/g)){if(match[1].startsWith('http'))continue;const path=resolve(dirname(join(dir,file)),match[1]);if(!await exists(path))issues.push({file,message:`Missing asset: ${match[1]}`});}
42
+ }
43
+ }
44
+ for(const file of [...manifest.assets,...manifest.templates.map(t=>'templates/'+t),...Object.values(manifest.sections).map(s=>s.component).filter(Boolean)])if(!files.includes(file))issues.push({file,message:'Declared file is missing.'});
45
+ if(issues.length)fail('THEME_CHECK_FAILED','Theme validation failed.',issues);
46
+ const reports=[];const {contrast}=await import('./palette.mjs');
47
+ for(const [name,palette] of [['light',manifest.tokens],['dark',manifest.tokens.dark]])if(palette.bg&&palette.ink){const ratio=contrast(palette.bg,palette.ink);reports.push({check:`${name} body text contrast`,ratio,needsReview:ratio===null||ratio<4.5});}
48
+ return {manifest,files,reports};
49
+ }
50
+ async function snapshot(dir){const entries={};for(const file of await filesIn(dir))entries[file]=createHash('sha256').update(await readFile(join(dir,file))).digest('hex');return entries;}
51
+ export async function themeCommand(command,args,flags){
52
+ const p=await project(),slug=args[0];
53
+ if(command==='install')return installTheme(p,slug,flags);
54
+ if(!slug)fail('USAGE','Supply a theme name.');Slug.parse(slug);
55
+ const dir=await themeDir(p,slug),local=join(p.cwd,'src/themes',slug);
56
+ if(command==='create') {
57
+ if(await exists(local))fail('EXISTS',`Theme ${slug} already exists.`);
58
+ const source=flags.from??'business';const builtin=source==='business';
59
+ const sourceDir=builtin?null:await themeDir(p,source);
60
+ if(sourceDir&&!await exists(join(sourceDir,'theme.json')))fail('NOT_FOUND',`Starter ${source} was not found.`);
61
+ if(flags['dry-run'])return {dryRun:true,path:local,source};
62
+ await mkdir(local,{recursive:true});
63
+ if(sourceDir){await cp(sourceDir,local,{recursive:true});for(const file of await filesIn(local))if(file.endsWith('.astro')){
64
+ const path=join(local,file);let text=await readFile(path,'utf8');
65
+ text=text.replace(/from ['"](?:\.\.\/)+lib\/schema['"]/g,"from '@acms-subir/core/theme'")
66
+ .replace(/from ['"](?:\.\.\/)+lib\/(content|dates)['"]/g,"from '@acms-subir/core/theme'");await writeFile(path,text);
67
+ }}
68
+ else {await mkdir(join(local,'templates'));await writeFile(join(local,'theme.css'),'.acms-sections { --section-accent: var(--accent); }\n');
69
+ await writeFile(join(local,'templates/home.astro'),`---\nimport type { HomeProps } from '@acms-subir/core/theme';\ninterface Props extends HomeProps {}\nconst { page } = Astro.props;\n---\n<article class="page-body">{page?.bodyHtml ? <div set:html={page.bodyHtml} /> : <h1>Welcome</h1>}</article>\n`);}
70
+ const raw=sourceDir?await readJson(join(local,'theme.json')):{name:slug,tokens:{accent:'#3355ff'},templates:['home.astro'],sections:{statement:{name:'Statement',version:1,component:'sections/Statement.astro',variants:['default'],fields:{heading:{label:'Heading',type:'text',default:''},text:{label:'Supporting text',type:'textarea',default:''}}}}};
71
+ if(builtin){await mkdir(join(local,'sections'));await writeFile(join(local,'sections/Statement.astro'),`---\nimport type { SectionProps } from '@acms-subir/core/theme';\ninterface Props extends SectionProps {}\nconst { data } = Astro.props;\n---\n<div class="studio-statement"><h2>{data.heading}</h2><p>{data.text}</p></div>\n<style>.studio-statement { max-width: 48rem; margin-inline: auto; text-align: center; } p { white-space: pre-line; }</style>\n`);}
72
+
73
+ await atomicJson(join(local,'theme.json'),{...raw,name:slug,slug,version:'1.0.0',apiVersion:1,core:`>=${p.version} <1.0.0`},0o644);
74
+ await atomicJson(join(p.cwd,'.acms/baselines',slug+'.json'),await snapshot(local));
75
+ return {created:local,next:[`acms theme dev ${slug}`,`acms theme check ${slug} --build`],sections:Object.keys(sectionDefinitions(ThemeManifest.parse(raw)))};
76
+ }
77
+ if(!await exists(join(dir,'theme.json')))fail('NOT_FOUND',`Theme ${slug} was not found.`);
78
+ if(command==='inspect'){const {manifest,files}=await validate(dir,p);return {slug,origin:dir===local?'project':'builtin',manifest,files,sections:sectionDefinitions(manifest),overrides:'Use design pull --site <name> to inspect runtime overrides.'};}
79
+ if(command==='check') {
80
+ const result=await validate(dir,p);let built=false;
81
+ if(flags.build&&!flags['dry-run']){const {buildFixture}=await import('./preview.mjs');await buildFixture(p,dir,slug);built=true;}
82
+ return {valid:true,slug,files:result.files,contrast:result.reports,built,manualReview:['Keyboard navigation','Narrow layouts','Custom CSS selector compatibility'],hint:built?null:'Use --build for Astro compiler checks.'};
83
+ }
84
+ if(command==='dev'){if(flags['dry-run'])return {dryRun:true,action:'Create isolated preview and start Astro'};const {devFixture}=await import('./preview.mjs');return devFixture(p,dir,slug,flags);}
85
+ if(command==='diff'){
86
+ const before=flags.baseline?await snapshot(resolve(flags.baseline)):await readJson(join(p.cwd,'.acms/baselines',slug+'.json')).catch(()=>({}));const after=await snapshot(dir);
87
+ return {baseline:flags.baseline??'creation snapshot',added:Object.keys(after).filter(f=>!before[f]),changed:Object.keys(after).filter(f=>before[f]&&before[f]!==after[f]),removed:Object.keys(before).filter(f=>!after[f])};
88
+ }
89
+ if(command==='pack'){
90
+ if(flags.optimize&&!flags['dry-run']){const r=spawnSync('npx',['--yes','img-subir@latest',dir,'--rewrite-refs','--json'],{encoding:'utf8'});if(r.status!==0)fail('OPTIMIZE_FAILED','Image compression failed.');
91
+ const report=JSON.parse(r.stdout);
92
+ for(const file of (await filesIn(dir)).filter(f=>/\.(json|astro|css|md)$/.test(f))){let text=await readFile(join(dir,file),'utf8');for(const rename of report.renamed??[]){const from=relative(dir,resolve(rename.from)).replaceAll('\\','/'),to=relative(dir,resolve(rename.to)).replaceAll('\\','/');text=text.split(from).join(to);}await writeFile(join(dir,file),text);}
93
+ }
94
+ const {manifest,files}=await validate(dir,p),output=resolve(flags.output??`${slug}-${manifest.version}.tgz`);
95
+ if(flags['dry-run'])return {dryRun:true,output,files};
96
+ if(await exists(output)&&!flags.force)fail('EXISTS','Archive exists. Choose --output or use --force.');
97
+ await tar.c({gzip:{mtime:0},portable:true,mtime:new Date(0),cwd:dir,file:output},files);
98
+ return {output,sha256:createHash('sha256').update(await readFile(output)).digest('hex'),files,version:manifest.version};
99
+ }
100
+ fail('USAGE','Unknown theme command.');
101
+ }
102
+ async function installTheme(p,input,flags){
103
+ if(!input)fail('USAGE','Supply a local theme archive.');const archive=resolve(input);
104
+ if((await lstat(archive)).size>30*1024*1024)fail('TOO_LARGE','Archive exceeds 30 MB.');
105
+ const seen=new Set();let total=0,bad=[];
106
+ await tar.t({file:archive,onReadEntry:e=>{
107
+ total+=e.size;
108
+ if(e.type!=='File'||e.path.includes('..')||e.path.startsWith('/')||e.path.includes('\\')||!allowedFile(e.path)||seen.has(e.path))bad.push(e.path);
109
+ seen.add(e.path);
110
+ if(total>60*1024*1024||seen.size>500)bad.push('Archive limits exceeded');
111
+ }});
112
+ if(bad.length)fail('UNSAFE_ARCHIVE','Archive contains unsafe or unsupported entries.',bad);
113
+ const temp=await mkdtemp(join(tmpdir(),'acms-theme-'));
114
+ try{
115
+ await tar.x({file:archive,cwd:temp,strict:true,noChmod:true});const {manifest}=await validate(temp,p);
116
+ const slug=manifest.slug;if(!slug)fail('INVALID_MANIFEST','Packaged themes must declare a slug.');
117
+ const dest=join(p.cwd,'src/themes',slug),digest=createHash('sha256').update(await readFile(archive)).digest('hex');
118
+ if(await exists(dest)&&!flags.force)fail('EXISTS','Theme exists. Use --force to retain a backup and replace it.');
119
+ if(flags['dry-run'])return {dryRun:true,slug,version:manifest.version,sha256:digest,rebuildRequired:true};
120
+ const stage=join(p.cwd,'src/themes',`.${slug}-${process.pid}`);await mkdir(dirname(stage),{recursive:true});await cp(temp,stage,{recursive:true});
121
+ let backup;
122
+ if(await exists(dest)){backup=join(p.cwd,'.acms/theme-backups',`${slug}-${Date.now()}`);await mkdir(dirname(backup),{recursive:true});await rename(dest,backup);}
123
+ try{await rename(stage,dest);}catch(e){if(backup)await rename(backup,dest);throw e;}
124
+ const lockPath=join(p.cwd,'.acms/themes.lock.json'),lock=await readJson(lockPath).catch(()=>({version:1,themes:{}}));lock.themes[slug]={version:manifest.version,core:manifest.core,sha256:digest,backup};await atomicJson(lockPath,lock);
125
+ return {installed:slug,version:manifest.version,backup,rebuildRequired:true,next:'Build, deploy, then stage theme selection. Install never changes the live site.'};
126
+ }finally{await rm(temp,{recursive:true,force:true});}
127
+ }
package/cli.mjs CHANGED
@@ -1,316 +1,3 @@
1
1
  #!/usr/bin/env node
2
- // `acms` — tiny maintenance CLI installed with @acms-subir/core.
3
- // acms update check the registry, install the latest core, print what's new
4
- // acms update --check just report whether an update exists (exit 1 if behind)
5
- // acms doctor sanity-check the project setup
6
- // acms add cloudflare retrofit Workers deployment (wrangler.jsonc, deps, D1/R2)
7
- // acms add docker retrofit self-hosting (Dockerfile, compose.yml)
8
- import { spawnSync } from 'node:child_process';
9
- import { readFileSync, writeFileSync, existsSync } from 'node:fs';
10
- import { createRequire } from 'node:module';
11
- import { dirname, join } from 'node:path';
12
-
13
- const PKG = '@acms-subir/core';
14
- const cmd = process.argv[2];
15
-
16
- const read = (p) => { try { return readFileSync(p, 'utf8'); } catch { return ''; } };
17
- const installedPkgJson = () => {
18
- try {
19
- return createRequire(join(process.cwd(), 'package.json')).resolve(`${PKG}/package.json`);
20
- } catch { return null; }
21
- };
22
- const installedVersion = () => {
23
- const p = installedPkgJson();
24
- try { return p ? JSON.parse(readFileSync(p, 'utf8')).version : null; } catch { return null; }
25
- };
26
- const latestVersion = async () => {
27
- const res = await fetch(`https://registry.npmjs.org/${encodeURIComponent(PKG)}/latest`, {
28
- signal: AbortSignal.timeout(10_000),
29
- });
30
- if (!res.ok) throw new Error(`registry responded ${res.status}`);
31
- return (await res.json()).version;
32
- };
33
- const semverLt = (a, b) => {
34
- const pa = a.split('.').map(Number), pb = b.split('.').map(Number);
35
- for (let i = 0; i < 3; i++) { if ((pa[i] ?? 0) < (pb[i] ?? 0)) return true; if ((pa[i] ?? 0) > (pb[i] ?? 0)) return false; }
36
- return false;
37
- };
38
-
39
- // Release notes come from the CHANGELOG that changesets writes into the
40
- // package itself, read after the install from the freshly unpacked copy. No
41
- // git-host API is involved on purpose: the repository lives on a private
42
- // OneDev instance, so anything API-based would need a token on every site
43
- // that runs this command.
44
- const changelogFor = (pkgJsonPath) => (pkgJsonPath ? read(join(dirname(pkgJsonPath), 'CHANGELOG.md')) : '');
45
- // Entries newer than `current` up to `latest`, oldest first. Changesets writes
46
- // one "## X.Y.Z" heading per release.
47
- const releaseNotes = (md, current, latest) => {
48
- // split on every "## " heading, not just the version ones, so a hand-written
49
- // "## Unreleased" section cannot get swallowed into the entry above it
50
- const heads = [...md.matchAll(/^## +(.*)$/gm)];
51
- return heads
52
- .map((h, i) => ({
53
- version: h[1].trim().match(/^\d+\.\d+\.\d+$/)?.[0],
54
- body: md.slice(h.index + h[0].length, heads[i + 1]?.index ?? md.length).trim(),
55
- }))
56
- .filter((r) => r.version && semverLt(current, r.version) && !semverLt(latest, r.version))
57
- .sort((a, b) => (semverLt(a.version, b.version) ? -1 : 1));
58
- };
59
- const deployTargets = () => ({
60
- cloudflare: ['wrangler.jsonc', 'wrangler.json', 'wrangler.toml'].some((f) => existsSync(f)),
61
- docker: ['compose.yml', 'compose.yaml', 'docker-compose.yml', 'Dockerfile'].some((f) => existsSync(f)),
62
- });
63
-
64
- if (cmd === 'update') {
65
- const checkOnly = process.argv.includes('--check');
66
- const current = installedVersion();
67
- if (!current) { console.error(`✗ ${PKG} is not installed here — run this from your site's folder.`); process.exit(1); }
68
- let latest;
69
- try { latest = await latestVersion(); } catch (e) { console.error(`✗ Could not reach the npm registry (${e.message}).`); process.exit(1); }
70
- if (!semverLt(current, latest)) {
71
- console.log(`✓ Already up to date (${current}).`);
72
- process.exit(0);
73
- }
74
- if (checkOnly) {
75
- console.log(`Update available: ${current} → ${latest}. Run: npx acms update`);
76
- process.exit(1);
77
- }
78
- console.log(`Updating ${PKG} ${current} → ${latest}…`);
79
- const res = spawnSync('npm', ['install', `${PKG}@latest`, '--no-audit', '--no-fund'], { stdio: 'inherit' });
80
- if (res.status !== 0) process.exit(res.status ?? 1);
81
- console.log(`\n✓ Updated to ${latest}.`);
82
- const notes = releaseNotes(changelogFor(installedPkgJson()), current, latest);
83
- if (notes.length) {
84
- console.log(`\nWhat's new:`);
85
- for (const r of notes) {
86
- console.log(`\n ${PKG} ${r.version}`);
87
- if (r.body) console.log(r.body.split('\n').map((l) => ` ${l}`).join('\n'));
88
- }
89
- } else {
90
- console.log(`\nWhat's new: https://www.npmjs.com/package/${PKG}/v/${latest}`);
91
- }
92
- const t = deployTargets();
93
- const steps = [
94
- ...(t.cloudflare ? [' npm run deploy # Cloudflare Workers'] : []),
95
- ...(t.docker ? [' git push, then on your server: docker compose up -d --build'] : []),
96
- ];
97
- console.log([
98
- '',
99
- 'Deploy when ready:',
100
- ...(steps.length ? steps : [' rebuild and redeploy your host']),
101
- ' (the database migrates itself on the first request — no manual steps)',
102
- ].join('\n'));
103
- // the registry fetch leaves a keep-alive socket in undici's pool, which can
104
- // hold the event loop open for seconds after there is nothing left to do
105
- process.exit(0);
106
- } else if (cmd === 'add') {
107
- // Retrofit a deployment target onto an existing project. Both targets share
108
- // one codebase — astro.config picks the adapter via DEPLOY_TARGET — so
109
- // "adding" one is only config files + deps. Content moves between backends
110
- // with a backup ZIP (admin → Backup, then admin → Restore on the new host).
111
- const what = process.argv[3];
112
- if (!existsSync('package.json')) {
113
- console.error("✗ No package.json here — run this from your site's folder.");
114
- process.exit(1);
115
- }
116
- const pkg = JSON.parse(readFileSync('package.json', 'utf8'));
117
- const slug = (pkg.name || 'my-site').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 50) || 'my-site';
118
- const writeNew = (file, body) => {
119
- if (existsSync(file)) { console.log(`• ${file} already exists — left untouched`); return false; }
120
- writeFileSync(file, body);
121
- console.log(`✓ wrote ${file}`);
122
- return true;
123
- };
124
- const setScript = (name, value) => {
125
- pkg.scripts ??= {};
126
- if (pkg.scripts[name]) return;
127
- pkg.scripts[name] = value;
128
- console.log(`✓ added "${name}" script`);
129
- };
130
-
131
- if (what === 'cloudflare') {
132
- writeNew('wrangler.jsonc', `{
133
- "$schema": "node_modules/wrangler/config-schema.json",
134
- "name": "${slug}",
135
- "main": "./dist/_worker.js/index.js",
136
- "compatibility_date": "2025-07-01",
137
- "compatibility_flags": ["nodejs_compat"],
138
- "assets": {
139
- "binding": "ASSETS",
140
- "directory": "./dist"
141
- },
142
- "observability": {
143
- "enabled": true
144
- },
145
- // Scheduled publishing: flips due scheduled posts to published every 5
146
- // minutes, independent of site traffic.
147
- "triggers": {
148
- "crons": ["*/5 * * * *"]
149
- },
150
- // If the id below is still zeros, create the real database with
151
- // npx wrangler d1 create ${slug}-db
152
- // and paste the returned database_id here. Local dev (wrangler dev /
153
- // astro dev) uses a local simulator and works with the placeholder.
154
- "d1_databases": [
155
- {
156
- "binding": "DB",
157
- "database_name": "${slug}-db",
158
- "database_id": "00000000-0000-0000-0000-000000000000"
159
- }
160
- ],
161
- // If missing, create the real bucket with:
162
- // npx wrangler r2 bucket create ${slug}-media
163
- // Local dev uses a local simulator and works without creating anything.
164
- "r2_buckets": [
165
- {
166
- "binding": "MEDIA",
167
- "bucket_name": "${slug}-media"
168
- }
169
- ]
170
- }
171
- `);
172
- setScript('preview', 'npm run build && wrangler dev');
173
- setScript('deploy', 'npm run build && wrangler deploy');
174
- writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n');
175
- if (!pkg.devDependencies?.wrangler && !pkg.dependencies?.wrangler) {
176
- console.log('Installing wrangler…');
177
- const res = spawnSync('npm', ['install', '-D', 'wrangler', '--no-audit', '--no-fund'], { stdio: 'inherit' });
178
- if (res.status !== 0) console.error('✗ npm install -D wrangler failed — install it manually.');
179
- }
180
- if (process.argv.includes('--provision')) {
181
- console.log(`Creating D1 database ${slug}-db…`);
182
- const d1 = spawnSync('npx', ['--no-install', 'wrangler', 'd1', 'create', `${slug}-db`], { encoding: 'utf8' });
183
- const id = (d1.stdout || '').match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/)?.[0];
184
- if (d1.status === 0 && id) {
185
- writeFileSync('wrangler.jsonc', readFileSync('wrangler.jsonc', 'utf8').replace('00000000-0000-0000-0000-000000000000', id));
186
- console.log(`✓ D1 created (${id})`);
187
- } else {
188
- console.error(`✗ D1 creation failed — create it later with: npx wrangler d1 create ${slug}-db`);
189
- }
190
- const r2 = spawnSync('npx', ['--no-install', 'wrangler', 'r2', 'bucket', 'create', `${slug}-media`], { encoding: 'utf8' });
191
- console.log(r2.status === 0 ? '✓ R2 bucket created' : `✗ R2 creation failed — create it later with: npx wrangler r2 bucket create ${slug}-media`);
192
- }
193
- console.log(`
194
- ✓ Cloudflare target ready. Next:
195
- npx wrangler d1 create ${slug}-db # unless --provision did it; paste the id into wrangler.jsonc
196
- npx wrangler secret put ADMIN_PASSWORD
197
- npx wrangler secret put SESSION_SECRET
198
- npm run deploy
199
- Moving existing content? Admin → Backup on the old host, Admin → Restore on the new one.`);
200
- } else if (what === 'docker') {
201
- writeNew('Dockerfile', `# Self-hosted Node target. Build/run via compose.yml, or directly:
202
- # docker build -t ${slug} . && docker run -p 4321:4321 -v acms_data:/app/data ${slug}
203
- FROM node:22-slim AS build
204
- WORKDIR /app
205
- COPY package.json package-lock.json* *.tgz* ./
206
- RUN npm install --no-audit --no-fund
207
- # self-heal npm/cli#4828: a lockfile updated on macOS can omit the Linux
208
- # platform binaries (rollup, libsql) while still "installing" cleanly
209
- RUN node -e "require('rollup')" >/dev/null 2>&1 || \\
210
- (rm -rf node_modules package-lock.json && npm install --no-audit --no-fund)
211
- COPY . .
212
- RUN DEPLOY_TARGET=node npm run build:node
213
-
214
- FROM node:22-slim
215
- # WORKDIR must stay stable: SQLITE_URL and the media store resolve ./data
216
- # against the process CWD
217
- WORKDIR /app
218
- ENV NODE_ENV=production HOST=0.0.0.0 PORT=4321
219
- # the standalone bundle in dist/ contains all app code; the only runtime
220
- # import left in node_modules is @libsql/client (dynamically imported so it
221
- # stays out of the Workers bundle) — installing just that keeps the image
222
- # ~300MB instead of ~2GB
223
- RUN npm install --no-save --no-audit --no-fund "@libsql/client@^0.15.0"
224
- COPY --from=build /app/dist ./dist
225
- # ./data holds the SQLite database and uploaded media — mount a volume here.
226
- # Only data/ needs to be writable (a chown -R of /app would copy-on-write
227
- # duplicate every node_modules file into a fresh layer)
228
- RUN mkdir -p data && chown node:node data
229
- USER node
230
- EXPOSE 4321
231
- CMD ["node", "./dist/server/entry.mjs"]
232
- `);
233
- writeNew('compose.yml', `# Self-hosting on your own server. A reverse proxy you already run (Coolify,
234
- # Dokploy, Traefik, nginx, Caddy) terminates TLS and routes to port 4321.
235
- # Secrets come from .env in this directory (compose reads it automatically).
236
- services:
237
- site:
238
- build: .
239
- restart: unless-stopped
240
- expose:
241
- - "4321"
242
- # No reverse proxy? Publish the port directly instead:
243
- # ports:
244
- # - "4321:4321"
245
- volumes:
246
- # one volume covers everything: SQLite database + uploaded media
247
- - data:/app/data
248
- environment:
249
- - HOST=0.0.0.0
250
- - PORT=4321
251
- - ADMIN_PASSWORD=\${ADMIN_PASSWORD:?set ADMIN_PASSWORD in .env}
252
- - SESSION_SECRET=\${SESSION_SECRET:?set SESSION_SECRET in .env}
253
- - SQLITE_URL=file:./data/acms.db
254
- healthcheck:
255
- # also serves as the scheduled-publish heartbeat: each probe is a real
256
- # request, which triggers the middleware publish sweep
257
- test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:4321/').then((r)=>process.exit(r.ok?0:1),()=>process.exit(1))"]
258
- interval: 300s
259
- start_period: 15s
260
-
261
- volumes:
262
- data:
263
- `);
264
- writeNew('.dockerignore', `node_modules
265
- dist
266
- .astro
267
- data
268
- .env
269
- .dev.vars
270
- .wrangler
271
- .git
272
- `);
273
- setScript('build:node', 'DEPLOY_TARGET=node astro build');
274
- writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n');
275
- console.log(`
276
- ✓ Docker target ready. Next (on your server):
277
- set real ADMIN_PASSWORD / SESSION_SECRET in .env
278
- docker compose up -d --build # or point Coolify/Dokploy/Railway at the repo
279
- data lives in the volume at /app/data (SQLite + media)
280
- Moving existing content? Admin → Backup on the old host, Admin → Restore on the new one.`);
281
- } else {
282
- console.error('Usage: acms add <cloudflare|docker> [--provision]');
283
- process.exit(1);
284
- }
285
- } else if (cmd === 'doctor') {
286
- let bad = 0;
287
- const check = (ok, label, hint) => {
288
- console.log(`${ok ? '✓' : '✗'} ${label}${ok || !hint ? '' : `\n ↳ ${hint}`}`);
289
- if (!ok) bad++;
290
- };
291
- const major = Number(process.versions.node.split('.')[0]);
292
- check(major >= 20, `Node ${process.versions.node} (need ≥20)`, 'Upgrade Node.');
293
- check(installedVersion() !== null, `${PKG} resolvable`, 'npm install');
294
- const cfg = read('astro.config.mjs') + read('astro.config.ts');
295
- check(/@acms-subir\/core/.test(cfg), 'astro.config uses the acms integration', "import acms from '@acms-subir/core'");
296
- check(/export\s+\{\s*server\s*\}/.test(read('src/actions/index.ts')), 'src/actions/index.ts re-exports core actions', "export { server } from '@acms-subir/core/actions';");
297
- const wrangler = read('wrangler.jsonc') + read('wrangler.json') + read('wrangler.toml');
298
- if (wrangler) {
299
- check(!wrangler.includes('00000000-0000-0000-0000-000000000000'), 'wrangler D1 database_id is real', 'npx wrangler d1 create <name>-db, then paste the id.');
300
- } else {
301
- console.log('• no wrangler config (Node target?) — skipping Cloudflare checks');
302
- }
303
- check(existsSync('.dev.vars') || existsSync('.env'), 'local secrets file (.env / .dev.vars)', 'Set ADMIN_PASSWORD and SESSION_SECRET.');
304
- console.log(bad === 0 ? '\nAll good.' : `\n${bad} issue${bad > 1 ? 's' : ''} found.`);
305
- process.exit(bad === 0 ? 0 : 1);
306
- } else {
307
- console.log(`acms — maintenance CLI for @acms-subir/core
308
-
309
- Usage:
310
- acms update Update the CMS core to the latest release
311
- acms update --check Report whether an update exists (exit 1 if behind)
312
- acms doctor Check this project's setup
313
- acms add cloudflare Add Workers deployment to this project (--provision creates D1 + R2)
314
- acms add docker Add Docker self-hosting to this project`);
315
- process.exit(cmd ? 1 : 0);
316
- }
2
+ import { main } from './cli/main.mjs';
3
+ await main();
@@ -0,0 +1,8 @@
1
+ {$SITE_DOMAIN} {
2
+ encode zstd gzip
3
+ reverse_proxy site:4321 {
4
+ # The API uses this adapter-recognized header for client rate limits.
5
+ # Overwrite any untrusted value supplied by an internet client.
6
+ header_up X-Forwarded-For {remote_host}
7
+ }
8
+ }
@@ -0,0 +1,34 @@
1
+ # Self-hosted Node target. Build/run via compose.yml, or directly:
2
+ # docker build -t acms-site . && docker run -p 4321:4321 -v acms_data:/app/data acms-site
3
+ FROM node:22-slim AS build
4
+ WORKDIR /app
5
+ COPY package.json package-lock.json* *.tgz* ./
6
+ RUN npm install --no-audit --no-fund
7
+ # self-heal npm/cli#4828: a lockfile updated on macOS can omit the Linux
8
+ # platform binaries (rollup, libsql) while still "installing" cleanly. If
9
+ # rollup's native module is missing, re-resolve everything from scratch.
10
+ RUN node -e "require('rollup')" >/dev/null 2>&1 || \
11
+ (rm -rf node_modules package-lock.json && npm install --no-audit --no-fund)
12
+ COPY . .
13
+ RUN DEPLOY_TARGET=node npm run build:node
14
+
15
+ FROM node:22-slim
16
+ # WORKDIR must stay stable: SQLITE_URL and the media store resolve ./data
17
+ # against the process CWD
18
+ WORKDIR /app
19
+ ENV NODE_ENV=production HOST=0.0.0.0 PORT=4321
20
+ # the standalone bundle in dist/ contains all app code; the only runtime
21
+ # import left in node_modules is @libsql/client (dynamically imported so it
22
+ # stays out of the Workers bundle) — installing just that keeps the image
23
+ # ~300MB instead of ~2GB (a full prod install would pull astro, react, and
24
+ # even miniflare/workerd via @astrojs/cloudflare)
25
+ RUN npm install --no-save --no-audit --no-fund "@libsql/client@^0.15.0"
26
+ COPY --from=build /app/dist ./dist
27
+ COPY --from=build /app/node_modules/@acms-subir/core/email-runner.mjs ./email-runner.mjs
28
+ # ./data holds the SQLite database and uploaded media — mount a volume here.
29
+ # Only data/ needs to be writable (a chown -R of /app would copy-on-write
30
+ # duplicate every node_modules file into a fresh layer)
31
+ RUN mkdir -p data && chown node:node data
32
+ USER node
33
+ EXPOSE 4321
34
+ CMD ["node", "./dist/server/entry.mjs"]
@@ -0,0 +1,21 @@
1
+ # Optional: only use this if you do not already run a TLS reverse proxy.
2
+ services:
3
+ proxy:
4
+ image: caddy:2-alpine
5
+ restart: unless-stopped
6
+ ports:
7
+ - "80:80"
8
+ - "443:443"
9
+ - "443:443/udp"
10
+ environment:
11
+ - SITE_DOMAIN=${SITE_DOMAIN:?set your public domain in .env}
12
+ volumes:
13
+ - ./Caddyfile:/etc/caddy/Caddyfile:ro
14
+ - caddy_data:/data
15
+ - caddy_config:/config
16
+ depends_on:
17
+ site:
18
+ condition: service_healthy
19
+ volumes:
20
+ caddy_data:
21
+ caddy_config:
@@ -0,0 +1,46 @@
1
+ # Connect your existing proxy to this Compose network and route to site:4321.
2
+ # Optional standalone TLS: docker compose -f compose.yml -f compose.https.yml up -d --build
3
+ services:
4
+ site:
5
+ build: &site-build .
6
+ init: true
7
+ restart: unless-stopped
8
+ expose:
9
+ - "4321"
10
+ volumes:
11
+ - data:/app/data
12
+ environment:
13
+ - HOST=0.0.0.0
14
+ - PORT=4321
15
+ - ADMIN_PASSWORD=${ADMIN_PASSWORD:?set ADMIN_PASSWORD in .env}
16
+ - SESSION_SECRET=${SESSION_SECRET:?set SESSION_SECRET in .env}
17
+ - EMAIL_RUNNER_SECRET=${EMAIL_RUNNER_SECRET:?set EMAIL_RUNNER_SECRET in .env}
18
+ - EMAIL_ENCRYPTION_KEY=${EMAIL_ENCRYPTION_KEY:-}
19
+ - SQLITE_URL=file:./data/acms.db
20
+ healthcheck:
21
+ test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:4321/api/v1/content?limit=1').then(r=>process.exit(r.ok?0:1),()=>process.exit(1))"]
22
+ interval: 60s
23
+ timeout: 15s
24
+ start_period: 30s
25
+ scheduler:
26
+ build: *site-build
27
+ init: true
28
+ restart: unless-stopped
29
+ # Share only the app network namespace. No public port or database volume.
30
+ network_mode: service:site
31
+ depends_on:
32
+ site:
33
+ condition: service_healthy
34
+ restart: true
35
+ command: ["node", "/app/email-runner.mjs"]
36
+ environment:
37
+ - EMAIL_RUNNER_URL=http://127.0.0.1:4321
38
+ - EMAIL_RUNNER_SECRET=${EMAIL_RUNNER_SECRET:?set EMAIL_RUNNER_SECRET in .env}
39
+ - SCHEDULER_HEARTBEAT_FILE=/tmp/acms-scheduler-heartbeat
40
+ healthcheck:
41
+ test: ["CMD", "node", "-e", "const fs=require('fs');try{process.exit(Date.now()-Number(fs.readFileSync('/tmp/acms-scheduler-heartbeat','utf8'))<180000?0:1)}catch{process.exit(1)}"]
42
+ interval: 60s
43
+ timeout: 5s
44
+ start_period: 120s
45
+ volumes:
46
+ data: