@acms-subir/core 0.0.1 → 0.0.2
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/cli.mjs +188 -5
- package/package.json +2 -2
- package/src/lib/repo/sql.ts +4 -2
- package/src/lib/repo/sqlite.ts +3 -1
package/cli.mjs
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// `acms` — tiny maintenance CLI installed with @acms-subir/core.
|
|
3
|
-
// acms update
|
|
4
|
-
// acms doctor
|
|
3
|
+
// acms update check the registry, install the latest core, print what's new
|
|
4
|
+
// acms doctor sanity-check the project setup
|
|
5
|
+
// acms add cloudflare retrofit Workers deployment (wrangler.jsonc, deps, D1/R2)
|
|
6
|
+
// acms add docker retrofit self-hosting (Dockerfile, compose.yml)
|
|
5
7
|
import { spawnSync } from 'node:child_process';
|
|
6
|
-
import { readFileSync, existsSync } from 'node:fs';
|
|
8
|
+
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
|
7
9
|
import { createRequire } from 'node:module';
|
|
8
10
|
import { join } from 'node:path';
|
|
9
11
|
|
|
@@ -44,6 +46,185 @@ if (cmd === 'update') {
|
|
|
44
46
|
✓ Updated to ${latest}. What's new: https://github.com/zurielbm/acms-subir/releases
|
|
45
47
|
Deploy when ready: npm run deploy
|
|
46
48
|
(the database migrates itself on the first request — no manual steps)`);
|
|
49
|
+
} else if (cmd === 'add') {
|
|
50
|
+
// Retrofit a deployment target onto an existing project. Both targets share
|
|
51
|
+
// one codebase — astro.config picks the adapter via DEPLOY_TARGET — so
|
|
52
|
+
// "adding" one is only config files + deps. Content moves between backends
|
|
53
|
+
// with a backup ZIP (admin → Backup, then admin → Restore on the new host).
|
|
54
|
+
const what = process.argv[3];
|
|
55
|
+
if (!existsSync('package.json')) {
|
|
56
|
+
console.error("✗ No package.json here — run this from your site's folder.");
|
|
57
|
+
process.exit(1);
|
|
58
|
+
}
|
|
59
|
+
const pkg = JSON.parse(readFileSync('package.json', 'utf8'));
|
|
60
|
+
const slug = (pkg.name || 'my-site').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 50) || 'my-site';
|
|
61
|
+
const writeNew = (file, body) => {
|
|
62
|
+
if (existsSync(file)) { console.log(`• ${file} already exists — left untouched`); return false; }
|
|
63
|
+
writeFileSync(file, body);
|
|
64
|
+
console.log(`✓ wrote ${file}`);
|
|
65
|
+
return true;
|
|
66
|
+
};
|
|
67
|
+
const setScript = (name, value) => {
|
|
68
|
+
pkg.scripts ??= {};
|
|
69
|
+
if (pkg.scripts[name]) return;
|
|
70
|
+
pkg.scripts[name] = value;
|
|
71
|
+
console.log(`✓ added "${name}" script`);
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
if (what === 'cloudflare') {
|
|
75
|
+
writeNew('wrangler.jsonc', `{
|
|
76
|
+
"$schema": "node_modules/wrangler/config-schema.json",
|
|
77
|
+
"name": "${slug}",
|
|
78
|
+
"main": "./dist/_worker.js/index.js",
|
|
79
|
+
"compatibility_date": "2025-07-01",
|
|
80
|
+
"compatibility_flags": ["nodejs_compat"],
|
|
81
|
+
"assets": {
|
|
82
|
+
"binding": "ASSETS",
|
|
83
|
+
"directory": "./dist"
|
|
84
|
+
},
|
|
85
|
+
"observability": {
|
|
86
|
+
"enabled": true
|
|
87
|
+
},
|
|
88
|
+
// Scheduled publishing: flips due scheduled posts to published every 5
|
|
89
|
+
// minutes, independent of site traffic.
|
|
90
|
+
"triggers": {
|
|
91
|
+
"crons": ["*/5 * * * *"]
|
|
92
|
+
},
|
|
93
|
+
// If the id below is still zeros, create the real database with
|
|
94
|
+
// npx wrangler d1 create ${slug}-db
|
|
95
|
+
// and paste the returned database_id here. Local dev (wrangler dev /
|
|
96
|
+
// astro dev) uses a local simulator and works with the placeholder.
|
|
97
|
+
"d1_databases": [
|
|
98
|
+
{
|
|
99
|
+
"binding": "DB",
|
|
100
|
+
"database_name": "${slug}-db",
|
|
101
|
+
"database_id": "00000000-0000-0000-0000-000000000000"
|
|
102
|
+
}
|
|
103
|
+
],
|
|
104
|
+
// If missing, create the real bucket with:
|
|
105
|
+
// npx wrangler r2 bucket create ${slug}-media
|
|
106
|
+
// Local dev uses a local simulator and works without creating anything.
|
|
107
|
+
"r2_buckets": [
|
|
108
|
+
{
|
|
109
|
+
"binding": "MEDIA",
|
|
110
|
+
"bucket_name": "${slug}-media"
|
|
111
|
+
}
|
|
112
|
+
]
|
|
113
|
+
}
|
|
114
|
+
`);
|
|
115
|
+
setScript('preview', 'npm run build && wrangler dev');
|
|
116
|
+
setScript('deploy', 'npm run build && wrangler deploy');
|
|
117
|
+
writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n');
|
|
118
|
+
if (!pkg.devDependencies?.wrangler && !pkg.dependencies?.wrangler) {
|
|
119
|
+
console.log('Installing wrangler…');
|
|
120
|
+
const res = spawnSync('npm', ['install', '-D', 'wrangler', '--no-audit', '--no-fund'], { stdio: 'inherit' });
|
|
121
|
+
if (res.status !== 0) console.error('✗ npm install -D wrangler failed — install it manually.');
|
|
122
|
+
}
|
|
123
|
+
if (process.argv.includes('--provision')) {
|
|
124
|
+
console.log(`Creating D1 database ${slug}-db…`);
|
|
125
|
+
const d1 = spawnSync('npx', ['--no-install', 'wrangler', 'd1', 'create', `${slug}-db`], { encoding: 'utf8' });
|
|
126
|
+
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];
|
|
127
|
+
if (d1.status === 0 && id) {
|
|
128
|
+
writeFileSync('wrangler.jsonc', readFileSync('wrangler.jsonc', 'utf8').replace('00000000-0000-0000-0000-000000000000', id));
|
|
129
|
+
console.log(`✓ D1 created (${id})`);
|
|
130
|
+
} else {
|
|
131
|
+
console.error(`✗ D1 creation failed — create it later with: npx wrangler d1 create ${slug}-db`);
|
|
132
|
+
}
|
|
133
|
+
const r2 = spawnSync('npx', ['--no-install', 'wrangler', 'r2', 'bucket', 'create', `${slug}-media`], { encoding: 'utf8' });
|
|
134
|
+
console.log(r2.status === 0 ? '✓ R2 bucket created' : `✗ R2 creation failed — create it later with: npx wrangler r2 bucket create ${slug}-media`);
|
|
135
|
+
}
|
|
136
|
+
console.log(`
|
|
137
|
+
✓ Cloudflare target ready. Next:
|
|
138
|
+
npx wrangler d1 create ${slug}-db # unless --provision did it; paste the id into wrangler.jsonc
|
|
139
|
+
npx wrangler secret put ADMIN_PASSWORD
|
|
140
|
+
npx wrangler secret put SESSION_SECRET
|
|
141
|
+
npm run deploy
|
|
142
|
+
Moving existing content? Admin → Backup on the old host, Admin → Restore on the new one.`);
|
|
143
|
+
} else if (what === 'docker') {
|
|
144
|
+
writeNew('Dockerfile', `# Self-hosted Node target. Build/run via compose.yml, or directly:
|
|
145
|
+
# docker build -t ${slug} . && docker run -p 4321:4321 -v acms_data:/app/data ${slug}
|
|
146
|
+
FROM node:22-slim AS build
|
|
147
|
+
WORKDIR /app
|
|
148
|
+
COPY package.json package-lock.json* *.tgz* ./
|
|
149
|
+
RUN npm install --no-audit --no-fund
|
|
150
|
+
# self-heal npm/cli#4828: a lockfile updated on macOS can omit the Linux
|
|
151
|
+
# platform binaries (rollup, libsql) while still "installing" cleanly
|
|
152
|
+
RUN node -e "require('rollup')" >/dev/null 2>&1 || \\
|
|
153
|
+
(rm -rf node_modules package-lock.json && npm install --no-audit --no-fund)
|
|
154
|
+
COPY . .
|
|
155
|
+
RUN DEPLOY_TARGET=node npm run build:node
|
|
156
|
+
|
|
157
|
+
FROM node:22-slim
|
|
158
|
+
# WORKDIR must stay stable: SQLITE_URL and the media store resolve ./data
|
|
159
|
+
# against the process CWD
|
|
160
|
+
WORKDIR /app
|
|
161
|
+
ENV NODE_ENV=production HOST=0.0.0.0 PORT=4321
|
|
162
|
+
# the standalone bundle in dist/ contains all app code; the only runtime
|
|
163
|
+
# import left in node_modules is @libsql/client (dynamically imported so it
|
|
164
|
+
# stays out of the Workers bundle) — installing just that keeps the image
|
|
165
|
+
# ~300MB instead of ~2GB
|
|
166
|
+
RUN npm install --no-save --no-audit --no-fund "@libsql/client@^0.15.0"
|
|
167
|
+
COPY --from=build /app/dist ./dist
|
|
168
|
+
# ./data holds the SQLite database and uploaded media — mount a volume here.
|
|
169
|
+
# Only data/ needs to be writable (a chown -R of /app would copy-on-write
|
|
170
|
+
# duplicate every node_modules file into a fresh layer)
|
|
171
|
+
RUN mkdir -p data && chown node:node data
|
|
172
|
+
USER node
|
|
173
|
+
EXPOSE 4321
|
|
174
|
+
CMD ["node", "./dist/server/entry.mjs"]
|
|
175
|
+
`);
|
|
176
|
+
writeNew('compose.yml', `# Self-hosting on your own server. A reverse proxy you already run (Coolify,
|
|
177
|
+
# Dokploy, Traefik, nginx, Caddy) terminates TLS and routes to port 4321.
|
|
178
|
+
# Secrets come from .env in this directory (compose reads it automatically).
|
|
179
|
+
services:
|
|
180
|
+
site:
|
|
181
|
+
build: .
|
|
182
|
+
restart: unless-stopped
|
|
183
|
+
expose:
|
|
184
|
+
- "4321"
|
|
185
|
+
# No reverse proxy? Publish the port directly instead:
|
|
186
|
+
# ports:
|
|
187
|
+
# - "4321:4321"
|
|
188
|
+
volumes:
|
|
189
|
+
# one volume covers everything: SQLite database + uploaded media
|
|
190
|
+
- data:/app/data
|
|
191
|
+
environment:
|
|
192
|
+
- HOST=0.0.0.0
|
|
193
|
+
- PORT=4321
|
|
194
|
+
- ADMIN_PASSWORD=\${ADMIN_PASSWORD:?set ADMIN_PASSWORD in .env}
|
|
195
|
+
- SESSION_SECRET=\${SESSION_SECRET:?set SESSION_SECRET in .env}
|
|
196
|
+
- SQLITE_URL=file:./data/acms.db
|
|
197
|
+
healthcheck:
|
|
198
|
+
# also serves as the scheduled-publish heartbeat: each probe is a real
|
|
199
|
+
# request, which triggers the middleware publish sweep
|
|
200
|
+
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:4321/').then((r)=>process.exit(r.ok?0:1),()=>process.exit(1))"]
|
|
201
|
+
interval: 300s
|
|
202
|
+
start_period: 15s
|
|
203
|
+
|
|
204
|
+
volumes:
|
|
205
|
+
data:
|
|
206
|
+
`);
|
|
207
|
+
writeNew('.dockerignore', `node_modules
|
|
208
|
+
dist
|
|
209
|
+
.astro
|
|
210
|
+
data
|
|
211
|
+
.env
|
|
212
|
+
.dev.vars
|
|
213
|
+
.wrangler
|
|
214
|
+
.git
|
|
215
|
+
`);
|
|
216
|
+
setScript('build:node', 'DEPLOY_TARGET=node astro build');
|
|
217
|
+
writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n');
|
|
218
|
+
console.log(`
|
|
219
|
+
✓ Docker target ready. Next (on your server):
|
|
220
|
+
set real ADMIN_PASSWORD / SESSION_SECRET in .env
|
|
221
|
+
docker compose up -d --build # or point Coolify/Dokploy/Railway at the repo
|
|
222
|
+
data lives in the volume at /app/data (SQLite + media)
|
|
223
|
+
Moving existing content? Admin → Backup on the old host, Admin → Restore on the new one.`);
|
|
224
|
+
} else {
|
|
225
|
+
console.error('Usage: acms add <cloudflare|docker> [--provision]');
|
|
226
|
+
process.exit(1);
|
|
227
|
+
}
|
|
47
228
|
} else if (cmd === 'doctor') {
|
|
48
229
|
let bad = 0;
|
|
49
230
|
const check = (ok, label, hint) => {
|
|
@@ -69,7 +250,9 @@ if (cmd === 'update') {
|
|
|
69
250
|
console.log(`acms — maintenance CLI for @acms-subir/core
|
|
70
251
|
|
|
71
252
|
Usage:
|
|
72
|
-
acms update
|
|
73
|
-
acms doctor
|
|
253
|
+
acms update Update the CMS core to the latest release
|
|
254
|
+
acms doctor Check this project's setup
|
|
255
|
+
acms add cloudflare Add Workers deployment to this project (--provision creates D1 + R2)
|
|
256
|
+
acms add docker Add Docker self-hosting to this project`);
|
|
74
257
|
process.exit(cmd ? 1 : 0);
|
|
75
258
|
}
|
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@acms-subir/core",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.2",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public"
|
|
6
6
|
},
|
|
7
|
-
"description": "Widget-based CMS for Astro on Cloudflare Workers
|
|
7
|
+
"description": "Widget-based CMS for Astro on Cloudflare Workers — admin UI, content storage, themes, and APIs shipped as one integration.",
|
|
8
8
|
"type": "module",
|
|
9
9
|
"license": "MIT",
|
|
10
10
|
"homepage": "https://acms.subir.dev",
|
package/src/lib/repo/sql.ts
CHANGED
|
@@ -584,9 +584,11 @@ export class SqlRepository implements ContentRepository {
|
|
|
584
584
|
...['posts', 'pages', 'media', 'links', 'tags', 'nav_items', 'users', 'post_authors', 'redirects'].map((t) => ({
|
|
585
585
|
sql: `DELETE FROM ${t}`,
|
|
586
586
|
})),
|
|
587
|
+
// p.locale defaults to '' via the zod schema, so pre-v20 backups
|
|
588
|
+
// restore cleanly as default-locale pages
|
|
587
589
|
...content.pages.map((p) => ({
|
|
588
|
-
sql: 'INSERT INTO pages (id, slug, title, status, is_home, blocks, body_json, body_html, seo_title, seo_desc, template, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
|
589
|
-
args: [p.id, p.slug, p.title, p.status, p.isHome ? 1 : 0, JSON.stringify(p.blocks), p.bodyJson, p.bodyHtml, p.seoTitle, p.seoDesc, p.template, p.createdAt, p.updatedAt],
|
|
590
|
+
sql: 'INSERT INTO pages (id, slug, title, status, is_home, locale, blocks, body_json, body_html, seo_title, seo_desc, template, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
|
591
|
+
args: [p.id, p.slug, p.title, p.status, p.isHome ? 1 : 0, p.locale, JSON.stringify(p.blocks), p.bodyJson, p.bodyHtml, p.seoTitle, p.seoDesc, p.template, p.createdAt, p.updatedAt],
|
|
590
592
|
})),
|
|
591
593
|
// p.collection defaults to 'posts' via the zod schema, so pre-v19
|
|
592
594
|
// backups restore cleanly into the built-in collection
|
package/src/lib/repo/sqlite.ts
CHANGED
|
@@ -37,7 +37,9 @@ export class SqliteRepository extends SqlRepository {
|
|
|
37
37
|
const { mkdirSync } = await import(/* @vite-ignore */ 'node:fs');
|
|
38
38
|
mkdirSync('./data', { recursive: true });
|
|
39
39
|
}
|
|
40
|
-
|
|
40
|
+
// remote libSQL (Turso, sqld) needs a token; local files don't
|
|
41
|
+
const authToken = env('SQLITE_AUTH_TOKEN', '');
|
|
42
|
+
return new LibsqlDriver(createClient(authToken ? { url, authToken } : { url }));
|
|
41
43
|
});
|
|
42
44
|
}
|
|
43
45
|
}
|