caracms 0.7.0 → 0.8.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.
@@ -0,0 +1,73 @@
1
+ import { defineRailway, github, preserve, project, redis, ref, service } from "railway/iac";
2
+
3
+ // A caracms site's Railway project, as code (written by `caracms init` from the
4
+ // template for a site on CaraCMS Cloud). From the site's root: `railway config
5
+ // plan` shows what would change, `railway config apply` makes it so. Plan
6
+ // first, every time.
7
+ //
8
+ // On CaraCMS Cloud the site's GitHub access, images, forms and email, and video
9
+ // all come through CARACMS_CLOUD_KEY, so the project is the site and its Redis
10
+ // (the editor's drafts and sessions): no buckets, no imgproxy.
11
+ //
12
+ // What this file doesn't do, set by hand on Railway: create the project, the
13
+ // site's domain, Redis's volume and start command (Railway's Redis template
14
+ // makes them), the secrets' values, and the optional secrets below until
15
+ // they're wanted.
16
+
17
+ // ---- The site's names: a new site edits this block and nothing else. ----
18
+ const SITE = "{{SITE}}";
19
+ // The repo Railway builds from (the editor's repo is the one linked in CaraCMS Cloud).
20
+ const REPO = "{{GITHUB_REPO}}";
21
+ // Railway's region for the services.
22
+ const REGION = "europe-west4-drams3a";
23
+ // The site is the repo's root: no watch paths, so every push to main deploys.
24
+ const WATCH_PATHS: string[] = [];
25
+ const BUILD_COMMAND = "{{BUILD}}";
26
+ const START_COMMAND = "{{START}}";
27
+ // -------------------------------------------------------------------------
28
+
29
+ export default defineRailway(() => {
30
+ // The editor's drafts and sessions.
31
+ const kv = redis("Redis", { region: REGION });
32
+ // Sleeps when idle. redis() takes only a region, so it's set on the node.
33
+ kv.deploy = { ...kv.deploy, sleepApplication: true };
34
+
35
+ const site = service("site", {
36
+ source: github(REPO, { branch: "main", checkSuites: false }),
37
+ build: { buildCommand: BUILD_COMMAND, watchPatterns: WATCH_PATHS },
38
+ start: START_COMMAND,
39
+ healthcheck: "/health",
40
+ replicas: { [REGION]: 1 },
41
+ deploy: { restartPolicyMaxRetries: 3 },
42
+ env: {
43
+ // ---- Written here: literals and Railway references ----
44
+ NODE_ENV: "production",
45
+ // The public address (canonical links, the sitemap, og:url). At launch it
46
+ // becomes the client's domain, written out as "https://<domain>".
47
+ SITE_URL: "https://${{RAILWAY_PUBLIC_DOMAIN}}",
48
+ REDIS_URL: ref(kv, "REDIS_URL"),
49
+
50
+ // ---- Set by hand on Railway: names only, the values never live here ----
51
+ // On a fresh project these stay unset and the plan doesn't mention them.
52
+ // The site's key from app.caracms.com (a key labelled for this environment).
53
+ CARACMS_CLOUD_KEY: preserve(),
54
+ // The editor's sign-in.
55
+ CARACMS_SECRET: preserve(),
56
+ CARACMS_ALLOW: preserve(),
57
+ CARACMS_BACKUP_USER: preserve(),
58
+ CARACMS_BACKUP_PASSWORD: preserve(),
59
+ // Try again on a failed publish redeploys through this project token.
60
+ CARACMS_RAILWAY_TOKEN: preserve(),
61
+ // Optional. The form's spam check: the site key is baked in at build, so
62
+ // a change needs a redeploy.
63
+ TURNSTILE_SITE_KEY: preserve(),
64
+ TURNSTILE_SECRET_KEY: preserve(),
65
+ // Optional. Pre-launch password ("user:password"); unset means public.
66
+ CARACMS_PRELAUNCH: preserve(),
67
+ },
68
+ });
69
+
70
+ return project(SITE, {
71
+ resources: [site, kv],
72
+ });
73
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "caracms",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "A visual CMS for Astro sites, with content in git.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -22,12 +22,16 @@ Start from a clean git checkout (untracked files are fine). The order is:
22
22
  (or `pnpm add caracms --save-exact`).
23
23
  2. **Commit the install** (`package.json` and the lockfile). `init --write` refuses while
24
24
  tracked files have uncommitted changes, so that its own change is one diff to review.
25
- 3. **`npx caracms init`**, the dry run (below), and read it.
26
- 4. **`npx caracms init --write`**.
25
+ 3. **`npx caracms init --cloud`** (or `--self-hosted`), the dry run (below), and read it.
26
+ The flag is the site's mode, where its services come from: choosing it is
27
+ `caracms-hosting` → section 1. Always pass it: run without a terminal, `init` won't
28
+ pick a mode, and on a new site it writes nothing and exits 1.
29
+ 4. **`npx caracms init --cloud --write`** (or `--self-hosted --write`).
27
30
  5. **Install again** (`npm install` / `pnpm install`): `init` adds dependencies to
28
31
  `package.json`.
29
32
 
30
- **`npx caracms init`** is a dry run: it writes nothing and prints
33
+ **`npx caracms init --cloud`** (or `--self-hosted`) without `--write` is a dry run: it
34
+ writes nothing and prints
31
35
 
32
36
  - one line per file: `add` (a new file), `edit` (an existing one), `link` / `unlink` (a
33
37
  skill link in `.claude/skills/`);
@@ -49,8 +53,8 @@ adapter in middleware mode; an Astro outside caracms's range, or none; a Tina si
49
53
  port, not `init`; an untracked leftover `tina/__generated__/` is only a note). `init --write` also refuses with uncommitted changes
50
54
  to tracked files: usually the install of step 1, not yet committed.
51
55
 
52
- Read the diff. Then **`npx caracms init --write`**, install again (`npm install` /
53
- `pnpm install`), and commit that as one change.
56
+ Read the diff. Then **`npx caracms init --cloud --write`** (or `--self-hosted --write`),
57
+ install again (`npm install` / `pnpm install`), and commit that as one change.
54
58
 
55
59
  pnpm 12.11 and later may print `Agent skills awaiting approval: caracms` after the install.
56
60
  That's pnpm's own gate: it links the skills a dependency ships into `.claude/skills/` only
@@ -61,7 +65,7 @@ unapproved, and don't approve it in passing (`pnpm approve` also reviews build s
61
65
  `init` added:
62
66
 
63
67
  - `caracms.config.ts`: a `pages` collection at `/` (nested), one `text` block, no text
64
- styles;
68
+ styles, and `services: 'cloud'` (or `'self-hosted'`), the mode;
65
69
  - `src/components/caracms/Page.astro` (the page component) and `TextBlock.astro`;
66
70
  - `src/pages/[...path].astro`, the one catch-all, which serves every document with an
67
71
  address (the pages today; a collection added later needs no route of its own, section
@@ -75,7 +79,7 @@ unapproved, and don't approve it in passing (`pnpm approve` also reviews build s
75
79
  - the skill links and a block in `CLAUDE.md`;
76
80
  - with `--railway` only, `.railway/railway.ts` (when the site had none) and `railway`
77
81
  (dev). Without it, `init` prints the variables any host needs instead. Where and how
78
- the site runs (Railway, Render, Fly, a VPS) is the caracms README's Hosting section.
82
+ the site runs (Railway, Render, Fly, a VPS) is `caracms-hosting` → section 2.
79
83
 
80
84
  Then `astro build`: every existing page must still build. The pages stay prerendered, but
81
85
  the output moves to `dist/client/` and `dist/server/`, and `npm start` is now
@@ -83,8 +87,9 @@ the output moves to `dist/client/` and `dist/server/`, and `npm start` is now
83
87
  (its own built-site checks, a script) needs the new paths: the pages are in
84
88
  `dist/client/`.
85
89
 
86
- Re-run `init` after updating caracms: it re-links the skills and refreshes the CLAUDE.md
87
- block and the schemas. On a set-up site with nothing to do it says
90
+ Re-run `init` after updating caracms: it reads the mode from the config, re-links the
91
+ skills and refreshes the CLAUDE.md block and the schemas. On a set-up site with nothing
92
+ to do it says
88
93
  `caracms init: nothing to do. caracms is set up here.`
89
94
 
90
95
  ## 2. Set up the comparison before changing a page
@@ -107,9 +112,12 @@ are `content/pages/<path>.json` and `content/<collection>/<slug>.json`, the buil
107
112
  `dist/client/`), not what they check. A check that a page's own CSS isn't on another page
108
113
  is the exception (section 4).
109
114
 
110
- **Images need the media variables** in the site's `.env` (never committed):
111
- `CARACMS_MEDIA_URL` (imgproxy's origin), `IMGPROXY_KEY`, `IMGPROXY_SALT` and `S3_BUCKET`
112
- to show images, plus `S3_ENDPOINT`, `S3_REGION`, `S3_ACCESS_KEY_ID` and
115
+ **Images need credentials** in the site's `.env` (never committed). On CaraCMS Cloud
116
+ that's `CARACMS_CLOUD_KEY` alone: a key labelled for this laptop, made in
117
+ app.caracms.com (`caracms-hosting` → section 1); it shows, imports and uploads images,
118
+ and makes `astro dev` live on the site's real repo. Self-hosted, it's the media
119
+ variables: `CARACMS_MEDIA_URL` (imgproxy's origin), `IMGPROXY_KEY`, `IMGPROXY_SALT` and
120
+ `S3_BUCKET` to show images, plus `S3_ENDPOINT`, `S3_REGION`, `S3_ACCESS_KEY_ID` and
113
121
  `S3_SECRET_ACCESS_KEY` to import and upload them. Without them `astro dev` draws a
114
122
  caracms image as nothing, and `astro build` stops with a sentence naming what's missing.
115
123
  If they aren't there, say so; don't invent values.
@@ -333,7 +341,8 @@ editor's uploads are) and prints one JSON array on stdout:
333
341
  - It reads `S3_ENDPOINT`, `S3_REGION`, `S3_BUCKET`, `S3_ACCESS_KEY_ID` and
334
342
  `S3_SECRET_ACCESS_KEY` from the environment, else the site's `.env`. On CaraCMS Cloud it
335
343
  reads `CARACMS_CLOUD_KEY` instead (the same places) and uploads to the site's cloud
336
- folder, with the same output; with both set it refuses.
344
+ folder, with the same output. It goes by the config's `services`: the other mode's
345
+ variables, if set, are ignored with one warning naming them.
337
346
  - The same image again writes nothing and prints its key with `"existed": true`. It never
338
347
  deletes anything.
339
348
  - JPEG, PNG, WebP, AVIF, HEIC/HEIF, GIF and SVG, up to 40 MB and 50 megapixels. If any file
@@ -527,3 +536,11 @@ node .claude/skills/caracms-adopt/look-same.mjs --playwright /tmp/look-same \
527
536
 
528
537
  Work on a branch and open a pull request; never commit to `main` directly, and never
529
538
  touch the `drafts` branch.
539
+
540
+ ## 11. Before the first deploy: `caracms-hosting`
541
+
542
+ The site's hosting is `caracms-hosting`'s, not this skill's. Before the site is first
543
+ deployed with caracms, `init` must have run with the mode (`--cloud` or `--self-hosted`,
544
+ section 1), so `caracms.config.ts` has `services`; then follow `caracms-hosting`: the
545
+ host for the server (section 2), and, for a site that ran self-hosted and is going onto
546
+ the cloud, the move list `init --cloud` prints (section 3).
@@ -0,0 +1,240 @@
1
+ ---
2
+ name: caracms-hosting
3
+ description: Set up where a caracms site runs — choose its mode (CaraCMS Cloud or self-hosted, the `services` field in caracms.config.ts) and run `caracms init --cloud` or `--self-hosted`, put its server on a host (Railway through `init --railway`; Render, Fly or a VPS by hand), move a self-hosted site onto the cloud by following `init`'s move list in order, and read the cloud's refusals (a storage or video limit, the daily email limit, a switched-off team, a revoked key, the GitHub App not installed) with what to do for each. Use before a site's first deploy, when moving a site between modes, when changing its host, or when the cloud says no.
4
+ ---
5
+
6
+ # Where a caracms site runs
7
+
8
+ A site makes three choices, and they're separate:
9
+
10
+ - **Where it starts** (Tina, an existing Astro site, …) decides which skill converts it:
11
+ `caracms-tina`, `caracms-adopt`. Those skills hand over to this one before the first
12
+ deploy.
13
+ - **Its mode**, where its services come from (GitHub access, media, forms and email,
14
+ video): **CaraCMS Cloud** or **self-hosted**. Section 1.
15
+ - **Where its server runs**: a cloud site still runs its own Node server and Redis
16
+ somewhere. Section 2.
17
+
18
+ **The config says the mode; the variables don't.** `services` in `caracms.config.ts`
19
+ (`'cloud'` or `'self-hosted'`, inside `defineSite({ … })`) is the one source of truth.
20
+ Variables from the other mode are leftovers to retire, never a second opinion: with the
21
+ field set, the site ignores them and says so once at build, once at the server's first
22
+ request and once from `media import`:
23
+
24
+ > S3_BUCKET is set, but this site runs on CaraCMS Cloud (caracms.config.ts): it's ignored.
25
+ > Remove it once the old bucket and imgproxy are retired.
26
+
27
+ A config with no `services` field behaves as caracms did before the field existed (the
28
+ key decides, and the key beside the site's own variables is refused). The next `init`
29
+ with a mode adds the field.
30
+
31
+ Detail lives in the caracms README (`node_modules/caracms/README.md`): **Hosting** (what
32
+ every host needs, the variables, each host) and **Hosting → CaraCMS Cloud** (the key,
33
+ images, forms and email, video, `media push`, working locally). This skill is the order
34
+ to do things in; read those sections for the rest, rather than guessing.
35
+
36
+ Commands run from the site's root: `npx caracms …`, or `pnpm caracms …` in a pnpm site.
37
+
38
+ ## 1. Choose the mode and run `init` with it
39
+
40
+ | Mode | The site's server needs | On its host |
41
+ |---|---|---|
42
+ | **CaraCMS Cloud** | `CARACMS_CLOUD_KEY`, plus the site's own `SITE_URL`, `REDIS_URL`, `CARACMS_SECRET`, sign-in (`CARACMS_ALLOW` and/or the backup sign-in), Turnstile | the site and Redis |
43
+ | **Self-hosted** | a GitHub token or App and `GITHUB_REPO`, the media bucket and imgproxy, a submissions bucket, Resend, Mux, plus everything in the row above but the key | the site, Redis, two buckets, imgproxy |
44
+
45
+ The full list, per variable, is the README's *Hosting → The variables*.
46
+
47
+ **CaraCMS Cloud** is for a site CaraCMS runs: it has a team and a site in
48
+ app.caracms.com, its repo linked there, and the caracms GitHub App installed on that
49
+ repo. **Self-hosted** is for a site that keeps its own buckets, imgproxy, Resend and
50
+ GitHub credential. If the brief or the person hasn't said which, ask. Don't pick one
51
+ from what variables happen to be in `.env`.
52
+
53
+ **Always pass the flag.** `init` asks for the mode only when a person is at a terminal;
54
+ run by an agent or a script, it won't pick one. On a new site it writes nothing, exits 1
55
+ and says to run it again with `--cloud` or `--self-hosted`; on a site already set up it
56
+ does everything else, leaves the field out and says "Mode not set: run
57
+ `caracms init --cloud` or `--self-hosted`."
58
+
59
+ ```sh
60
+ npx caracms init --cloud # the dry run: read it
61
+ npx caracms init --cloud --write # writes services: 'cloud' and the rest
62
+ ```
63
+
64
+ (`--self-hosted` the same way. Both flags at once is a problem.)
65
+
66
+ - `init` writes the field as the first property of the `defineSite({` literal, or
67
+ replaces an existing `services` property. If the config's `defineSite` argument isn't
68
+ a literal, it writes nothing and names the line to add by hand: add it, then run `init`
69
+ again.
70
+ - A second `init` reads the mode from the config and asks nothing; it re-links the
71
+ skills and refreshes the CLAUDE.md block (whose first line says where the site runs)
72
+ and the schemas. Run it after every caracms update.
73
+ - **A flag that contradicts the field is a move**, not an error: `--cloud` on a
74
+ self-hosted site prints the move list (section 3); `--self-hosted` on a cloud site
75
+ prints the way back (section 3, the end).
76
+ - **`init --cloud` checks the key** when it finds one (the environment, then `.env`): it
77
+ shows the site and team the key belongs to, the linked repo, and whether the App is
78
+ installed on it, or the cloud's own sentence for a refused key (section 4). No key: it
79
+ says where to make one and carries on. **Never print, paste or commit a key.**
80
+
81
+ **One key per place the site runs**, each labelled in app.caracms.com: "Railway" for
82
+ the host, "Rich's laptop" for a laptop's `.env`, "staging" for a staging deploy.
83
+ Revoking one never touches the others. Leave `CARACMS_CLOUD_URL` unset (it's only for
84
+ working on CaraCMS Cloud itself). On a laptop a cloud key makes `astro dev` live on the
85
+ site's real repo and folder; without one, `astro dev` edits content on disk as always.
86
+
87
+ **A production build needs the key.** With `services: 'cloud'` and no
88
+ `CARACMS_CLOUD_KEY`, `astro build` (and `pnpm build` on a laptop, which is a production
89
+ build) fails, and a production server's `/admin` answers 503, with:
90
+
91
+ > This site runs on CaraCMS Cloud (caracms.config.ts), but CARACMS_CLOUD_KEY isn't set.
92
+ > Make a key for it in app.caracms.com.
93
+
94
+ It never falls back to self-hosted variables that happen to be set.
95
+
96
+ ## 2. Where the server runs
97
+
98
+ Every host needs what the README's *Hosting* opens with: one instance, HTTPS, a deploy
99
+ whenever `main` changes, `SITE_URL` the same at build and run time, and the server
100
+ started with `caracms start`. The mode only changes which variables go on it (section 1).
101
+ Off Railway, Fly and Render, `CARACMS_CLIENT_IP_HEADER` is required too (*Hosting → The
102
+ visitor's IP*).
103
+
104
+ Each host below is its own section. **A new host is a new section here**, written the
105
+ same way: how it's set up, the variables per mode, anything it does differently. The
106
+ rest of this skill doesn't change for it.
107
+
108
+ ### Railway
109
+
110
+ **Set up by `init`.** `init --railway` writes `.railway/railway.ts`, the file that
111
+ describes the whole Railway project, when the site has none:
112
+
113
+ - `--cloud --railway`: the site and Redis, nothing else.
114
+ - `--self-hosted --railway`: the site, Redis, the media and submissions buckets, and
115
+ imgproxy.
116
+
117
+ Then the README's *Hosting → Railway* (the IP header and commit come by default; "Try
118
+ again" uses `CARACMS_RAILWAY_TOKEN`). Set `CARACMS_CLOUD_KEY` (the "Railway" key) on the
119
+ site's service on a cloud site.
120
+
121
+ **`init` never rewrites an existing `.railway/railway.ts`.** `railway config apply`
122
+ makes the project match the file, deleting what the file no longer declares. So:
123
+
124
+ - **Never run `railway config apply` on a file cut down to the site and Redis before
125
+ `caracms media push` has finished** (section 3, step 3): it would delete the media
126
+ bucket the images are still being copied from.
127
+ - Never apply a file you haven't read against the environment it would change. A
128
+ production file a template wrote (a Tina template site's) can switch its CDN off; set
129
+ production's variables with `railway variables --set` instead.
130
+ - Before any apply, `railway config plan` shows what it would create, change or delete.
131
+ Read it.
132
+
133
+ ### Render
134
+
135
+ By hand, from the README's *Hosting → Render*: one instance (no autoscaling), the IP
136
+ default to check, the commit Render sets. Put the mode's variables (section 1) on the
137
+ web service, Redis beside it (*Hosting → Redis*). A self-hosted site also needs its two
138
+ buckets and an imgproxy somewhere the server can reach.
139
+
140
+ ### Fly
141
+
142
+ By hand, from the README's *Hosting → Fly*: one machine, a GitHub Action to deploy on a
143
+ push to `main`, the IP header. The mode's variables as Fly secrets; Redis (Upstash works,
144
+ in the site's region). A self-hosted site needs its buckets and imgproxy elsewhere.
145
+
146
+ ### A VPS
147
+
148
+ By hand, from the README's *Hosting → A VPS, behind Caddy or nginx*: the proxy,
149
+ `CARACMS_CLIENT_IP_HEADER` set to match it, a deploy on a push to `main`, Redis. The
150
+ mode's variables in the server's environment.
151
+
152
+ ## 3. The move: self-hosted to CaraCMS Cloud
153
+
154
+ **Follow the list `init --cloud` prints, in its order.** On a site that's self-hosted
155
+ (the field says so, or there's none and the site has a `.railway/railway.ts` with
156
+ buckets or imgproxy, or media, video, forms or GitHub variables in `.env`),
157
+ `npx caracms init --cloud --write` writes `services: 'cloud'` and prints the steps to
158
+ finish the move, each with the check that says it worked. Only steps that apply are
159
+ printed. Don't reorder them, skip a check, or fit a list of your own to the site.
160
+
161
+ **`init` only sees the repo.** The list names every variable the cloud replaces and marks
162
+ the ones it found in `.env` or `railway.ts`; the host may have more. Open the host's
163
+ variables page and check it against the full list.
164
+
165
+ The replaced variables:
166
+
167
+ - media: `S3_ENDPOINT`, `S3_REGION`, `S3_BUCKET`, `S3_ACCESS_KEY_ID`,
168
+ `S3_SECRET_ACCESS_KEY`, `IMGPROXY_KEY`, `IMGPROXY_SALT`, `CARACMS_MEDIA_URL`
169
+ - video: `MUX_TOKEN_ID`, `MUX_TOKEN_SECRET`
170
+ - forms and email: `SUBMISSIONS_S3_BUCKET`, `SUBMISSIONS_S3_ACCESS_KEY_ID`,
171
+ `SUBMISSIONS_S3_SECRET_ACCESS_KEY`, `RESEND_API_KEY`, `FORMS_FROM`
172
+ - GitHub: `CARACMS_GITHUB_TOKEN`, `GITHUB_APP_ID`, `GITHUB_APP_PRIVATE_KEY`, `GITHUB_REPO`
173
+
174
+ (`GITHUB_WEBHOOK_SECRET` isn't on it: the cloud has no webhook, so it's unused and
175
+ harmless.)
176
+
177
+ The list's steps, for orientation (what `init` prints is the one to follow):
178
+
179
+ 1. The site and a key in app.caracms.com, the App on the repo, then open the site's page
180
+ in app.caracms.com (the cloud refreshes the install state only when that page opens, a
181
+ repo is linked or the site mints a token). *Check:* `init --cloud` again shows "App
182
+ installed".
183
+ 2. Commit and push the field. **The host's build then fails with "CARACMS_CLOUD_KEY isn't
184
+ set" until step 4.** That's expected: the old deployment keeps serving. Don't "fix"
185
+ it by reverting the field.
186
+ 3. Copy the images: `caracms media push`, with the old bucket's `S3_*` on its command line
187
+ (README: *Moving a site's images in*). *Check:* `failed: 0`, and a second run skips
188
+ everything. It stops at the storage limit with the cloud's sentence (section 4).
189
+ 4. Set `CARACMS_CLOUD_KEY` on the host and **leave the old variables in place** (they're
190
+ ignored). *Check:* the deploy's logs name them as ignored; the editor signs in; an
191
+ upload lands in the cloud.
192
+ 5. Video, if `MUX_TOKEN_*` was found: each clip uploaded again in the editor and placed
193
+ where the old one was (no copy command; old clips keep playing until replaced).
194
+ *Check:* no page plays from the old account.
195
+ 6. Remove the replaced variables from the host, one by one. *Check:* the next deploy logs
196
+ no "ignored" lines.
197
+ 7. Retire, **only once 4–6 hold**: imgproxy, the media bucket, the GitHub token or App on
198
+ this repo, Resend. **Keep the submissions bucket** until its old submissions are
199
+ exported or no longer wanted (the cloud doesn't copy them, and its inbox doesn't show
200
+ them), and **keep the old Mux account** until step 5 is finished.
201
+ 8. Cut `.railway/railway.ts` down to the site and Redis (what `--cloud --railway` writes,
202
+ keeping the site's own names). *Check:* `railway config plan` shows nothing to create
203
+ or change.
204
+
205
+ **Retiring can't be undone by git.** Deleting a bucket, a service or an account is the
206
+ person's call: say what's ready to go and let them do it, or do it when they say so.
207
+ Everything before step 7 can be reverted with git and the host's variables.
208
+
209
+ **The way back** (cloud to self-hosted): `npx caracms init --self-hosted --write` writes
210
+ the field and prints the README's leaving notes: copy the site's cloud folder to a bucket
211
+ of your own (the keys in content stay the same), set the self-hosted variables, and stand
212
+ up what the mode needs on the host (section 2).
213
+
214
+ ## 4. When the cloud says no
215
+
216
+ The cloud refuses in its own sentence, and caracms shows that sentence as it is: in the
217
+ build log, on `/admin` (a 503, with the public site still serving), in the editor while
218
+ it's open, in `media push` / `media import` output, or on a submission in the inbox.
219
+ **Never answer a refusal by setting the site's own variables:** with
220
+ `services: 'cloud'` they're ignored.
221
+
222
+ Who's who: **CaraCMS** runs the cloud (staff, in admin.caracms.com); the **site's team**
223
+ owns the site in app.caracms.com (its keys, its repo). Limits and switching a team on
224
+ are CaraCMS's; keys and the repo are the team's.
225
+
226
+ | The sentence (its code) | What to do |
227
+ |---|---|
228
+ | "This site has reached its storage limit in CaraCMS Cloud (…). Ask CaraCMS to raise it." (`upload_limit`) | Uploads and `media push` stop there. Tell the person: CaraCMS raises the limit. Then run `media push` again (it skips what's copied). Don't delete images to make room. |
229
+ | "This site has reached its video limit in CaraCMS Cloud (…). Ask CaraCMS to raise it." (`video_limit`) | New clips are refused; existing ones play. CaraCMS raises it. A clip counts once Mux has finished it, so one upload can go past the limit. |
230
+ | "Not emailed: this site reached its daily email limit in CaraCMS Cloud (…). Ask CaraCMS to raise it." (on a submission in the inbox) | The submission is stored and readable in the inbox; its email is never sent later. The limit is 50 a day (UTC) unless CaraCMS has set another, and CaraCMS can raise it. For a test, a laptop never emails (submissions go to `.caracms/submissions/`, emails to the log). |
231
+ | "This site has sent the most sign-in emails CaraCMS Cloud sends in a day. Try again tomorrow." (`sign_in_limit`) | Sign-in links stop until the next day. The backup sign-in (`CARACMS_BACKUP_USER` / `_PASSWORD`) still works, if it's set. |
232
+ | "This site's team is switched off. Ask CaraCMS to switch it back on." (`team_off`) | Builds fail, `/admin` answers 503, the running site's public pages keep serving. Only CaraCMS can switch it back on; tell the person. |
233
+ | "That site key was revoked. Make a new one in app.caracms.com." (`revoked_key`) | Make a new labelled key for that place (host, laptop, staging), set it where the old one was (the host's variable or `.env`), redeploy or restart. Don't reuse another place's key. |
234
+ | "That site key isn't one we know." (`unknown_key`) | A mistyped or truncated key, a key for a deleted site, or `CARACMS_CLOUD_URL` pointing at another cloud. Check the variable's value has no stray quotes or spaces, unset `CARACMS_CLOUD_URL`, else make a new key. |
235
+ | "The caracms GitHub App isn't installed on <repo>, or that repo doesn't exist. Install the App on it: <link>" (`app_not_installed`) | Install the App from the link on that repo only. The cloud refreshes its record of the install only when the site's page in app.caracms.com opens (or a repo is linked, or the site mints a token), so open the site's page there, then run `init --cloud` again: it shows "App installed". The next visit to `/admin` tries again. If it is installed, the repo linked in app.caracms.com is the wrong one: the team fixes it there (a changed repo reaches the site at its next start). |
236
+ | "This site has no GitHub repo in CaraCMS Cloud yet. Ask CaraCMS to set one." (`no_repo`; what `/admin` shows. `POST /v1/github/token` answers it as "A team owner can pick one in app.caracms.com.") | A team owner links the repo in app.caracms.com, then the App goes on it. The site still builds meanwhile. |
237
+ | "CaraCMS Cloud couldn't be reached, so the site can't be built. Try again in a minute." (`unreachable`) | Retry the build. A running site keeps serving. If it persists, tell the person; don't switch the mode. |
238
+ | "This site runs on CaraCMS Cloud (caracms.config.ts), but CARACMS_CLOUD_KEY isn't set. Make a key for it in app.caracms.com." | Not the cloud refusing: the key is missing where the site is built or runs (section 1). Expected during the move's steps 2–3. |
239
+
240
+ The README's *Hosting → CaraCMS Cloud* has each of these in context.
@@ -21,6 +21,8 @@ Read with this skill:
21
21
  preview (section 8). This skill is the Tina-specific order; adopt's page-by-page
22
22
  method doesn't apply, since the script converts every page at once.
23
23
  - `caracms-content`: the content files the script writes.
24
+ - `caracms-hosting`: the mode `init` is run with, the host, and (on CaraCMS Cloud) the
25
+ move list to follow at the switch. Hosting is that skill's, not this one's.
24
26
  - Beside this file: **`tina-to-caracms.mjs`**, the reference script (its header is its
25
27
  manual), and **`field-kinds.md`**, the mapping table: how each Tina field kind becomes a
26
28
  caracms field and value.
@@ -64,9 +66,13 @@ Work on a branch (`caracms-port`, say) in a worktree of the site. Commit after e
64
66
  3. **Delete `content/`** (git keeps it). `init` refuses a `content/` that isn't caracms's.
65
67
  4. **Switch the adapter to standalone:** `@astrojs/node` with `mode: 'standalone'`.
66
68
  `caracms start` serves `dist/server/entry.mjs` and refuses any other mode.
67
- 5. **`npx caracms init`**, read the dry run, then **`npx caracms init --write`** and install
68
- again (caracms-adopt → section 1). It links the four skills, writes the config, the
69
- CLAUDE.md block and the health route. **A template site already has a
69
+ 5. **`npx caracms init --cloud`** (or `--self-hosted`), read the dry run, then
70
+ **`npx caracms init --cloud --write`** (or `--self-hosted --write`) and install again
71
+ (caracms-adopt → section 1). The flag is the site's mode: choosing it is
72
+ `caracms-hosting` → section 1, and it's always passed (without a terminal `init`
73
+ won't pick one). It links the skills, writes the config (with `services`), the
74
+ CLAUDE.md block and the health route. On a site with a template's `railway.ts`,
75
+ `--cloud` also prints the move list: hold it for the switch (section 9). **A template site already has a
70
76
  `.railway/railway.ts` that describes production: leave it alone and never apply it**
71
77
  (applying it can switch production's CDN off). `init` treats a site that has one as
72
78
  a Railway site and leaves the file untouched, which is the expected outcome. Set the
@@ -366,16 +372,30 @@ in the editor and in `validate`, naming the page.
366
372
  ## 7. Images
367
373
 
368
374
  - **Keep the bucket keys.** The template's images are imgproxy URLs over the media bucket
369
- (`…/unsafe/plain/s3://<bucket>/uploads/…`); the script keeps each `uploads/…` key, so no
370
- image is copied and the bucket only grows.
375
+ (`…/unsafe/plain/s3://<bucket>/uploads/…`); the script keeps each `uploads/…` key, so the
376
+ content never needs rewriting for where the images live, and the bucket only grows.
371
377
  - **`w`/`h` are found once** and cached in `scripts/tina-media-sizes.json` (commit it). The
372
378
  first run needs the images reachable (their Tina URLs) or the bucket (`S3_*`); after
373
379
  that the cache answers. `validate` warns on any image still missing its size.
374
- - **imgproxy:** caracms signs every URL (`CARACMS_MEDIA_URL`, `IMGPROXY_KEY`,
380
+ Where the images are served from then depends on the mode (`services`, section 1 step 5):
381
+
382
+ - **On CaraCMS Cloud:** the keys are kept and `caracms media push` copies them, under the
383
+ same `uploads/…` keys, from the template's bucket into the site's cloud folder (README:
384
+ *Moving a site's images in*; `caracms-hosting` → section 3, step 3). The content doesn't
385
+ change, and the cloud serves them signed from `img.caracms.cloud`, so **no signed
386
+ imgproxy goes up beside the old one**. Run the push with the bucket's `S3_*` on its
387
+ command line before the branch environment's first build that shows images, and again
388
+ before the switch for anything uploaded in Tina since (it skips what's copied).
389
+ `CARACMS_CLOUD_KEY` alone shows images (a key per place: the laptop, the branch
390
+ environment). The template's own imgproxy keeps serving Tina's site untouched until
391
+ the switch.
392
+ - **Self-hosted:** caracms signs every URL (`CARACMS_MEDIA_URL`, `IMGPROXY_KEY`,
375
393
  `IMGPROXY_SALT`, `S3_BUCKET`). The template's production imgproxy is unsigned and its
376
394
  URLs are cached by the CDN, so **never give it a key while Tina runs**: stand up a new,
377
- signed imgproxy beside it, reading the same bucket. Without the media variables the
378
- preview draws no images, and a build stops with a sentence naming them.
395
+ signed imgproxy beside it, reading the same bucket.
396
+
397
+ In both, without the credentials (the key, or the media variables) the preview draws no
398
+ images, and a build stops with a sentence naming what's missing.
379
399
 
380
400
  ## 8. Forms
381
401
 
@@ -413,19 +433,26 @@ The port is deployed from its branch to its own environment, **never production*
413
433
  template sites run on Railway, so this section is written for it. A site on another host
414
434
  needs the same pieces there (the caracms README's Hosting section has each host):
415
435
 
416
- - A new environment in the site's Railway project (`caracms`), with its own `web`, Redis, a
417
- signed imgproxy and its own submissions bucket. It reads the live media bucket (caracms
418
- only ever adds to a bucket). Its variables copy production's where they carry over
419
- (`RESEND_API_KEY`, `FORMS_FROM`, the site's `MUX_TOKEN_*`, `CARACMS_ALLOW` from the
420
- template's admin allow list), and it deploys from the port branch.
436
+ - A new environment in the site's Railway project (`caracms`), with its own `web` and
437
+ Redis, deploying from the port branch, and `CARACMS_ALLOW` from the template's admin
438
+ allow list. Then, by mode:
439
+ - **On CaraCMS Cloud:** a key of its own, labelled for the environment in
440
+ app.caracms.com, as `CARACMS_CLOUD_KEY`; no imgproxy, buckets, Resend, Mux or GitHub
441
+ variables (the key replaces them). Its images are the ones `media push` copied
442
+ (section 7).
443
+ - **Self-hosted:** a signed imgproxy and its own submissions bucket. It reads the live
444
+ media bucket (caracms only ever adds to a bucket). Its variables copy production's
445
+ where they carry over (`RESEND_API_KEY`, `FORMS_FROM`, the site's `MUX_TOKEN_*`).
421
446
  - **`CARACMS_MAIN_BRANCH` and `CARACMS_DRAFTS_BRANCH`** name caracms's two branches
422
447
  (default `main` and `drafts`). Set them to the port branch and its own drafts branch
423
448
  (`caracms-port`, `caracms-drafts`) **before anyone signs in**: a Publish there then
424
449
  commits to the port branch and redeploys the environment, and the live site's `main`
425
450
  never moves. With the defaults, the first Publish would land caracms's content on the
426
451
  live site's `main`.
427
- - A GitHub credential on the environment, as for any caracms site: a fine-grained token
428
- on the site's repo, or the App (the caracms README → Hosting → GitHub).
452
+ - Self-hosted, a GitHub credential on the environment, as for any caracms site: a
453
+ fine-grained token on the site's repo, or the App (the caracms README → Hosting →
454
+ GitHub). On the cloud, the caracms App on the repo covers it (`caracms-hosting` →
455
+ section 1).
429
456
  - Convert with `--recipients <agency address>`, so test submissions never reach the client.
430
457
  - **Never `railway config apply` against production**: production's variables are set
431
458
  with `railway variables --set` (a template's Railway file can switch its CDN off).
@@ -433,6 +460,9 @@ needs the same pieces there (the caracms README's Hosting section has each host)
433
460
  **The switch** (its own task, later): Tina is frozen, the script is re-run on the latest
434
461
  `main` without `--recipients`, the branch is merged, production's variables move to
435
462
  caracms's (the branch variables unset), and Tina's imgproxy and variables retire.
463
+ Production's hosting is `caracms-hosting`'s: before the merge deploys, follow it (the
464
+ host, section 2; on the cloud, the move list `init --cloud` printed, in its order,
465
+ retiring nothing until its checks hold, section 3).
436
466
 
437
467
  ## 10. Before calling it done
438
468
 
@@ -17,10 +17,15 @@
17
17
  * package's own. The build ignores `github.repo`: a site with no repo linked still builds.
18
18
  * The key beside the site's own forms and email variables fails it too (caracms-cloud M4
19
19
  * Decision 22), whether or not the media is the cloud's.
20
+ *
21
+ * With the config's `services` (caracms-cloud M5): `'self-hosted'` never fetches (the key is
22
+ * ignored); `'cloud'` without the key fails the build with Decision 4's sentence. The build
23
+ * names the ignored variables once, here; the server, at its first request (`cloudMedia`).
20
24
  */
21
25
  import type { MiddlewareHandler } from 'astro';
22
26
  import { CLOUD_NO_MEDIA, CLOUD_UNREACHABLE_BUILD, isCloudRefusal, heldCloudSettings, loadCloudSettings } from './live/cloud.ts';
23
27
  import { cloudFormsConflict } from './live/mode.ts';
28
+ import { CLOUD_KEY_MISSING_PROBLEM, servicesEnv, type Services } from './live/services.ts';
24
29
  import { cloudMedia } from './media.ts';
25
30
 
26
31
  type Env = Record<string, string | undefined>;
@@ -63,12 +68,17 @@ export const onRequest: MiddlewareHandler = async (_context, next) => {
63
68
  return next();
64
69
  };
65
70
 
66
- /** The build's start: the settings, or an Error with the sentence that fails the build. Nothing for a site off the cloud. */
67
- export async function cloudSettingsForBuild(env: Env = process.env): Promise<void> {
71
+ /**
72
+ * The build's start: the settings, or an Error with the sentence that fails the build. Nothing for a site off the cloud.
73
+ * `services` is the config's (the integration passes it: this runs outside the bundle, where the build's defines don't reach).
74
+ */
75
+ export async function cloudSettingsForBuild(env: Env = process.env, services: Services | null = null): Promise<void> {
76
+ env = servicesEnv(env, services);
77
+ if (services === 'cloud' && !env.CARACMS_CLOUD_KEY?.trim()) throw new Error(`caracms: ${CLOUD_KEY_MISSING_PROBLEM}`);
68
78
  // caracms-cloud M4 Decision 22: the key with the site's own forms and email variables fails the build too.
69
79
  const conflict = cloudFormsConflict(env);
70
80
  const forms = conflict ? [conflict] : [];
71
- const cloud = cloudMedia(env);
81
+ const cloud = cloudMedia(env, services);
72
82
  if (!cloud) {
73
83
  if (forms.length) throw new Error(`caracms: ${forms.join(' ')}`);
74
84
  return;
package/src/config.ts CHANGED
@@ -253,8 +253,19 @@ export interface SiteConfig {
253
253
  * whose pages all show the site's own title.
254
254
  */
255
255
  titleFallback?: TitleFallback;
256
+ /**
257
+ * Where the site's services come from (caracms-cloud M5 Decision 1): GitHub access,
258
+ * media, forms and email, video. `'cloud'`: CaraCMS Cloud, through `CARACMS_CLOUD_KEY`.
259
+ * `'self-hosted'`: the site's own variables. The other mode's variables are ignored,
260
+ * with a warning (live/services.ts). Unset, the key decides, as before M5 (Decision 3).
261
+ * `caracms init --cloud` or `--self-hosted` writes it.
262
+ */
263
+ services?: Services;
256
264
  }
257
265
 
266
+ /** The config's `services` values (live/services.ts). */
267
+ export type Services = 'cloud' | 'self-hosted';
268
+
258
269
  /** The config's `titleFallback` values (head.ts). */
259
270
  export type TitleFallback = 'page-and-brand' | 'site';
260
271
 
@@ -366,6 +377,8 @@ export interface ResolvedSite {
366
377
  titleSeparator: string;
367
378
  /** What a page without its own `seo.title` falls back to (`'page-and-brand'` when the config sets none). */
368
379
  titleFallback: TitleFallback;
380
+ /** Where the site's services come from (caracms-cloud M5 Decision 1); null when the config doesn't say. */
381
+ services: Services | null;
369
382
  /**
370
383
  * M8 Decision 10: the URLs the site's own `src/pages/` serves (code-routes.ts), read by the
371
384
  * integration at setup (and by the commands). Absent before then: nothing is reserved.
@@ -628,7 +641,13 @@ export function resolveSite(config: SiteConfig, root: string): ResolvedSite {
628
641
  fail(`titleFallback must be 'page-and-brand' (the page's title and the site's name) or 'site' (Site Settings' title on every page) (got ${JSON.stringify(config.titleFallback)}).`);
629
642
  }
630
643
 
631
- return { root, collections, blocks, textStyles, page: abs(config.page), brand, sharedFolder, phoneLinesWidth, repoPath: repoPath!, timeZone, forms, titleSeparator, titleFallback };
644
+ // caracms-cloud M5 Decision 1: where the site's services come from.
645
+ const services = config.services ?? null;
646
+ if (services !== null && services !== 'cloud' && services !== 'self-hosted') {
647
+ fail(`services must be 'cloud' (CaraCMS Cloud) or 'self-hosted' (the site's own GitHub access, bucket, imgproxy and so on) (got ${JSON.stringify(config.services)}).`);
648
+ }
649
+
650
+ return { root, collections, blocks, textStyles, page: abs(config.page), brand, sharedFolder, phoneLinesWidth, repoPath: repoPath!, timeZone, forms, titleSeparator, titleFallback, services };
632
651
  }
633
652
 
634
653
  interface FieldCheck {