@acms-subir/core 0.2.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.
- package/CHANGELOG.md +78 -0
- package/cli/common.mjs +35 -0
- package/cli/legacy.mjs +317 -0
- package/cli/main.mjs +66 -0
- package/cli/palette.mjs +4 -0
- package/cli/preview.mjs +50 -0
- package/cli/remote.mjs +55 -0
- package/cli/themes.mjs +127 -0
- package/cli.mjs +2 -369
- package/deploy/Caddyfile +8 -0
- package/deploy/Dockerfile +34 -0
- package/deploy/compose.https.yml +21 -0
- package/deploy/compose.yml +46 -0
- package/docs/theme-authoring.md +103 -0
- package/email-runner.mjs +32 -0
- package/integration.mjs +182 -2
- package/ops-check.mjs +27 -0
- package/package.json +27 -5
- package/src/actions/index.ts +575 -47
- package/src/components/AiCleaner.tsx +291 -0
- package/src/components/AiSettings.tsx +575 -0
- package/src/components/CommandPalette.tsx +200 -0
- package/src/components/ContentList.astro +246 -0
- package/src/components/DesignAccess.tsx +182 -0
- package/src/components/DesignManager.tsx +418 -46
- package/src/components/EmailSettings.tsx +521 -0
- package/src/components/EventPage.astro +178 -0
- package/src/components/LegalPage.astro +27 -0
- package/src/components/LinksManager.tsx +114 -57
- package/src/components/NavManager.tsx +42 -43
- package/src/components/ThemeScript.astro +50 -0
- package/src/components/ThemeSections.astro +57 -0
- package/src/components/ThemeSectionsEditor.tsx +513 -0
- package/src/components/booking/BookingFlow.astro +150 -0
- package/src/components/booking/HoursEditor.astro +63 -0
- package/src/components/builder/PageBuilder.tsx +10 -2
- package/src/components/editor/EventPanel.tsx +401 -0
- package/src/components/editor/FormatDock.tsx +81 -0
- package/src/components/editor/LivePane.tsx +122 -0
- package/src/components/editor/MobileToolbar.tsx +93 -0
- package/src/components/editor/PostEditor.tsx +605 -223
- package/src/components/editor/PreviewOverlay.tsx +242 -43
- package/src/components/editor/extensions/calendar.tsx +51 -7
- package/src/components/editor/extensions/events.tsx +153 -0
- package/src/components/editor/extensions/slash.tsx +7 -1
- package/src/components/site/Pager.astro +24 -0
- package/src/components/site/PostCard.astro +64 -0
- package/src/components/site/TagLabel.astro +11 -0
- package/src/layouts/Admin.astro +289 -49
- package/src/layouts/Site.astro +324 -59
- package/src/lib/agent-api.ts +13 -0
- package/src/lib/agent-discovery.ts +40 -0
- package/src/lib/agent.ts +114 -0
- package/src/lib/ai-config.ts +58 -0
- package/src/lib/ai-schema.ts +22 -0
- package/src/lib/ai.ts +90 -0
- package/src/lib/api-keys.ts +65 -0
- package/src/lib/api.ts +52 -0
- package/src/lib/auth.ts +25 -5
- package/src/lib/backup/content.ts +36 -0
- package/src/lib/backup/unzip.ts +12 -1
- package/src/lib/backup/zip.ts +1 -1
- package/src/lib/booking/agent.ts +108 -0
- package/src/lib/booking/availability.ts +221 -0
- package/src/lib/booking/emails.ts +115 -0
- package/src/lib/booking/forms.ts +77 -0
- package/src/lib/booking/service.ts +277 -0
- package/src/lib/booking/view.ts +54 -0
- package/src/lib/calendar/agent-schema.ts +48 -0
- package/src/lib/calendar/agent.ts +136 -0
- package/src/lib/calendar/api.ts +81 -0
- package/src/lib/calendar/cards.ts +102 -0
- package/src/lib/calendar/config.ts +52 -0
- package/src/lib/calendar/data.ts +130 -0
- package/src/lib/calendar/events.ts +261 -0
- package/src/lib/calendar/feeds.ts +43 -0
- package/src/lib/calendar/hooks.ts +38 -0
- package/src/lib/calendar/http.ts +30 -0
- package/src/lib/calendar/ics-write.ts +120 -0
- package/src/lib/calendar/ics.ts +15 -5
- package/src/lib/calendar/import.ts +203 -0
- package/src/lib/calendar/render.ts +120 -37
- package/src/lib/calendar/rrule.ts +149 -0
- package/src/lib/calendar/seo.ts +48 -0
- package/src/lib/design-service.ts +58 -0
- package/src/lib/designDiff.ts +145 -0
- package/src/lib/designPreview.ts +110 -12
- package/src/lib/documents/cleanup.ts +31 -0
- package/src/lib/documents/extract.ts +154 -0
- package/src/lib/documents/vendor.d.ts +4 -0
- package/src/lib/drafts.ts +60 -6
- package/src/lib/dynamicBlocks.ts +40 -8
- package/src/lib/email/accounts.ts +55 -0
- package/src/lib/email/config.ts +86 -0
- package/src/lib/email/migration.ts +24 -0
- package/src/lib/email/providers.ts +89 -0
- package/src/lib/email/service.ts +78 -0
- package/src/lib/email/store.ts +167 -0
- package/src/lib/email/templates.ts +18 -0
- package/src/lib/email/types.ts +32 -0
- package/src/lib/email/webhooks.ts +87 -0
- package/src/lib/email.ts +15 -0
- package/src/lib/env.ts +1 -1
- package/src/lib/legal/index.ts +25 -0
- package/src/lib/mobile/bookings.ts +73 -0
- package/src/lib/mobile/calendar.ts +85 -0
- package/src/lib/mobile/content.ts +85 -0
- package/src/lib/mobile/http.ts +63 -0
- package/src/lib/mobile/route.ts +20 -0
- package/src/lib/ops.ts +8 -0
- package/src/lib/preview.ts +21 -1
- package/src/lib/repo/migrations.ts +217 -0
- package/src/lib/repo/sql.ts +853 -60
- package/src/lib/repo/types.ts +122 -5
- package/src/lib/scheduler.ts +25 -0
- package/src/lib/schema.ts +309 -0
- package/src/lib/search-i18n.ts +3 -0
- package/src/lib/socialIcons.ts +32 -0
- package/src/lib/tagColor.ts +11 -0
- package/src/lib/theme.ts +59 -40
- package/src/lib/themeTokens.ts +174 -0
- package/src/lib/umami.ts +108 -0
- package/src/lib/webhooks.ts +139 -0
- package/src/middleware.ts +36 -3
- package/src/pages/404.astro +23 -0
- package/src/pages/[...slug].astro +8 -5
- package/src/pages/account/[action].astro +53 -0
- package/src/pages/admin/ai.astro +12 -0
- package/src/pages/admin/c/[collection]/[id].astro +4 -1
- package/src/pages/admin/c/[collection]/index.astro +127 -92
- package/src/pages/admin/calendar/bookings.astro +400 -0
- package/src/pages/admin/calendar/calendars.astro +265 -0
- package/src/pages/admin/calendar/events/[id].astro +56 -0
- package/src/pages/admin/calendar/events/index.astro +408 -0
- package/src/pages/admin/calendar/index.ts +4 -0
- package/src/pages/admin/calendar/integrations.astro +221 -0
- package/src/pages/admin/calendar/resources/[id].astro +180 -0
- package/src/pages/admin/calendar/resources/index.astro +106 -0
- package/src/pages/admin/calendar/resources/link.astro +196 -0
- package/src/pages/admin/design.astro +10 -3
- package/src/pages/admin/email.astro +13 -0
- package/src/pages/admin/index.astro +232 -85
- package/src/pages/admin/links.astro +3 -3
- package/src/pages/admin/login.astro +14 -10
- package/src/pages/admin/navigation.astro +8 -2
- package/src/pages/admin/pages/[id].astro +4 -1
- package/src/pages/admin/pages/index.astro +41 -48
- package/src/pages/admin/settings.astro +418 -367
- package/src/pages/admin/tags.astro +4 -4
- package/src/pages/admin/users.astro +38 -28
- package/src/pages/api/agent/docs.ts +33 -0
- package/src/pages/api/agent/index.ts +21 -0
- package/src/pages/api/ai/clean.ts +13 -0
- package/src/pages/api/ai/draft.ts +14 -0
- package/src/pages/api/ai/settings.ts +53 -0
- package/src/pages/api/ai/test.ts +12 -0
- package/src/pages/api/backup.ts +8 -21
- package/src/pages/api/calendar/availability.ts +35 -0
- package/src/pages/api/calendar/bookings/[id].ts +49 -0
- package/src/pages/api/calendar/bookings/index.ts +73 -0
- package/src/pages/api/calendar/calendars.ts +35 -0
- package/src/pages/api/calendar/docs.ts +64 -0
- package/src/pages/api/calendar/events/[id].ts +63 -0
- package/src/pages/api/calendar/events/index.ts +112 -0
- package/src/pages/api/calendar/resources.ts +34 -0
- package/src/pages/api/design/capabilities.ts +10 -0
- package/src/pages/api/design/index.ts +58 -0
- package/src/pages/api/design/keys.ts +24 -0
- package/src/pages/api/design/media.ts +32 -0
- package/src/pages/api/email/run.ts +11 -0
- package/src/pages/api/email/settings.ts +61 -0
- package/src/pages/api/email/webhooks/[provider].ts +4 -0
- package/src/pages/api/mcp.ts +59 -0
- package/src/pages/api/media/upload.ts +2 -2
- package/src/pages/api/ops/status.ts +39 -0
- package/src/pages/api/restore.ts +27 -37
- package/src/pages/api/search.ts +18 -4
- package/src/pages/api/v1/availability.ts +7 -0
- package/src/pages/api/v1/bookings.ts +17 -0
- package/src/pages/api/v1/calendars.ts +7 -0
- package/src/pages/api/v1/content/[id].ts +7 -0
- package/src/pages/api/v1/content/index.ts +7 -0
- package/src/pages/api/v1/events.ts +7 -0
- package/src/pages/api/v1/resources.ts +7 -0
- package/src/pages/author/[slug].astro +43 -35
- package/src/pages/blog/[slug].astro +137 -66
- package/src/pages/blog/index.astro +95 -30
- package/src/pages/blog/tag/[slug].astro +45 -40
- package/src/pages/book/[resource]/[link].astro +70 -0
- package/src/pages/book/[resource]/index.astro +63 -0
- package/src/pages/book/[resource].ics.ts +38 -0
- package/src/pages/book/index.astro +47 -0
- package/src/pages/book/manage/[token].astro +108 -0
- package/src/pages/calendar/[slug].ics.ts +27 -0
- package/src/pages/calendar/feed.ics.ts +15 -0
- package/src/pages/calendar/index.astro +191 -0
- package/src/pages/embed/book/[resource]/[link].astro +33 -0
- package/src/pages/embed/calendar/[slug].astro +43 -0
- package/src/pages/events/[slug].astro +74 -0
- package/src/pages/events/[slug].ics.ts +25 -0
- package/src/pages/index.astro +6 -3
- package/src/pages/legal/privacy.astro +4 -0
- package/src/pages/legal/terms.astro +4 -0
- package/src/pages/links.astro +23 -3
- package/src/pages/llms.txt.ts +4 -0
- package/src/pages/preview/[type]/[id].astro +27 -6
- package/src/pages/search.astro +40 -20
- package/src/pages/sitemap.xml.ts +16 -1
- package/src/styles/admin.css +924 -105
- package/src/styles/cards.css +92 -74
- package/src/styles/editor.css +492 -114
- package/src/styles/email-settings.css +117 -0
- package/src/styles/sections.css +12 -0
- package/src/styles/site.css +938 -281
- package/src/styles/theme-editor.css +135 -0
- package/src/theme-sdk/contract.d.mts +619 -0
- package/src/theme-sdk/contract.mjs +80 -0
- package/src/theme-sdk/index.ts +15 -0
- package/src/theme-sdk/tokens.d.mts +148 -0
- package/src/theme-sdk/tokens.mjs +52 -0
- package/src/themes/editorial/templates/home.astro +4 -4
- package/src/themes/editorial/templates/post-minimal.astro +2 -2
- package/src/themes/editorial/theme.css +22 -37
- package/src/themes/editorial/theme.json +23 -3
- package/src/themes/field/theme.json +26 -9
- package/src/themes/ink/theme.json +30 -12
- package/src/themes/night/theme.css +4 -7
- package/src/themes/night/theme.json +27 -10
- package/src/virtual.d.ts +14 -0
- 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,370 +1,3 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
// acms update --check just report whether an update exists (exit 1 if behind)
|
|
5
|
-
// acms update --next follow the canary channel (`next` dist-tag); sticky once installed
|
|
6
|
-
// acms update --latest leave the canary channel and go back to stable
|
|
7
|
-
// acms doctor sanity-check the project setup
|
|
8
|
-
// acms add cloudflare retrofit Workers deployment (wrangler.jsonc, deps, D1/R2)
|
|
9
|
-
// acms add docker retrofit self-hosting (Dockerfile, compose.yml)
|
|
10
|
-
import { spawnSync } from 'node:child_process';
|
|
11
|
-
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
|
12
|
-
import { createRequire } from 'node:module';
|
|
13
|
-
import { dirname, join } from 'node:path';
|
|
14
|
-
|
|
15
|
-
const PKG = '@acms-subir/core';
|
|
16
|
-
const cmd = process.argv[2];
|
|
17
|
-
|
|
18
|
-
const read = (p) => { try { return readFileSync(p, 'utf8'); } catch { return ''; } };
|
|
19
|
-
const installedPkgJson = () => {
|
|
20
|
-
try {
|
|
21
|
-
return createRequire(join(process.cwd(), 'package.json')).resolve(`${PKG}/package.json`);
|
|
22
|
-
} catch { return null; }
|
|
23
|
-
};
|
|
24
|
-
const installedVersion = () => {
|
|
25
|
-
const p = installedPkgJson();
|
|
26
|
-
try { return p ? JSON.parse(readFileSync(p, 'utf8')).version : null; } catch { return null; }
|
|
27
|
-
};
|
|
28
|
-
const latestVersion = async (tag = 'latest') => {
|
|
29
|
-
const res = await fetch(`https://registry.npmjs.org/${encodeURIComponent(PKG)}/${tag}`, {
|
|
30
|
-
signal: AbortSignal.timeout(10_000),
|
|
31
|
-
});
|
|
32
|
-
if (res.status === 404 && tag !== 'latest') {
|
|
33
|
-
throw Object.assign(new Error(`the '${tag}' channel has no releases yet`), { noTag: true });
|
|
34
|
-
}
|
|
35
|
-
if (!res.ok) throw new Error(`registry responded ${res.status}`);
|
|
36
|
-
return (await res.json()).version;
|
|
37
|
-
};
|
|
38
|
-
const splitPre = (v) => {
|
|
39
|
-
const i = v.indexOf('-');
|
|
40
|
-
return i === -1 ? [v, ''] : [v.slice(0, i), v.slice(i + 1)];
|
|
41
|
-
};
|
|
42
|
-
const isCanary = (v) => splitPre(v)[1] !== '';
|
|
43
|
-
const semverLt = (a, b) => {
|
|
44
|
-
const [aBase, aPre] = splitPre(a), [bBase, bPre] = splitPre(b);
|
|
45
|
-
const pa = aBase.split('.').map(Number), pb = bBase.split('.').map(Number);
|
|
46
|
-
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; }
|
|
47
|
-
// equal bases: a release outranks any of its prereleases; two prereleases
|
|
48
|
-
// compare identifier by identifier, numerically where both sides are numbers
|
|
49
|
-
if (!aPre) return false;
|
|
50
|
-
if (!bPre) return true;
|
|
51
|
-
const as = aPre.split('.'), bs = bPre.split('.');
|
|
52
|
-
for (let i = 0; i < Math.max(as.length, bs.length); i++) {
|
|
53
|
-
const x = as[i], y = bs[i];
|
|
54
|
-
if (x === undefined) return true;
|
|
55
|
-
if (y === undefined) return false;
|
|
56
|
-
const nx = /^\d+$/.test(x), ny = /^\d+$/.test(y);
|
|
57
|
-
if (nx && ny) { if (Number(x) !== Number(y)) return Number(x) < Number(y); }
|
|
58
|
-
else if (nx !== ny) return nx;
|
|
59
|
-
else if (x !== y) return x < y;
|
|
60
|
-
}
|
|
61
|
-
return false;
|
|
62
|
-
};
|
|
63
|
-
|
|
64
|
-
// Release notes come from the CHANGELOG that changesets writes into the
|
|
65
|
-
// package itself, read after the install from the freshly unpacked copy. No
|
|
66
|
-
// git-host API is involved on purpose: the repository lives on a private
|
|
67
|
-
// OneDev instance, so anything API-based would need a token on every site
|
|
68
|
-
// that runs this command.
|
|
69
|
-
const changelogFor = (pkgJsonPath) => (pkgJsonPath ? read(join(dirname(pkgJsonPath), 'CHANGELOG.md')) : '');
|
|
70
|
-
// Entries newer than `current` up to `latest`, oldest first. Changesets writes
|
|
71
|
-
// one "## X.Y.Z" heading per release.
|
|
72
|
-
const releaseNotes = (md, current, latest) => {
|
|
73
|
-
// split on every "## " heading, not just the version ones, so a hand-written
|
|
74
|
-
// "## Unreleased" section cannot get swallowed into the entry above it
|
|
75
|
-
const heads = [...md.matchAll(/^## +(.*)$/gm)];
|
|
76
|
-
return heads
|
|
77
|
-
.map((h, i) => ({
|
|
78
|
-
version: h[1].trim().match(/^\d+\.\d+\.\d+$/)?.[0],
|
|
79
|
-
body: md.slice(h.index + h[0].length, heads[i + 1]?.index ?? md.length).trim(),
|
|
80
|
-
}))
|
|
81
|
-
.filter((r) => r.version && semverLt(current, r.version) && !semverLt(latest, r.version))
|
|
82
|
-
.sort((a, b) => (semverLt(a.version, b.version) ? -1 : 1));
|
|
83
|
-
};
|
|
84
|
-
const deployTargets = () => ({
|
|
85
|
-
cloudflare: ['wrangler.jsonc', 'wrangler.json', 'wrangler.toml'].some((f) => existsSync(f)),
|
|
86
|
-
docker: ['compose.yml', 'compose.yaml', 'docker-compose.yml', 'Dockerfile'].some((f) => existsSync(f)),
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
if (cmd === 'update') {
|
|
90
|
-
const checkOnly = process.argv.includes('--check');
|
|
91
|
-
const current = installedVersion();
|
|
92
|
-
if (!current) { console.error(`✗ ${PKG} is not installed here — run this from your site's folder.`); process.exit(1); }
|
|
93
|
-
// Channel: --next opts into canary prereleases and is sticky — once a canary
|
|
94
|
-
// is installed, a plain `acms update` keeps following the next tag until
|
|
95
|
-
// --latest switches back to stable.
|
|
96
|
-
const backToStable = process.argv.includes('--latest') && isCanary(current);
|
|
97
|
-
const channel = process.argv.includes('--latest') ? 'latest'
|
|
98
|
-
: process.argv.includes('--next') || isCanary(current) ? 'next'
|
|
99
|
-
: 'latest';
|
|
100
|
-
let latest;
|
|
101
|
-
try { latest = await latestVersion(channel); } catch (e) {
|
|
102
|
-
console.error(e.noTag ? `✗ ${e.message[0].toUpperCase()}${e.message.slice(1)}.` : `✗ Could not reach the npm registry (${e.message}).`);
|
|
103
|
-
process.exit(1);
|
|
104
|
-
}
|
|
105
|
-
// Leaving the canary channel is usually a downgrade (0.1.1-next.x → 0.1.0),
|
|
106
|
-
// so "am I behind" is the wrong question there — switch unconditionally.
|
|
107
|
-
if (!backToStable && !semverLt(current, latest)) {
|
|
108
|
-
console.log(`✓ Already up to date (${current}${channel === 'next' ? ', canary channel' : ''}).`);
|
|
109
|
-
process.exit(0);
|
|
110
|
-
}
|
|
111
|
-
if (checkOnly) {
|
|
112
|
-
console.log(`Update available: ${current} → ${latest}. Run: npx acms update${channel === 'next' ? ' --next' : backToStable ? ' --latest' : ''}`);
|
|
113
|
-
process.exit(1);
|
|
114
|
-
}
|
|
115
|
-
console.log(backToStable
|
|
116
|
-
? `Returning to the stable channel: ${PKG} ${current} → ${latest}…`
|
|
117
|
-
: `Updating ${PKG} ${current} → ${latest}${channel === 'next' ? ' (canary)' : ''}…`);
|
|
118
|
-
const res = spawnSync('npm', ['install', `${PKG}@${channel}`, '--no-audit', '--no-fund'], { stdio: 'inherit' });
|
|
119
|
-
if (res.status !== 0) process.exit(res.status ?? 1);
|
|
120
|
-
console.log(`\n✓ Updated to ${latest}.`);
|
|
121
|
-
const notes = releaseNotes(changelogFor(installedPkgJson()), current, latest);
|
|
122
|
-
if (notes.length) {
|
|
123
|
-
console.log(`\nWhat's new:`);
|
|
124
|
-
for (const r of notes) {
|
|
125
|
-
console.log(`\n ${PKG} ${r.version}`);
|
|
126
|
-
if (r.body) console.log(r.body.split('\n').map((l) => ` ${l}`).join('\n'));
|
|
127
|
-
}
|
|
128
|
-
} else if (channel === 'next') {
|
|
129
|
-
console.log(`\nWhat's new: canary build — notes arrive with the next stable release.`);
|
|
130
|
-
} else {
|
|
131
|
-
console.log(`\nWhat's new: https://www.npmjs.com/package/${PKG}/v/${latest}`);
|
|
132
|
-
}
|
|
133
|
-
if (channel === 'next') {
|
|
134
|
-
console.log(`\nYou're on the canary channel; \`npx acms update --latest\` returns to stable.`);
|
|
135
|
-
}
|
|
136
|
-
const t = deployTargets();
|
|
137
|
-
const steps = [
|
|
138
|
-
...(t.cloudflare ? [' npm run deploy # Cloudflare Workers'] : []),
|
|
139
|
-
...(t.docker ? [' git push, then on your server: docker compose up -d --build'] : []),
|
|
140
|
-
];
|
|
141
|
-
console.log([
|
|
142
|
-
'',
|
|
143
|
-
'Deploy when ready:',
|
|
144
|
-
...(steps.length ? steps : [' rebuild and redeploy your host']),
|
|
145
|
-
' (the database migrates itself on the first request — no manual steps)',
|
|
146
|
-
].join('\n'));
|
|
147
|
-
// the registry fetch leaves a keep-alive socket in undici's pool, which can
|
|
148
|
-
// hold the event loop open for seconds after there is nothing left to do
|
|
149
|
-
process.exit(0);
|
|
150
|
-
} else if (cmd === 'add') {
|
|
151
|
-
// Retrofit a deployment target onto an existing project. Both targets share
|
|
152
|
-
// one codebase — astro.config picks the adapter via DEPLOY_TARGET — so
|
|
153
|
-
// "adding" one is only config files + deps. Content moves between backends
|
|
154
|
-
// with a backup ZIP (admin → Backup, then admin → Restore on the new host).
|
|
155
|
-
const what = process.argv[3];
|
|
156
|
-
if (!existsSync('package.json')) {
|
|
157
|
-
console.error("✗ No package.json here — run this from your site's folder.");
|
|
158
|
-
process.exit(1);
|
|
159
|
-
}
|
|
160
|
-
const pkg = JSON.parse(readFileSync('package.json', 'utf8'));
|
|
161
|
-
const slug = (pkg.name || 'my-site').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 50) || 'my-site';
|
|
162
|
-
const writeNew = (file, body) => {
|
|
163
|
-
if (existsSync(file)) { console.log(`• ${file} already exists — left untouched`); return false; }
|
|
164
|
-
writeFileSync(file, body);
|
|
165
|
-
console.log(`✓ wrote ${file}`);
|
|
166
|
-
return true;
|
|
167
|
-
};
|
|
168
|
-
const setScript = (name, value) => {
|
|
169
|
-
pkg.scripts ??= {};
|
|
170
|
-
if (pkg.scripts[name]) return;
|
|
171
|
-
pkg.scripts[name] = value;
|
|
172
|
-
console.log(`✓ added "${name}" script`);
|
|
173
|
-
};
|
|
174
|
-
|
|
175
|
-
if (what === 'cloudflare') {
|
|
176
|
-
writeNew('wrangler.jsonc', `{
|
|
177
|
-
"$schema": "node_modules/wrangler/config-schema.json",
|
|
178
|
-
"name": "${slug}",
|
|
179
|
-
"main": "./dist/_worker.js/index.js",
|
|
180
|
-
"compatibility_date": "2025-07-01",
|
|
181
|
-
"compatibility_flags": ["nodejs_compat"],
|
|
182
|
-
"assets": {
|
|
183
|
-
"binding": "ASSETS",
|
|
184
|
-
"directory": "./dist"
|
|
185
|
-
},
|
|
186
|
-
"observability": {
|
|
187
|
-
"enabled": true
|
|
188
|
-
},
|
|
189
|
-
// Scheduled publishing: flips due scheduled posts to published every 5
|
|
190
|
-
// minutes, independent of site traffic.
|
|
191
|
-
"triggers": {
|
|
192
|
-
"crons": ["*/5 * * * *"]
|
|
193
|
-
},
|
|
194
|
-
// If the id below is still zeros, create the real database with
|
|
195
|
-
// npx wrangler d1 create ${slug}-db
|
|
196
|
-
// and paste the returned database_id here. Local dev (wrangler dev /
|
|
197
|
-
// astro dev) uses a local simulator and works with the placeholder.
|
|
198
|
-
"d1_databases": [
|
|
199
|
-
{
|
|
200
|
-
"binding": "DB",
|
|
201
|
-
"database_name": "${slug}-db",
|
|
202
|
-
"database_id": "00000000-0000-0000-0000-000000000000"
|
|
203
|
-
}
|
|
204
|
-
],
|
|
205
|
-
// If missing, create the real bucket with:
|
|
206
|
-
// npx wrangler r2 bucket create ${slug}-media
|
|
207
|
-
// Local dev uses a local simulator and works without creating anything.
|
|
208
|
-
"r2_buckets": [
|
|
209
|
-
{
|
|
210
|
-
"binding": "MEDIA",
|
|
211
|
-
"bucket_name": "${slug}-media"
|
|
212
|
-
}
|
|
213
|
-
]
|
|
214
|
-
}
|
|
215
|
-
`);
|
|
216
|
-
setScript('preview', 'npm run build && wrangler dev');
|
|
217
|
-
setScript('deploy', 'npm run build && wrangler deploy');
|
|
218
|
-
writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n');
|
|
219
|
-
if (!pkg.devDependencies?.wrangler && !pkg.dependencies?.wrangler) {
|
|
220
|
-
console.log('Installing wrangler…');
|
|
221
|
-
const res = spawnSync('npm', ['install', '-D', 'wrangler', '--no-audit', '--no-fund'], { stdio: 'inherit' });
|
|
222
|
-
if (res.status !== 0) console.error('✗ npm install -D wrangler failed — install it manually.');
|
|
223
|
-
}
|
|
224
|
-
if (process.argv.includes('--provision')) {
|
|
225
|
-
console.log(`Creating D1 database ${slug}-db…`);
|
|
226
|
-
const d1 = spawnSync('npx', ['--no-install', 'wrangler', 'd1', 'create', `${slug}-db`], { encoding: 'utf8' });
|
|
227
|
-
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];
|
|
228
|
-
if (d1.status === 0 && id) {
|
|
229
|
-
writeFileSync('wrangler.jsonc', readFileSync('wrangler.jsonc', 'utf8').replace('00000000-0000-0000-0000-000000000000', id));
|
|
230
|
-
console.log(`✓ D1 created (${id})`);
|
|
231
|
-
} else {
|
|
232
|
-
console.error(`✗ D1 creation failed — create it later with: npx wrangler d1 create ${slug}-db`);
|
|
233
|
-
}
|
|
234
|
-
const r2 = spawnSync('npx', ['--no-install', 'wrangler', 'r2', 'bucket', 'create', `${slug}-media`], { encoding: 'utf8' });
|
|
235
|
-
console.log(r2.status === 0 ? '✓ R2 bucket created' : `✗ R2 creation failed — create it later with: npx wrangler r2 bucket create ${slug}-media`);
|
|
236
|
-
}
|
|
237
|
-
console.log(`
|
|
238
|
-
✓ Cloudflare target ready. Next:
|
|
239
|
-
npx wrangler d1 create ${slug}-db # unless --provision did it; paste the id into wrangler.jsonc
|
|
240
|
-
npx wrangler secret put ADMIN_PASSWORD
|
|
241
|
-
npx wrangler secret put SESSION_SECRET
|
|
242
|
-
npm run deploy
|
|
243
|
-
Moving existing content? Admin → Backup on the old host, Admin → Restore on the new one.`);
|
|
244
|
-
} else if (what === 'docker') {
|
|
245
|
-
writeNew('Dockerfile', `# Self-hosted Node target. Build/run via compose.yml, or directly:
|
|
246
|
-
# docker build -t ${slug} . && docker run -p 4321:4321 -v acms_data:/app/data ${slug}
|
|
247
|
-
FROM node:22-slim AS build
|
|
248
|
-
WORKDIR /app
|
|
249
|
-
COPY package.json package-lock.json* *.tgz* ./
|
|
250
|
-
RUN npm install --no-audit --no-fund
|
|
251
|
-
# self-heal npm/cli#4828: a lockfile updated on macOS can omit the Linux
|
|
252
|
-
# platform binaries (rollup, libsql) while still "installing" cleanly
|
|
253
|
-
RUN node -e "require('rollup')" >/dev/null 2>&1 || \\
|
|
254
|
-
(rm -rf node_modules package-lock.json && npm install --no-audit --no-fund)
|
|
255
|
-
COPY . .
|
|
256
|
-
RUN DEPLOY_TARGET=node npm run build:node
|
|
257
|
-
|
|
258
|
-
FROM node:22-slim
|
|
259
|
-
# WORKDIR must stay stable: SQLITE_URL and the media store resolve ./data
|
|
260
|
-
# against the process CWD
|
|
261
|
-
WORKDIR /app
|
|
262
|
-
ENV NODE_ENV=production HOST=0.0.0.0 PORT=4321
|
|
263
|
-
# the standalone bundle in dist/ contains all app code; the only runtime
|
|
264
|
-
# import left in node_modules is @libsql/client (dynamically imported so it
|
|
265
|
-
# stays out of the Workers bundle) — installing just that keeps the image
|
|
266
|
-
# ~300MB instead of ~2GB
|
|
267
|
-
RUN npm install --no-save --no-audit --no-fund "@libsql/client@^0.15.0"
|
|
268
|
-
COPY --from=build /app/dist ./dist
|
|
269
|
-
# ./data holds the SQLite database and uploaded media — mount a volume here.
|
|
270
|
-
# Only data/ needs to be writable (a chown -R of /app would copy-on-write
|
|
271
|
-
# duplicate every node_modules file into a fresh layer)
|
|
272
|
-
RUN mkdir -p data && chown node:node data
|
|
273
|
-
USER node
|
|
274
|
-
EXPOSE 4321
|
|
275
|
-
CMD ["node", "./dist/server/entry.mjs"]
|
|
276
|
-
`);
|
|
277
|
-
writeNew('compose.yml', `# Self-hosting on your own server. A reverse proxy you already run (Coolify,
|
|
278
|
-
# Dokploy, Traefik, nginx, Caddy) terminates TLS and routes to port 4321.
|
|
279
|
-
# Secrets come from .env in this directory (compose reads it automatically).
|
|
280
|
-
services:
|
|
281
|
-
site:
|
|
282
|
-
build: .
|
|
283
|
-
restart: unless-stopped
|
|
284
|
-
expose:
|
|
285
|
-
- "4321"
|
|
286
|
-
# No reverse proxy? Publish the port directly instead:
|
|
287
|
-
# ports:
|
|
288
|
-
# - "4321:4321"
|
|
289
|
-
volumes:
|
|
290
|
-
# one volume covers everything: SQLite database + uploaded media
|
|
291
|
-
- data:/app/data
|
|
292
|
-
environment:
|
|
293
|
-
- HOST=0.0.0.0
|
|
294
|
-
- PORT=4321
|
|
295
|
-
- ADMIN_PASSWORD=\${ADMIN_PASSWORD:?set ADMIN_PASSWORD in .env}
|
|
296
|
-
- SESSION_SECRET=\${SESSION_SECRET:?set SESSION_SECRET in .env}
|
|
297
|
-
- SQLITE_URL=file:./data/acms.db
|
|
298
|
-
healthcheck:
|
|
299
|
-
# also serves as the scheduled-publish heartbeat: each probe is a real
|
|
300
|
-
# request, which triggers the middleware publish sweep
|
|
301
|
-
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:4321/').then((r)=>process.exit(r.ok?0:1),()=>process.exit(1))"]
|
|
302
|
-
interval: 300s
|
|
303
|
-
start_period: 15s
|
|
304
|
-
|
|
305
|
-
volumes:
|
|
306
|
-
data:
|
|
307
|
-
`);
|
|
308
|
-
writeNew('.dockerignore', `node_modules
|
|
309
|
-
dist
|
|
310
|
-
.astro
|
|
311
|
-
data
|
|
312
|
-
.env
|
|
313
|
-
.dev.vars
|
|
314
|
-
.wrangler
|
|
315
|
-
.git
|
|
316
|
-
`);
|
|
317
|
-
writeNew('.env.example', `# Copy to .env and fill in real values — compose.yml reads .env automatically.
|
|
318
|
-
# .env itself is gitignored; this file is the committed record of what to set.
|
|
319
|
-
ADMIN_PASSWORD=change-me
|
|
320
|
-
# Random secret used to sign the session cookie (openssl rand -hex 32)
|
|
321
|
-
SESSION_SECRET=
|
|
322
|
-
# SQLite file used by the Node target (a volume mounts /app/data in Docker)
|
|
323
|
-
SQLITE_URL=file:./data/acms.db
|
|
324
|
-
`);
|
|
325
|
-
setScript('build:node', 'DEPLOY_TARGET=node astro build');
|
|
326
|
-
writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n');
|
|
327
|
-
console.log(`
|
|
328
|
-
✓ Docker target ready. Next (on your server):
|
|
329
|
-
set real ADMIN_PASSWORD / SESSION_SECRET in .env
|
|
330
|
-
docker compose up -d --build # or point Coolify/Dokploy/Railway at the repo
|
|
331
|
-
data lives in the volume at /app/data (SQLite + media)
|
|
332
|
-
Moving existing content? Admin → Backup on the old host, Admin → Restore on the new one.`);
|
|
333
|
-
} else {
|
|
334
|
-
console.error('Usage: acms add <cloudflare|docker> [--provision]');
|
|
335
|
-
process.exit(1);
|
|
336
|
-
}
|
|
337
|
-
} else if (cmd === 'doctor') {
|
|
338
|
-
let bad = 0;
|
|
339
|
-
const check = (ok, label, hint) => {
|
|
340
|
-
console.log(`${ok ? '✓' : '✗'} ${label}${ok || !hint ? '' : `\n ↳ ${hint}`}`);
|
|
341
|
-
if (!ok) bad++;
|
|
342
|
-
};
|
|
343
|
-
const major = Number(process.versions.node.split('.')[0]);
|
|
344
|
-
check(major >= 20, `Node ${process.versions.node} (need ≥20)`, 'Upgrade Node.');
|
|
345
|
-
check(installedVersion() !== null, `${PKG} resolvable`, 'npm install');
|
|
346
|
-
const cfg = read('astro.config.mjs') + read('astro.config.ts');
|
|
347
|
-
check(/@acms-subir\/core/.test(cfg), 'astro.config uses the acms integration', "import acms from '@acms-subir/core'");
|
|
348
|
-
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';");
|
|
349
|
-
const wrangler = read('wrangler.jsonc') + read('wrangler.json') + read('wrangler.toml');
|
|
350
|
-
if (wrangler) {
|
|
351
|
-
check(!wrangler.includes('00000000-0000-0000-0000-000000000000'), 'wrangler D1 database_id is real', 'npx wrangler d1 create <name>-db, then paste the id.');
|
|
352
|
-
} else {
|
|
353
|
-
console.log('• no wrangler config (Node target?) — skipping Cloudflare checks');
|
|
354
|
-
}
|
|
355
|
-
check(existsSync('.dev.vars') || existsSync('.env'), 'local secrets file (.env / .dev.vars)', 'Set ADMIN_PASSWORD and SESSION_SECRET.');
|
|
356
|
-
console.log(bad === 0 ? '\nAll good.' : `\n${bad} issue${bad > 1 ? 's' : ''} found.`);
|
|
357
|
-
process.exit(bad === 0 ? 0 : 1);
|
|
358
|
-
} else {
|
|
359
|
-
console.log(`acms — maintenance CLI for @acms-subir/core
|
|
360
|
-
|
|
361
|
-
Usage:
|
|
362
|
-
acms update Update the CMS core to the latest release
|
|
363
|
-
acms update --check Report whether an update exists (exit 1 if behind)
|
|
364
|
-
acms update --next Follow the canary channel (prereleases); later updates stay on it
|
|
365
|
-
acms update --latest Leave the canary channel and return to stable releases
|
|
366
|
-
acms doctor Check this project's setup
|
|
367
|
-
acms add cloudflare Add Workers deployment to this project (--provision creates D1 + R2)
|
|
368
|
-
acms add docker Add Docker self-hosting to this project`);
|
|
369
|
-
process.exit(cmd ? 1 : 0);
|
|
370
|
-
}
|
|
2
|
+
import { main } from './cli/main.mjs';
|
|
3
|
+
await main();
|
package/deploy/Caddyfile
ADDED
|
@@ -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:
|