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.
- package/README.md +85 -17
- package/bin/caracms.mjs +11 -3
- package/bin/init.mjs +332 -34
- package/bin/media.mjs +60 -6
- package/bin/templates/railway-cloud.ts.txt +73 -0
- package/package.json +1 -1
- package/skills/caracms-adopt/SKILL.md +30 -13
- package/skills/caracms-hosting/SKILL.md +240 -0
- package/skills/caracms-tina/SKILL.md +45 -15
- package/src/cloud-middleware.ts +13 -3
- package/src/config.ts +20 -1
- package/src/editor/server/media/config.ts +12 -5
- package/src/editor/server/mode.ts +3 -1
- package/src/email.ts +4 -1
- package/src/forms/store.ts +7 -2
- package/src/index.ts +3 -1
- package/src/live/mode.ts +19 -8
- package/src/live/services.ts +114 -0
- package/src/media.ts +9 -3
|
@@ -16,9 +16,12 @@
|
|
|
16
16
|
* - **And the clips' (caracms-cloud M4.5 Decision 15)**: with the key, clips go through the
|
|
17
17
|
* cloud's Mux (cloud-mux.ts), and the site's own `MUX_TOKEN_ID` / `MUX_TOKEN_SECRET`
|
|
18
18
|
* beside it is a problem sentence naming them (one credential, not two).
|
|
19
|
+
* - **With the config's `services`** (caracms-cloud M5 Decision 2) the other mode's variables
|
|
20
|
+
* are never read (live/services.ts), and a cloud site without its key has neither.
|
|
19
21
|
*/
|
|
20
22
|
import type { CloudConnection } from '../../../live/cloud.ts';
|
|
21
23
|
import { CLOUD_AND_MUX_PROBLEM, OWN_MUX_VARIABLES, readCloudUrl } from '../../../live/mode.ts';
|
|
24
|
+
import { bakedServices, CLOUD_KEY_MISSING_PROBLEM, servicesEnv, type Services } from '../../../live/services.ts';
|
|
22
25
|
import { cloudMedia, isProductionBuild, readMediaConfig, type MediaSettings } from '../../../media.ts';
|
|
23
26
|
|
|
24
27
|
type Env = Record<string, string | undefined>;
|
|
@@ -53,15 +56,17 @@ const get = (env: Env, name: string) => {
|
|
|
53
56
|
const memory = (env: Env) => get(env, 'CARACMS_MEDIA') === 'memory';
|
|
54
57
|
|
|
55
58
|
/** The image routes' settings: the bucket, and imgproxy's for the thumbnails. */
|
|
56
|
-
export function readImagesConfig(env: Env = process.env, production = isProductionBuild()): ImagesConfig {
|
|
59
|
+
export function readImagesConfig(env: Env = process.env, production = isProductionBuild(), services: Services | null = bakedServices()): ImagesConfig {
|
|
60
|
+
env = servicesEnv(env, services);
|
|
57
61
|
if (memory(env)) {
|
|
58
62
|
if (production) return { kind: 'unset', message: MEMORY_IN_PRODUCTION, missing: [] };
|
|
59
|
-
const signing = readMediaConfig(env, production);
|
|
63
|
+
const signing = readMediaConfig(env, production, services);
|
|
60
64
|
if (signing.kind !== 'ready') return { kind: 'unset', message: MEMORY_IN_PRODUCTION, missing: [] };
|
|
61
65
|
return { kind: 'memory', signing: signing.settings };
|
|
62
66
|
}
|
|
63
|
-
|
|
64
|
-
const
|
|
67
|
+
if (services === 'cloud' && !get(env, 'CARACMS_CLOUD_KEY')) return { kind: 'unset', message: CLOUD_KEY_MISSING_PROBLEM, missing: [] };
|
|
68
|
+
const signing = readMediaConfig(env, production, services);
|
|
69
|
+
const cloud = cloudMedia(env, services);
|
|
65
70
|
if (cloud) {
|
|
66
71
|
if (signing.kind === 'ready') return { kind: 'cloud', signing: signing.settings, connection: cloud.connection };
|
|
67
72
|
return { kind: 'unset', message: signing.problems.join(' '), missing: [] };
|
|
@@ -86,9 +91,11 @@ export function readImagesConfig(env: Env = process.env, production = isProducti
|
|
|
86
91
|
}
|
|
87
92
|
|
|
88
93
|
/** The clip routes' settings: the cloud's Mux (the site key), or the site's Mux environment's token. */
|
|
89
|
-
export function readMuxConfig(env: Env = process.env, production = isProductionBuild()): MuxConfig {
|
|
94
|
+
export function readMuxConfig(env: Env = process.env, production = isProductionBuild(), services: Services | null = bakedServices()): MuxConfig {
|
|
95
|
+
env = servicesEnv(env, services);
|
|
90
96
|
if (memory(env)) return production ? { kind: 'unset', message: MEMORY_IN_PRODUCTION, missing: [] } : { kind: 'memory' };
|
|
91
97
|
const key = get(env, 'CARACMS_CLOUD_KEY');
|
|
98
|
+
if (services === 'cloud' && !key) return { kind: 'unset', message: CLOUD_KEY_MISSING_PROBLEM, missing: [] };
|
|
92
99
|
if (key) {
|
|
93
100
|
const problems: string[] = [];
|
|
94
101
|
const both = OWN_MUX_VARIABLES.filter((name) => get(env, name));
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
import type { ResolvedSite } from '../../config.ts';
|
|
16
16
|
import { liveEditor, type LiveEditor } from '../../live/editor.ts';
|
|
17
17
|
import { readMode, unconfiguredMessage, type Baked, type Mode } from '../../live/mode.ts';
|
|
18
|
+
import { bakedServices } from '../../live/services.ts';
|
|
18
19
|
import { localDraftsStore, type DraftsStore } from './drafts-store.ts';
|
|
19
20
|
|
|
20
21
|
declare const __CARACMS_PRODUCTION__: boolean | undefined;
|
|
@@ -26,11 +27,12 @@ export function isProductionServer(): boolean {
|
|
|
26
27
|
return typeof __CARACMS_PRODUCTION__ === 'boolean' ? __CARACMS_PRODUCTION__ : false;
|
|
27
28
|
}
|
|
28
29
|
|
|
29
|
-
/** The site's address and the
|
|
30
|
+
/** The site's address, the commit and the config's `services` the build baked in (runs-anywhere Decisions 7 and 9, caracms-cloud M5 Decision 1); empty in plain Node (tests). */
|
|
30
31
|
export function bakedAtBuild(): Baked {
|
|
31
32
|
return {
|
|
32
33
|
site: typeof __CARACMS_SITE__ === 'string' ? __CARACMS_SITE__ : null,
|
|
33
34
|
commit: typeof __CARACMS_BUILD_COMMIT__ === 'string' ? __CARACMS_BUILD_COMMIT__ : null,
|
|
35
|
+
services: bakedServices(),
|
|
34
36
|
};
|
|
35
37
|
}
|
|
36
38
|
|
package/src/email.ts
CHANGED
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
* reaches anyone's inbox. It never throws: an unset key or sender, Resend's refusal
|
|
11
11
|
* or a timeout all come back as a result with a sentence.
|
|
12
12
|
*/
|
|
13
|
+
import { bakedServices, servicesEnv } from './live/services.ts';
|
|
14
|
+
|
|
13
15
|
export const RESEND_URL = 'https://api.resend.com/emails';
|
|
14
16
|
export const SEND_TIMEOUT_MS = 10_000;
|
|
15
17
|
|
|
@@ -30,7 +32,8 @@ export async function sendEmail(
|
|
|
30
32
|
email: Email,
|
|
31
33
|
options: { env?: Record<string, string | undefined>; fetch?: typeof fetch; timeoutMs?: number } = {},
|
|
32
34
|
): Promise<SendResult> {
|
|
33
|
-
|
|
35
|
+
// caracms-cloud M5 Decision 2: on a cloud site RESEND_API_KEY and FORMS_FROM are never read (the cloud sends).
|
|
36
|
+
const env = servicesEnv(options.env ?? process.env, bakedServices());
|
|
34
37
|
const timeoutMs = options.timeoutMs ?? SEND_TIMEOUT_MS;
|
|
35
38
|
const apiKey = (env['RESEND_API_KEY'] ?? '').trim();
|
|
36
39
|
const from = (env['FORMS_FROM'] ?? '').trim();
|
package/src/forms/store.ts
CHANGED
|
@@ -36,6 +36,7 @@ import { mkdir, readdir, readFile, rmdir, unlink, writeFile } from 'node:fs/prom
|
|
|
36
36
|
import { dirname, join, resolve, sep } from 'node:path';
|
|
37
37
|
import { AwsClient } from 'aws4fetch';
|
|
38
38
|
import { cloudForms } from '../live/mode.ts';
|
|
39
|
+
import { bakedServices, CLOUD_KEY_MISSING_PROBLEM, servicesEnv, type Services } from '../live/services.ts';
|
|
39
40
|
import { cloudStore } from './cloud-store.ts';
|
|
40
41
|
import { isSubmissionId, shownFileName } from './rules.ts';
|
|
41
42
|
|
|
@@ -459,17 +460,21 @@ const list = (names: readonly string[]) => (names.length <= 1 ? names.join('') :
|
|
|
459
460
|
export const isProductionEnv = (env: Env): boolean => env['NODE_ENV'] === 'production';
|
|
460
461
|
|
|
461
462
|
/** Decision 7's rule, from the environment. Read on each call (cheap). */
|
|
462
|
-
export function readSubmissionsConfig(root: string, env: Env = process.env, production = isProductionEnv(env)): SubmissionsConfig {
|
|
463
|
+
export function readSubmissionsConfig(root: string, env: Env = process.env, production = isProductionEnv(env), services: Services | null = bakedServices()): SubmissionsConfig {
|
|
464
|
+
// caracms-cloud M5 Decision 2: with the config's `services`, the other mode's variables are never read.
|
|
465
|
+
env = servicesEnv(env, services);
|
|
463
466
|
const get = (name: string) => env[name]?.trim() || '';
|
|
464
467
|
const mode = get('CARACMS_SUBMISSIONS');
|
|
465
468
|
if (mode === 'memory') return production ? { kind: 'unset', message: SUBMISSIONS_MEMORY_IN_PRODUCTION, missing: [] } : { kind: 'memory' };
|
|
466
469
|
if (mode) return { kind: 'unset', message: `CARACMS_SUBMISSIONS must be "memory" or unset (got "${mode}").`, missing: [] };
|
|
467
470
|
// The key with the site's own variables is a mistake everywhere (M4 Decision 22), as the media's is.
|
|
468
|
-
const cloud = cloudForms(env);
|
|
471
|
+
const cloud = cloudForms(env, services);
|
|
469
472
|
if (cloud?.problems.length) return { kind: 'unset', message: cloud.problems.join(' '), missing: [] };
|
|
470
473
|
// Outside production: the folder, even with the cloud key (M4's Core framing).
|
|
471
474
|
if (!production) return { kind: 'folder', dir: join(root, LOCAL_SUBMISSIONS_FOLDER) };
|
|
472
475
|
if (cloud) return { kind: 'cloud', connection: cloud.connection };
|
|
476
|
+
// M5 Decision 4: a cloud site without its key, never the site's own bucket instead.
|
|
477
|
+
if (services === 'cloud') return { kind: 'unset', message: CLOUD_KEY_MISSING_PROBLEM, missing: ['CARACMS_CLOUD_KEY'] };
|
|
473
478
|
const missing = [...SUBMISSIONS_ENV, ...SHARED_ENV].filter((name) => !get(name));
|
|
474
479
|
if (missing.length) {
|
|
475
480
|
return {
|
package/src/index.ts
CHANGED
|
@@ -466,6 +466,8 @@ export default function caracms(config: SiteConfig): AstroIntegration {
|
|
|
466
466
|
// Runs-anywhere Decisions 7 and 9: the address and the commit the build was made with (editor/server/mode.ts → bakedAtBuild).
|
|
467
467
|
__CARACMS_SITE__: JSON.stringify(astroConfig.site ?? address.url ?? null),
|
|
468
468
|
__CARACMS_BUILD_COMMIT__: JSON.stringify(commit),
|
|
469
|
+
// caracms-cloud M5 Decision 1: the config's `services`, for the key-or-own readers (live/services.ts → bakedServices).
|
|
470
|
+
__CARACMS_SERVICES__: JSON.stringify(site.services),
|
|
469
471
|
__CARACMS_TOKENS_CSS__: JSON.stringify(readFileSync(new URL('./editor/styles/tokens.css', import.meta.url), 'utf8')),
|
|
470
472
|
},
|
|
471
473
|
// Installed from npm, the package ships TypeScript and .astro source.
|
|
@@ -518,7 +520,7 @@ export default function caracms(config: SiteConfig): AstroIntegration {
|
|
|
518
520
|
// caracms-cloud M3 Decision 20: a cloud site's settings, fetched once before any page renders; a refusal fails the build.
|
|
519
521
|
'astro:build:start': async () => {
|
|
520
522
|
shareBuildReads(true);
|
|
521
|
-
await cloudSettingsForBuild();
|
|
523
|
+
await cloudSettingsForBuild(process.env, site.services);
|
|
522
524
|
},
|
|
523
525
|
'astro:build:done': () => {
|
|
524
526
|
shareBuildReads(false);
|
package/src/live/mode.ts
CHANGED
|
@@ -17,12 +17,20 @@
|
|
|
17
17
|
* browser runs, never against the site's own repo. It's live mode with no App and
|
|
18
18
|
* no repo, and it's **refused in production**.
|
|
19
19
|
*
|
|
20
|
+
* - **The config's `services`** (caracms-cloud M5, live/services.ts, baked in as `Baked.services`):
|
|
21
|
+
* with it, the other mode's variables are removed before anything here reads them, so the
|
|
22
|
+
* "not both" sentences below only ever apply to a config without the field (Decision 3);
|
|
23
|
+
* `'cloud'` without the key is `CLOUD_KEY_MISSING_PROBLEM` on a production server (Decision 4).
|
|
24
|
+
*
|
|
20
25
|
* This only reads; it doesn't wire anything (Phase 2 switches the routes on it,
|
|
21
26
|
* Phase 3 guards with it).
|
|
22
27
|
*/
|
|
23
28
|
import { CLOUD_KEY_SHAPE, DEFAULT_CLOUD_URL } from './cloud.ts';
|
|
24
29
|
import { DEFAULT_BRANCHES, type BranchNames } from './branches.ts';
|
|
25
30
|
import { DEFAULT_TIMINGS, type LiveTimings } from './timings.ts';
|
|
31
|
+
import { bakedServices, CLOUD_KEY_MISSING_PROBLEM, OWN_FORMS_VARIABLES, OWN_MEDIA_VARIABLES, OWN_MUX_VARIABLES, servicesEnv, type Services } from './services.ts';
|
|
32
|
+
|
|
33
|
+
export { OWN_FORMS_VARIABLES, OWN_MEDIA_VARIABLES, OWN_MUX_VARIABLES };
|
|
26
34
|
|
|
27
35
|
export interface LiveSettings {
|
|
28
36
|
/** `memory`: the in-memory GitHub backend (Decision 2). `token`: CARACMS_GITHUB_TOKEN (runs-anywhere Decision 3). */
|
|
@@ -81,6 +89,8 @@ export interface Baked {
|
|
|
81
89
|
site?: string | null;
|
|
82
90
|
/** The commit the build was made from (runs-anywhere Decision 9). */
|
|
83
91
|
commit?: string | null;
|
|
92
|
+
/** The config's `services` (caracms-cloud M5 Decision 1; live/services.ts). Null or missing: the key decides (Decision 3). */
|
|
93
|
+
services?: Services | null;
|
|
84
94
|
}
|
|
85
95
|
|
|
86
96
|
// The sentences the configuration problems print, kept here so the docs can quote them.
|
|
@@ -97,15 +107,9 @@ export const CLOUD_KEY_PROBLEM =
|
|
|
97
107
|
"CARACMS_CLOUD_KEY isn't a CaraCMS Cloud site key: a key is cck_ followed by 43 letters, digits, - and _. Copy it again from app.caracms.com.";
|
|
98
108
|
export const CLOUD_AND_GITHUB_PROBLEM = (other: string) =>
|
|
99
109
|
`Both CARACMS_CLOUD_KEY and ${other} are set: a site uses one GitHub credential, so remove ${other}.`;
|
|
100
|
-
/** The site's own media variables, which `CARACMS_CLOUD_KEY` replaces (caracms-cloud M3 Decision 19). */
|
|
101
|
-
export const OWN_MEDIA_VARIABLES = ['S3_ENDPOINT', 'S3_REGION', 'S3_BUCKET', 'S3_ACCESS_KEY_ID', 'S3_SECRET_ACCESS_KEY', 'IMGPROXY_KEY', 'IMGPROXY_SALT', 'CARACMS_MEDIA_URL'] as const;
|
|
102
110
|
const listed = (names: readonly string[]) => (names.length <= 1 ? names.join('') : `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`);
|
|
103
111
|
export const CLOUD_AND_MEDIA_PROBLEM = (names: readonly string[]) => `Set CARACMS_CLOUD_KEY or the site's own media variables, not both: remove ${listed(names)}.`;
|
|
104
|
-
/** The site's own Mux token, which `CARACMS_CLOUD_KEY` replaces (caracms-cloud M4.5 Decision 15). */
|
|
105
|
-
export const OWN_MUX_VARIABLES = ['MUX_TOKEN_ID', 'MUX_TOKEN_SECRET'] as const;
|
|
106
112
|
export const CLOUD_AND_MUX_PROBLEM = (names: readonly string[]) => `Set CARACMS_CLOUD_KEY or the site's own Mux variables, not both: remove ${listed(names)}.`;
|
|
107
|
-
/** The site's own forms and email variables, which `CARACMS_CLOUD_KEY` replaces (caracms-cloud M4 Decision 22). */
|
|
108
|
-
export const OWN_FORMS_VARIABLES = ['SUBMISSIONS_S3_BUCKET', 'SUBMISSIONS_S3_ACCESS_KEY_ID', 'SUBMISSIONS_S3_SECRET_ACCESS_KEY', 'RESEND_API_KEY', 'FORMS_FROM'] as const;
|
|
109
113
|
export const CLOUD_AND_FORMS_PROBLEM = (names: readonly string[]) => `Set CARACMS_CLOUD_KEY or the site's own forms and email variables, not both: remove ${listed(names)}.`;
|
|
110
114
|
|
|
111
115
|
/** The key beside any of the site's own forms and email variables: Decision 22's sentence, or null. */
|
|
@@ -121,7 +125,8 @@ export function cloudFormsConflict(env: Env): string | null {
|
|
|
121
125
|
* email variables beside it, a bad `CARACMS_CLOUD_URL`). Null: not a cloud site. Whether
|
|
122
126
|
* the cloud is *used* is the caller's (only on a production server; the Core framing).
|
|
123
127
|
*/
|
|
124
|
-
export function cloudForms(env: Env): { connection: { key: string; url: string }; problems: string[] } | null {
|
|
128
|
+
export function cloudForms(env: Env, services: Services | null = bakedServices()): { connection: { key: string; url: string }; problems: string[] } | null {
|
|
129
|
+
env = servicesEnv(env, services);
|
|
125
130
|
const get = (name: string) => env[name]?.trim() || null;
|
|
126
131
|
const key = get('CARACMS_CLOUD_KEY');
|
|
127
132
|
if (!key) return null;
|
|
@@ -192,6 +197,9 @@ const isLocalhost = (host: string) => host === 'localhost' || host.endsWith('.lo
|
|
|
192
197
|
* `baked` is what the build baked in (the address and the commit).
|
|
193
198
|
*/
|
|
194
199
|
export function readMode(env: Env = process.env, production = env.NODE_ENV === 'production', baked: Baked = {}): Mode {
|
|
200
|
+
// caracms-cloud M5 Decision 2: with the config's `services`, the other mode's variables are never read.
|
|
201
|
+
const services = baked.services ?? null;
|
|
202
|
+
env = servicesEnv(env, services);
|
|
195
203
|
const get = (name: string) => {
|
|
196
204
|
const v = env[name]?.trim();
|
|
197
205
|
return v ? v : null;
|
|
@@ -215,7 +223,10 @@ export function readMode(env: Env = process.env, production = env.NODE_ENV === '
|
|
|
215
223
|
if (other && other !== 'memory') problems.push(`CARACMS_GITHUB must be "memory" or unset (got "${other}").`);
|
|
216
224
|
|
|
217
225
|
let github: LiveSettings['github'] = { kind: 'memory' };
|
|
218
|
-
if (cloudKey && (!memory || production)) {
|
|
226
|
+
if (services === 'cloud' && !cloudKey && (!memory || production)) {
|
|
227
|
+
// M5 Decision 4: never the self-hosted variables instead (they were removed above).
|
|
228
|
+
problems.push(CLOUD_KEY_MISSING_PROBLEM);
|
|
229
|
+
} else if (cloudKey && (!memory || production)) {
|
|
219
230
|
// The cloud names the repo (Decision 13), so GITHUB_REPO is a mistake and Railway's own variables are ignored.
|
|
220
231
|
if (!CLOUD_KEY_SHAPE.test(cloudKey)) problems.push(CLOUD_KEY_PROBLEM);
|
|
221
232
|
if (ghToken) problems.push(CLOUD_AND_GITHUB_PROBLEM('CARACMS_GITHUB_TOKEN'));
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the site's services come from (caracms-cloud M5): the config's `services` field,
|
|
3
|
+
* not whichever variables happen to be set.
|
|
4
|
+
*
|
|
5
|
+
* - **`'cloud'`**: `CARACMS_CLOUD_KEY` is the one credential. The self-hosted variables
|
|
6
|
+
* (`REPLACED_VARIABLES`) are **removed from the environment the readers see**, so none is
|
|
7
|
+
* ever read, not even as a fallback (Decision 2, check 5), and each is named once per
|
|
8
|
+
* process as ignored.
|
|
9
|
+
* - **`'self-hosted'`**: the site's own variables; `CARACMS_CLOUD_KEY` is removed and named
|
|
10
|
+
* as ignored the same way.
|
|
11
|
+
* - **Unset**: the environment as it is, so the key decides and the "not both" refusals
|
|
12
|
+
* stand (Decision 3).
|
|
13
|
+
*
|
|
14
|
+
* Every key-or-own reader (live/mode.ts `readMode` and `cloudForms`, media.ts, the editor's
|
|
15
|
+
* media config, forms/store.ts, cloud-middleware.ts, and `media import` in bin/media.mjs)
|
|
16
|
+
* starts from `servicesEnv`. The running site's value is baked in at build
|
|
17
|
+
* (`__CARACMS_SERVICES__`, a Vite define from the config: `bakedServices`), since the readers
|
|
18
|
+
* are synchronous and have no config in hand.
|
|
19
|
+
*/
|
|
20
|
+
import type { Services } from '../config.ts';
|
|
21
|
+
|
|
22
|
+
export type { Services };
|
|
23
|
+
type Env = Record<string, string | undefined>;
|
|
24
|
+
|
|
25
|
+
/** The site's own media variables, which `CARACMS_CLOUD_KEY` replaces (caracms-cloud M3 Decision 19). */
|
|
26
|
+
export const OWN_MEDIA_VARIABLES = ['S3_ENDPOINT', 'S3_REGION', 'S3_BUCKET', 'S3_ACCESS_KEY_ID', 'S3_SECRET_ACCESS_KEY', 'IMGPROXY_KEY', 'IMGPROXY_SALT', 'CARACMS_MEDIA_URL'] as const;
|
|
27
|
+
/** The site's own Mux token, which `CARACMS_CLOUD_KEY` replaces (caracms-cloud M4.5 Decision 15). */
|
|
28
|
+
export const OWN_MUX_VARIABLES = ['MUX_TOKEN_ID', 'MUX_TOKEN_SECRET'] as const;
|
|
29
|
+
/** The site's own forms and email variables, which `CARACMS_CLOUD_KEY` replaces (caracms-cloud M4 Decision 22). */
|
|
30
|
+
export const OWN_FORMS_VARIABLES = ['SUBMISSIONS_S3_BUCKET', 'SUBMISSIONS_S3_ACCESS_KEY_ID', 'SUBMISSIONS_S3_SECRET_ACCESS_KEY', 'RESEND_API_KEY', 'FORMS_FROM'] as const;
|
|
31
|
+
|
|
32
|
+
/** The site's own GitHub variables, which the cloud key replaces (caracms-cloud M2 Decision 11). `GITHUB_WEBHOOK_SECRET` is harmless and not here. */
|
|
33
|
+
export const OWN_GITHUB_VARIABLES = ['CARACMS_GITHUB_TOKEN', 'GITHUB_APP_ID', 'GITHUB_APP_PRIVATE_KEY', 'GITHUB_REPO'] as const;
|
|
34
|
+
|
|
35
|
+
/** Every variable the cloud replaces, by group, in the move list's order of retiring (M5 Contracts). */
|
|
36
|
+
export const REPLACED_VARIABLES = {
|
|
37
|
+
media: OWN_MEDIA_VARIABLES,
|
|
38
|
+
video: OWN_MUX_VARIABLES,
|
|
39
|
+
forms: OWN_FORMS_VARIABLES,
|
|
40
|
+
github: OWN_GITHUB_VARIABLES,
|
|
41
|
+
} as const;
|
|
42
|
+
|
|
43
|
+
/** What each group's ignored line ends with: when it can go. */
|
|
44
|
+
const RETIRE: Record<keyof typeof REPLACED_VARIABLES, string> = {
|
|
45
|
+
media: 'Remove them once the old bucket and imgproxy are retired.',
|
|
46
|
+
video: 'Remove them once every clip is uploaded again in the editor.',
|
|
47
|
+
forms: 'Remove them once the old submissions are exported or no longer wanted.',
|
|
48
|
+
github: 'Remove them: the cloud gives the site its repo and access.',
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** The sentence Decision 4 fails a production build and `/admin` with. */
|
|
52
|
+
export const CLOUD_KEY_MISSING_PROBLEM = "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.";
|
|
53
|
+
|
|
54
|
+
const listed = (names: readonly string[]) => (names.length <= 1 ? names.join('') : `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`);
|
|
55
|
+
|
|
56
|
+
/** The ignored line for some of one group's variables (cloud), or for the key (self-hosted). */
|
|
57
|
+
export function ignoredMessage(names: readonly string[], services: Services, group?: keyof typeof REPLACED_VARIABLES): string {
|
|
58
|
+
const one = names.length === 1;
|
|
59
|
+
const set = `${listed(names)} ${one ? 'is' : 'are'} set`;
|
|
60
|
+
if (services === 'self-hosted') return `${set}, but this site is self-hosted (caracms.config.ts): ${one ? "it's" : "they're"} ignored. Remove ${one ? 'it' : 'them'}, or run \`caracms init --cloud\` to move the site to CaraCMS Cloud.`;
|
|
61
|
+
const tail = group ? RETIRE[group] : 'Remove them once the move is done.';
|
|
62
|
+
return `${set}, but this site runs on CaraCMS Cloud (caracms.config.ts): ${one ? "it's" : "they're"} ignored. ${one ? tail.replace(/them/g, 'it') : tail}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const WARNED = Symbol.for('caracms.ignoredVariables');
|
|
66
|
+
type Shared = { [WARNED]?: Set<string> };
|
|
67
|
+
const warned = () => ((globalThis as Shared)[WARNED] ??= new Set());
|
|
68
|
+
|
|
69
|
+
/** Forget which variables were named (tests). */
|
|
70
|
+
export function resetIgnoredWarnings(): void {
|
|
71
|
+
delete (globalThis as Shared)[WARNED];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const set = (env: Env, name: string) => Boolean(env[name]?.trim());
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The environment the readers see for this `services` (null: the environment as it is),
|
|
78
|
+
* with the other mode's variables removed. Each removed variable is named once per process
|
|
79
|
+
* (shared on globalThis, so the build's own module and its bundled chunks warn once between them).
|
|
80
|
+
*/
|
|
81
|
+
export function servicesEnv(env: Env, services: Services | null | undefined, log: (line: string) => void = (line) => console.warn(line)): Env {
|
|
82
|
+
if (!services) return env;
|
|
83
|
+
const seen = warned();
|
|
84
|
+
const out: Env = { ...env };
|
|
85
|
+
if (services === 'self-hosted') {
|
|
86
|
+
if (!set(env, 'CARACMS_CLOUD_KEY')) return env;
|
|
87
|
+
delete out.CARACMS_CLOUD_KEY;
|
|
88
|
+
if (!seen.has('CARACMS_CLOUD_KEY')) log(`[caracms] ${ignoredMessage(['CARACMS_CLOUD_KEY'], services)}`);
|
|
89
|
+
seen.add('CARACMS_CLOUD_KEY');
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
for (const [group, names] of Object.entries(REPLACED_VARIABLES) as [keyof typeof REPLACED_VARIABLES, readonly string[]][]) {
|
|
93
|
+
const found = names.filter((name) => set(env, name));
|
|
94
|
+
for (const name of found) delete out[name];
|
|
95
|
+
const fresh = found.filter((name) => !seen.has(name));
|
|
96
|
+
if (fresh.length) log(`[caracms] ${ignoredMessage(fresh, services, group)}`);
|
|
97
|
+
for (const name of fresh) seen.add(name);
|
|
98
|
+
}
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The names `servicesEnv` would ignore in `env` for `services` (init's move list, tests). */
|
|
103
|
+
export function ignoredVariables(env: Env, services: Services | null | undefined): string[] {
|
|
104
|
+
if (!services) return [];
|
|
105
|
+
if (services === 'self-hosted') return set(env, 'CARACMS_CLOUD_KEY') ? ['CARACMS_CLOUD_KEY'] : [];
|
|
106
|
+
return Object.values(REPLACED_VARIABLES).flatMap((names) => names.filter((name) => set(env, name)));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
declare const __CARACMS_SERVICES__: Services | null | undefined;
|
|
110
|
+
|
|
111
|
+
/** The config's `services` as the build baked it in; null in plain Node (tests) and for a config without one. */
|
|
112
|
+
export function bakedServices(): Services | null {
|
|
113
|
+
return typeof __CARACMS_SERVICES__ === 'string' ? __CARACMS_SERVICES__ : null;
|
|
114
|
+
}
|
package/src/media.ts
CHANGED
|
@@ -35,6 +35,7 @@ import { createHmac } from 'node:crypto';
|
|
|
35
35
|
import type { ImageFormat } from './fields.ts';
|
|
36
36
|
import { CLOUD_NO_MEDIA, heldCloudSettings, type CloudConnection } from './live/cloud.ts';
|
|
37
37
|
import { CLOUD_AND_MEDIA_PROBLEM, OWN_MEDIA_VARIABLES, readCloudUrl } from './live/mode.ts';
|
|
38
|
+
import { bakedServices, CLOUD_KEY_MISSING_PROBLEM, servicesEnv, type Services } from './live/services.ts';
|
|
38
39
|
import { renderMedia, type RenderImage } from './media-value.ts';
|
|
39
40
|
|
|
40
41
|
/** The srcset width ladder every fluid image draws from (Decision 2). 1920 at the top: imgproxy doesn't upscale. */
|
|
@@ -92,8 +93,10 @@ export const CLOUD_MEDIA_UNREACHABLE = "CaraCMS Cloud couldn't be reached, so im
|
|
|
92
93
|
* The cloud as the media credential: the connection when `CARACMS_CLOUD_KEY` is set and
|
|
93
94
|
* `CARACMS_MEDIA` isn't `memory` (which wins over everything), with any problems (the
|
|
94
95
|
* site's own media variables beside it, a bad `CARACMS_CLOUD_URL`). Null: not a cloud site's media.
|
|
96
|
+
* With the config's `services` (caracms-cloud M5), the other mode's variables aren't read.
|
|
95
97
|
*/
|
|
96
|
-
export function cloudMedia(env: Env = process.env): { connection: Pick<CloudConnection, 'key' | 'url'>; problems: string[] } | null {
|
|
98
|
+
export function cloudMedia(env: Env = process.env, services: Services | null = bakedServices()): { connection: Pick<CloudConnection, 'key' | 'url'>; problems: string[] } | null {
|
|
99
|
+
env = servicesEnv(env, services);
|
|
97
100
|
const get = (name: string) => env[name]?.trim() || null;
|
|
98
101
|
const key = get('CARACMS_CLOUD_KEY');
|
|
99
102
|
if (!key || get('CARACMS_MEDIA') === 'memory') return null;
|
|
@@ -117,7 +120,8 @@ function cloudMediaConfig(cloud: NonNullable<ReturnType<typeof cloudMedia>>, pro
|
|
|
117
120
|
}
|
|
118
121
|
|
|
119
122
|
/** The media variables (Decision 5). Read on each call (cheap), so tests can change them. */
|
|
120
|
-
export function readMediaConfig(env: Env = process.env, production = isProductionBuild()): MediaConfig {
|
|
123
|
+
export function readMediaConfig(env: Env = process.env, production = isProductionBuild(), services: Services | null = bakedServices()): MediaConfig {
|
|
124
|
+
env = servicesEnv(env, services);
|
|
121
125
|
const get = (name: string) => {
|
|
122
126
|
const v = env[name]?.trim();
|
|
123
127
|
return v ? v : null;
|
|
@@ -129,7 +133,9 @@ export function readMediaConfig(env: Env = process.env, production = isProductio
|
|
|
129
133
|
return { kind: 'ready', settings: { ...MEMORY_MEDIA, memory: true } };
|
|
130
134
|
}
|
|
131
135
|
if (mode) problems.push(`CARACMS_MEDIA must be "memory" or unset (got "${mode}").`);
|
|
132
|
-
|
|
136
|
+
// M5 Decision 4: a cloud site without its key has no media, never the site's own instead.
|
|
137
|
+
if (services === 'cloud' && !get('CARACMS_CLOUD_KEY')) return { kind: 'unset', missing: [], problems: [...problems, CLOUD_KEY_MISSING_PROBLEM] };
|
|
138
|
+
const cloud = cloudMedia(env, services);
|
|
133
139
|
if (cloud) return cloudMediaConfig(cloud, problems);
|
|
134
140
|
const missing: string[] = [];
|
|
135
141
|
const need = (name: string) => {
|