@projectdochelp/s3te 3.1.1 → 3.1.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/README.md CHANGED
@@ -6,14 +6,19 @@ This README is the user guide for the rewrite generation. The deeper implementat
6
6
 
7
7
  ## Table of Contents
8
8
 
9
- 1. [Motivation](#motivation)
10
- 2. [Support](#support)
11
- 3. [Concept](#concept)
12
- 4. [Installation (AWS)](#installation-aws)
13
- 5. [Installation (VSCode)](#installation-vscode)
14
- 6. [Installation (S3TE)](#installation-s3te)
15
- 7. [Usage](#usage)
16
- 8. [Optional: Webiny CMS](#optional-webiny-cms)
9
+ - [S3TemplateEngine](#s3templateengine)
10
+ - [Table of Contents](#table-of-contents)
11
+ - [Motivation](#motivation)
12
+ - [Support](#support)
13
+ - [Concept](#concept)
14
+ - [Installation (AWS)](#installation-aws)
15
+ - [Installation (VSCode)](#installation-vscode)
16
+ - [Installation (S3TE)](#installation-s3te)
17
+ - [Usage](#usage)
18
+ - [Daily Workflow](#daily-workflow)
19
+ - [CLI Commands](#cli-commands)
20
+ - [Template Commands](#template-commands)
21
+ - [Optional: Webiny CMS](#optional-webiny-cms)
17
22
 
18
23
  ## Motivation
19
24
 
@@ -152,7 +157,7 @@ With the local package installed, initialize the project like this:
152
157
  npx s3te init --project-name mywebsite --base-url example.com
153
158
  ```
154
159
 
155
- If `npm install` already created a minimal `package.json`, `s3te init` extends it with the missing S3TE defaults and scripts instead of failing.
160
+ You can safely run `s3te init` more than once. If `npm install` already created a minimal `package.json`, `s3te init` extends it with the missing S3TE defaults and scripts instead of failing. An existing `s3te.config.json` is completed with missing scaffold defaults, explicit `--project-name` and `--base-url` values are refreshed on re-run, and the generated schema file is updated to the current package version. Existing content files and templates stay untouched unless you use `--force`.
156
161
 
157
162
  If you want a one-shot scaffold without installing first, and `@projectdochelp/s3te` is already published on npm, this also works:
158
163
 
@@ -160,19 +165,6 @@ If you want a one-shot scaffold without installing first, and `@projectdochelp/s
160
165
  npx --package @projectdochelp/s3te s3te init --project-name mywebsite --base-url example.com
161
166
  ```
162
167
 
163
- That command only works after a real npm publish. A GitHub repository on its own is not enough.
164
-
165
- If you are still working from this repository before the first npm publish, run the CLI directly from the repo root instead:
166
-
167
- ```bash
168
- node packages/cli/bin/s3te.mjs init --dir ./mywebsite --project-name mywebsite --base-url example.com
169
- ```
170
-
171
- </details>
172
-
173
- <details>
174
- <summary>4. What the scaffold creates</summary>
175
-
176
168
  The default scaffold creates:
177
169
 
178
170
  ```text
@@ -198,7 +190,7 @@ mywebsite/
198
190
  </details>
199
191
 
200
192
  <details>
201
- <summary>5. Fill in the real AWS values in <code>s3te.config.json</code></summary>
193
+ <summary>4. Fill in the real AWS values in <code>s3te.config.json</code></summary>
202
194
 
203
195
  The most important fields for a first deployment are:
204
196
 
@@ -227,10 +219,12 @@ The most important fields for a first deployment are:
227
219
 
228
220
  `route53HostedZoneId` is optional. Leave it out if you want to manage DNS yourself.
229
221
 
222
+ Use plain hostnames in `baseUrl` and `cloudFrontAliases`, not full URLs. If your config contains a `prod` environment plus additional environments such as `test` or `stage`, S3TE keeps the `prod` hostname unchanged and derives non-production hostnames automatically by prepending `<env>.`.
223
+
230
224
  </details>
231
225
 
232
226
  <details>
233
- <summary>6. Run the first local check and deploy</summary>
227
+ <summary>5. Run the first local check and deploy</summary>
234
228
 
235
229
  ```bash
236
230
  npx s3te validate
@@ -378,6 +372,13 @@ npx s3te migrate --enable-webiny --webiny-source-table webiny-1234567 --webiny-t
378
372
 
379
373
  `staticContent` and `staticCodeContent` are kept automatically. Add `--webiny-model` once per custom model you want S3TE to mirror.
380
374
 
375
+ If different environments should read from different Webiny installations or tenants, run the migration per environment:
376
+
377
+ ```bash
378
+ npx s3te migrate --env test --enable-webiny --webiny-source-table webiny-test-1234567 --webiny-tenant preview --write
379
+ npx s3te migrate --env prod --enable-webiny --webiny-source-table webiny-live-1234567 --webiny-tenant root --write
380
+ ```
381
+
381
382
  4. Turn on DynamoDB Streams for the Webiny source table with `NEW_AND_OLD_IMAGES`.
382
383
  5. If your S3TE language keys are not identical to your Webiny locales, add `webinyLocale` per language in `s3te.config.json`, for example `"en": { "webinyLocale": "en-US" }`.
383
384
  6. If your Webiny installation hosts multiple tenants, keep `integrations.webiny.tenant` set so S3TE only mirrors the intended tenant.
@@ -411,7 +412,17 @@ Example config block:
411
412
  "sourceTableName": "webiny-1234567",
412
413
  "mirrorTableName": "{stackPrefix}_s3te_content_{project}",
413
414
  "tenant": "root",
414
- "relevantModels": ["article", "staticContent", "staticCodeContent"]
415
+ "relevantModels": ["article", "staticContent", "staticCodeContent"],
416
+ "environments": {
417
+ "test": {
418
+ "sourceTableName": "webiny-test-1234567",
419
+ "tenant": "preview"
420
+ },
421
+ "prod": {
422
+ "sourceTableName": "webiny-live-1234567",
423
+ "tenant": "root"
424
+ }
425
+ }
415
426
  }
416
427
  }
417
428
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@projectdochelp/s3te",
3
- "version": "3.1.1",
3
+ "version": "3.1.2",
4
4
  "description": "CLI, render core, AWS adapter, and testkit for S3TemplateEngine projects",
5
5
  "repository": {
6
6
  "type": "git",
@@ -243,12 +243,12 @@ export async function deployAwsProject({
243
243
  }) {
244
244
  const runtimeConfig = buildEnvironmentRuntimeConfig(config, environment);
245
245
  const requestedFeatureSet = new Set(features);
246
- const featureSet = new Set(resolveRequestedFeatures(config, features));
246
+ const featureSet = new Set(resolveRequestedFeatures(config, features, environment));
247
247
  const stackName = resolveStackName(config, environment);
248
248
  const tempStackName = temporaryStackName(stackName);
249
249
  const runtimeManifestPath = path.join(projectDir, packageDir ?? path.join("offline", "IAAS", "package", environment), "runtime-manifest.json");
250
250
 
251
- if (requestedFeatureSet.has("webiny") && !config.integrations.webiny.enabled) {
251
+ if (requestedFeatureSet.has("webiny") && !runtimeConfig.integrations.webiny.enabled) {
252
252
  throw new S3teError("ADAPTER_ERROR", "Feature webiny was requested but is not enabled in s3te.config.json.");
253
253
  }
254
254
 
@@ -1,16 +1,29 @@
1
- export function getConfiguredFeatures(config) {
1
+ import { resolveEnvironmentWebinyIntegration } from "../../core/src/index.mjs";
2
+
3
+ export function getConfiguredFeatures(config, environment) {
2
4
  const features = [];
3
5
 
4
- if (config.integrations?.webiny?.enabled) {
6
+ if (environment) {
7
+ if (resolveEnvironmentWebinyIntegration(config, environment).enabled) {
8
+ features.push("webiny");
9
+ }
10
+ return features;
11
+ }
12
+
13
+ const hasAnyEnvironmentWebiny = Object.keys(config.environments ?? {}).some((environmentName) => (
14
+ resolveEnvironmentWebinyIntegration(config, environmentName).enabled
15
+ ));
16
+
17
+ if (hasAnyEnvironmentWebiny) {
5
18
  features.push("webiny");
6
19
  }
7
20
 
8
21
  return features;
9
22
  }
10
23
 
11
- export function resolveRequestedFeatures(config, requestedFeatures = []) {
24
+ export function resolveRequestedFeatures(config, requestedFeatures = [], environment) {
12
25
  return [...new Set([
13
26
  ...requestedFeatures,
14
- ...getConfiguredFeatures(config)
27
+ ...getConfiguredFeatures(config, environment)
15
28
  ])].sort();
16
29
  }
@@ -221,11 +221,13 @@ export async function packageAwsProject({
221
221
  clean = false,
222
222
  features = []
223
223
  }) {
224
- if (features.includes("webiny") && !config.integrations.webiny.enabled) {
224
+ const runtimeConfig = buildEnvironmentRuntimeConfig(config, environment);
225
+
226
+ if (features.includes("webiny") && !runtimeConfig.integrations.webiny.enabled) {
225
227
  throw new S3teError("ADAPTER_ERROR", "Feature webiny was requested but is not enabled in s3te.config.json.");
226
228
  }
227
229
 
228
- const resolvedFeatures = resolveRequestedFeatures(config, features);
230
+ const resolvedFeatures = resolveRequestedFeatures(config, features, environment);
229
231
  const packageDir = outDir
230
232
  ? path.join(projectDir, outDir)
231
233
  : path.join(projectDir, "offline", "IAAS", "package", environment);
@@ -234,7 +236,6 @@ export async function packageAwsProject({
234
236
  }
235
237
  await ensureDirectory(packageDir);
236
238
 
237
- const runtimeConfig = buildEnvironmentRuntimeConfig(config, environment);
238
239
  const lambdaDir = path.join(packageDir, "lambda");
239
240
  const templatePath = path.join(packageDir, "cloudformation.template.json");
240
241
  const packagingManifestPath = path.join(packageDir, "manifest.json");
@@ -349,6 +349,7 @@ async function main() {
349
349
  const rawConfig = JSON.parse(await fs.readFile(configPath, "utf8"));
350
350
  const migration = await migrateProject(configPath, rawConfig, {
351
351
  writeChanges: Boolean(options.write) && !Boolean(options["dry-run"]),
352
+ environment: asArray(options.env)[0],
352
353
  enableWebiny: Boolean(options["enable-webiny"]),
353
354
  disableWebiny: Boolean(options["disable-webiny"]),
354
355
  webinySourceTable: options["webiny-source-table"],
@@ -31,6 +31,26 @@ function normalizePath(value) {
31
31
  return String(value).replace(/\\/g, "/");
32
32
  }
33
33
 
34
+ function normalizeBaseUrl(value) {
35
+ const trimmed = String(value).trim();
36
+ if (!trimmed) {
37
+ return trimmed;
38
+ }
39
+
40
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)) {
41
+ try {
42
+ return new URL(trimmed).host;
43
+ } catch {
44
+ // fall back to lightweight normalization below
45
+ }
46
+ }
47
+
48
+ return trimmed
49
+ .replace(/^[a-z][a-z0-9+.-]*:\/\//i, "")
50
+ .replace(/^\/+|\/+$/g, "")
51
+ .split("/")[0];
52
+ }
53
+
34
54
  function schemaTemplate() {
35
55
  return {
36
56
  $schema: "https://json-schema.org/draft/2020-12/schema",
@@ -67,23 +87,53 @@ function schemaTemplate() {
67
87
  }
68
88
  }
69
89
  },
90
+ rendering: {
91
+ type: "object",
92
+ additionalProperties: false,
93
+ properties: {
94
+ minifyHtml: { type: "boolean" },
95
+ renderExtensions: {
96
+ type: "array",
97
+ items: { type: "string" }
98
+ },
99
+ outputDir: { type: "string" },
100
+ maxRenderDepth: {
101
+ type: "integer",
102
+ minimum: 1
103
+ }
104
+ }
105
+ },
70
106
  variants: {
71
107
  type: "object",
72
108
  additionalProperties: {
73
109
  type: "object",
74
- additionalProperties: true,
110
+ additionalProperties: false,
111
+ required: ["defaultLanguage", "languages"],
75
112
  properties: {
113
+ sourceDir: { type: "string" },
114
+ partDir: { type: "string" },
115
+ defaultLanguage: { type: "string" },
116
+ routing: {
117
+ type: "object",
118
+ additionalProperties: false,
119
+ properties: {
120
+ indexDocument: { type: "string" },
121
+ notFoundDocument: { type: "string" }
122
+ }
123
+ },
76
124
  languages: {
77
125
  type: "object",
78
126
  additionalProperties: {
79
127
  type: "object",
80
128
  additionalProperties: false,
129
+ required: ["baseUrl", "cloudFrontAliases"],
81
130
  properties: {
82
131
  baseUrl: { type: "string" },
83
132
  targetBucket: { type: "string" },
84
133
  cloudFrontAliases: {
85
134
  type: "array",
86
- items: { type: "string" }
135
+ items: { type: "string" },
136
+ minItems: 1
87
137
  },
88
138
  webinyLocale: { type: "string" }
89
139
  }
@@ -92,6 +142,56 @@ function schemaTemplate() {
92
142
  }
93
143
  }
94
144
  },
145
+ aws: {
146
+ type: "object",
147
+ additionalProperties: false,
148
+ properties: {
149
+ codeBuckets: {
150
+ type: "object",
151
+ additionalProperties: { type: "string" }
152
+ },
153
+ dependencyStore: {
154
+ type: "object",
155
+ additionalProperties: false,
156
+ properties: {
157
+ tableName: { type: "string" }
158
+ }
159
+ },
160
+ contentStore: {
161
+ type: "object",
162
+ additionalProperties: false,
163
+ properties: {
164
+ tableName: { type: "string" },
165
+ contentIdIndexName: { type: "string" }
166
+ }
167
+ },
168
+ invalidationStore: {
169
+ type: "object",
170
+ additionalProperties: false,
171
+ properties: {
172
+ tableName: { type: "string" },
173
+ debounceSeconds: {
174
+ type: "integer",
175
+ minimum: 0
176
+ }
177
+ }
178
+ },
179
+ lambda: {
180
+ type: "object",
181
+ additionalProperties: false,
182
+ properties: {
183
+ runtime: {
184
+ type: "string",
185
+ enum: ["nodejs22.x"]
186
+ },
187
+ architecture: {
188
+ type: "string",
189
+ enum: ["arm64", "x86_64"]
190
+ }
191
+ }
192
+ }
193
+ }
194
+ },
95
195
  integrations: {
96
196
  type: "object",
97
197
  additionalProperties: false,
@@ -107,6 +207,23 @@ function schemaTemplate() {
107
207
  relevantModels: {
108
208
  type: "array",
109
209
  items: { type: "string" }
210
+ },
211
+ environments: {
212
+ type: "object",
213
+ additionalProperties: {
214
+ type: "object",
215
+ additionalProperties: false,
216
+ properties: {
217
+ enabled: { type: "boolean" },
218
+ sourceTableName: { type: "string" },
219
+ mirrorTableName: { type: "string" },
220
+ tenant: { type: "string" },
221
+ relevantModels: {
222
+ type: "array",
223
+ items: { type: "string" }
224
+ }
225
+ }
226
+ }
110
227
  }
111
228
  }
112
229
  }
@@ -129,14 +246,34 @@ function isPlainObject(value) {
129
246
  return value !== null && typeof value === "object" && !Array.isArray(value);
130
247
  }
131
248
 
132
- async function writeProjectFile(targetPath, body, force = false) {
133
- if (!force && await fileExists(targetPath)) {
134
- throw new Error(`Refusing to overwrite existing file: ${targetPath}`);
249
+ async function writeProjectFile(targetPath, body, force = false, overwriteExisting = false) {
250
+ if (!force && !overwriteExisting && await fileExists(targetPath)) {
251
+ return;
135
252
  }
136
253
  await writeTextFile(targetPath, body);
137
254
  }
138
255
 
139
- function mergeProjectPackageJson(existingPackageJson, projectPackageJson) {
256
+ function mergeDefaults(existingValue, defaultValue) {
257
+ if (existingValue === undefined) {
258
+ return defaultValue;
259
+ }
260
+
261
+ if (isPlainObject(defaultValue)) {
262
+ if (!isPlainObject(existingValue)) {
263
+ throw new Error("Existing JSON content must use an object where S3TE expects one.");
264
+ }
265
+
266
+ const mergedValue = { ...existingValue };
267
+ for (const [key, value] of Object.entries(defaultValue)) {
268
+ mergedValue[key] = mergeDefaults(existingValue[key], value);
269
+ }
270
+ return mergedValue;
271
+ }
272
+
273
+ return existingValue;
274
+ }
275
+
276
+ function mergeProjectPackageJson(existingPackageJson, projectPackageJson, scaffoldOptions = {}) {
140
277
  if (!isPlainObject(existingPackageJson)) {
141
278
  throw new Error("Existing package.json must contain a JSON object.");
142
279
  }
@@ -167,10 +304,14 @@ function mergeProjectPackageJson(existingPackageJson, projectPackageJson) {
167
304
  mergedPackageJson.scripts = mergedScripts;
168
305
  }
169
306
 
307
+ if (scaffoldOptions.projectNameProvided) {
308
+ mergedPackageJson.name = scaffoldOptions.projectName;
309
+ }
310
+
170
311
  return mergedPackageJson;
171
312
  }
172
313
 
173
- async function writeProjectPackageJson(targetPath, projectPackageJson, force = false) {
314
+ async function writeProjectPackageJson(targetPath, projectPackageJson, scaffoldOptions = {}, force = false) {
174
315
  if (force || !await fileExists(targetPath)) {
175
316
  await writeTextFile(targetPath, JSON.stringify(projectPackageJson, null, 2) + "\n");
176
317
  return;
@@ -183,10 +324,63 @@ async function writeProjectPackageJson(targetPath, projectPackageJson, force = f
183
324
  throw new Error(`Existing package.json is not valid JSON: ${targetPath}`, { cause: error });
184
325
  }
185
326
 
186
- const mergedPackageJson = mergeProjectPackageJson(existingPackageJson, projectPackageJson);
327
+ const mergedPackageJson = mergeProjectPackageJson(existingPackageJson, projectPackageJson, scaffoldOptions);
187
328
  await writeTextFile(targetPath, JSON.stringify(mergedPackageJson, null, 2) + "\n");
188
329
  }
189
330
 
331
+ function applyScaffoldConfigOverrides(config, scaffoldOptions = {}) {
332
+ if (scaffoldOptions.projectNameProvided) {
333
+ config.project.name = scaffoldOptions.projectName;
334
+ }
335
+
336
+ const variantConfig = config.variants?.[scaffoldOptions.variant];
337
+ const languageConfig = variantConfig?.languages?.[scaffoldOptions.language];
338
+ if (!variantConfig || !languageConfig) {
339
+ return config;
340
+ }
341
+
342
+ if (scaffoldOptions.languageProvided) {
343
+ variantConfig.defaultLanguage = scaffoldOptions.language;
344
+ }
345
+
346
+ if (languageConfig.webinyLocale === undefined) {
347
+ languageConfig.webinyLocale = scaffoldOptions.language;
348
+ }
349
+
350
+ if (scaffoldOptions.baseUrlProvided) {
351
+ const previousBaseUrl = languageConfig.baseUrl;
352
+ languageConfig.baseUrl = scaffoldOptions.baseUrl;
353
+ if (!Array.isArray(languageConfig.cloudFrontAliases)
354
+ || languageConfig.cloudFrontAliases.length === 0
355
+ || (languageConfig.cloudFrontAliases.length === 1 && languageConfig.cloudFrontAliases[0] === previousBaseUrl)) {
356
+ languageConfig.cloudFrontAliases = [scaffoldOptions.baseUrl];
357
+ }
358
+ }
359
+
360
+ return config;
361
+ }
362
+
363
+ async function writeProjectConfigJson(targetPath, projectConfig, scaffoldOptions = {}, force = false) {
364
+ if (force || !await fileExists(targetPath)) {
365
+ await writeTextFile(targetPath, JSON.stringify(projectConfig, null, 2) + "\n");
366
+ return;
367
+ }
368
+
369
+ let existingConfig;
370
+ try {
371
+ existingConfig = JSON.parse(await fs.readFile(targetPath, "utf8"));
372
+ } catch (error) {
373
+ throw new Error(`Existing s3te.config.json is not valid JSON: ${targetPath}`, { cause: error });
374
+ }
375
+
376
+ if (!isPlainObject(existingConfig)) {
377
+ throw new Error("Existing s3te.config.json must contain a JSON object.");
378
+ }
379
+
380
+ const mergedConfig = applyScaffoldConfigOverrides(mergeDefaults(existingConfig, projectConfig), scaffoldOptions);
381
+ await writeTextFile(targetPath, JSON.stringify(mergedConfig, null, 2) + "\n");
382
+ }
383
+
190
384
  async function loadRenderState(projectDir, environment) {
191
385
  const statePath = path.join(projectDir, "offline", "S3TELocal", "render-state", `${environment}.json`);
192
386
  try {
@@ -268,10 +462,20 @@ export async function validateProject(projectDir, config, options = {}) {
268
462
 
269
463
  export async function scaffoldProject(projectDir, options = {}) {
270
464
  const projectName = options.projectName ?? path.basename(projectDir).toLowerCase().replace(/[^a-z0-9-]/g, "-");
271
- const baseUrl = options.baseUrl ?? "example.com";
465
+ const baseUrl = normalizeBaseUrl(options.baseUrl ?? "example.com");
272
466
  const variant = options.variant ?? "website";
273
467
  const language = options.language ?? "en";
274
468
  const force = Boolean(options.force);
469
+ const scaffoldOptions = {
470
+ projectName,
471
+ projectNameProvided: options.projectName !== undefined,
472
+ baseUrl,
473
+ baseUrlProvided: options.baseUrl !== undefined,
474
+ variant,
475
+ variantProvided: options.variant !== undefined,
476
+ language,
477
+ languageProvided: options.language !== undefined
478
+ };
275
479
 
276
480
  await ensureDirectory(path.join(projectDir, "app", "part"));
277
481
  await ensureDirectory(path.join(projectDir, "app", variant));
@@ -323,9 +527,9 @@ export async function scaffoldProject(projectDir, options = {}) {
323
527
  }
324
528
  };
325
529
 
326
- await writeProjectPackageJson(path.join(projectDir, "package.json"), projectPackageJson, force);
327
- await writeProjectFile(path.join(projectDir, "s3te.config.json"), JSON.stringify(config, null, 2) + "\n", force);
328
- await writeProjectFile(path.join(projectDir, "offline", "schemas", "s3te.config.schema.json"), JSON.stringify(schemaTemplate(), null, 2) + "\n", force);
530
+ await writeProjectPackageJson(path.join(projectDir, "package.json"), projectPackageJson, scaffoldOptions, force);
531
+ await writeProjectConfigJson(path.join(projectDir, "s3te.config.json"), config, scaffoldOptions, force);
532
+ await writeProjectFile(path.join(projectDir, "offline", "schemas", "s3te.config.schema.json"), JSON.stringify(schemaTemplate(), null, 2) + "\n", force, true);
329
533
  await writeProjectFile(path.join(projectDir, "app", "part", "head.part"), "<meta charset='utf-8'>\n<title>My S3TE Site</title>\n", force);
330
534
  await writeProjectFile(path.join(projectDir, "app", variant, "index.html"), "<!doctype html>\n<html lang=\"<lang>2</lang>\">\n <head>\n <part>head.part</part>\n </head>\n <body>\n <h1>Hello from S3TemplateEngine</h1>\n </body>\n</html>\n", force);
331
535
  await writeProjectFile(path.join(projectDir, "offline", "content", `${language}.json`), "[]\n", force);
@@ -529,6 +733,7 @@ export async function migrateProject(configPath, rawConfig, writeChanges) {
529
733
 
530
734
  const enableWebiny = Boolean(options.enableWebiny);
531
735
  const disableWebiny = Boolean(options.disableWebiny);
736
+ const targetEnvironment = options.environment ? String(options.environment).trim() : "";
532
737
  const webinySourceTable = options.webinySourceTable ? String(options.webinySourceTable).trim() : "";
533
738
  const webinyTenant = options.webinyTenant ? String(options.webinyTenant).trim() : "";
534
739
  const webinyModels = normalizeStringList(options.webinyModels);
@@ -537,45 +742,84 @@ export async function migrateProject(configPath, rawConfig, writeChanges) {
537
742
  throw new S3teError("CONFIG_CONFLICT_ERROR", "migrate does not allow --enable-webiny and --disable-webiny at the same time.");
538
743
  }
539
744
 
540
- const touchesWebiny = enableWebiny || disableWebiny || Boolean(webinySourceTable) || webinyModels.length > 0;
745
+ const touchesWebiny = enableWebiny || disableWebiny || Boolean(webinySourceTable) || Boolean(webinyTenant) || webinyModels.length > 0;
541
746
  if (touchesWebiny) {
747
+ if (targetEnvironment && !nextConfig.environments?.[targetEnvironment]) {
748
+ throw new S3teError("CONFIG_CONFLICT_ERROR", `Unknown environment for migrate: ${targetEnvironment}.`);
749
+ }
750
+
542
751
  const existingIntegrations = nextConfig.integrations ?? {};
543
752
  const existingWebiny = existingIntegrations.webiny ?? {};
544
- const existingModels = normalizeStringList(existingWebiny.relevantModels ?? ["staticContent", "staticCodeContent"]);
753
+ const existingEnvironmentOverrides = existingWebiny.environments ?? {};
754
+ const existingTargetWebiny = targetEnvironment
755
+ ? (existingEnvironmentOverrides[targetEnvironment] ?? {})
756
+ : existingWebiny;
757
+ const inheritedModels = normalizeStringList(
758
+ existingTargetWebiny.relevantModels
759
+ ?? (targetEnvironment ? existingWebiny.relevantModels : undefined)
760
+ ?? ["staticContent", "staticCodeContent"]
761
+ );
545
762
  const shouldEnableWebiny = disableWebiny
546
763
  ? false
547
764
  : (enableWebiny || Boolean(webinySourceTable) || webinyModels.length > 0
548
765
  ? true
549
- : Boolean(existingWebiny.enabled));
550
- const nextSourceTableName = webinySourceTable || existingWebiny.sourceTableName || "";
766
+ : Boolean(targetEnvironment
767
+ ? (existingTargetWebiny.enabled ?? existingWebiny.enabled)
768
+ : existingWebiny.enabled));
769
+ const nextSourceTableName = webinySourceTable
770
+ || existingTargetWebiny.sourceTableName
771
+ || (targetEnvironment ? existingWebiny.sourceTableName : "")
772
+ || "";
551
773
 
552
774
  if (shouldEnableWebiny && !nextSourceTableName) {
553
- throw new S3teError("CONFIG_CONFLICT_ERROR", "Enabling Webiny requires --webiny-source-table <table> or an existing integrations.webiny.sourceTableName.");
775
+ throw new S3teError(
776
+ "CONFIG_CONFLICT_ERROR",
777
+ targetEnvironment
778
+ ? `Enabling Webiny for environment ${targetEnvironment} requires --webiny-source-table <table> or an existing sourceTableName.`
779
+ : "Enabling Webiny requires --webiny-source-table <table> or an existing integrations.webiny.sourceTableName."
780
+ );
554
781
  }
555
782
 
783
+ const nextWebinyConfig = {
784
+ enabled: shouldEnableWebiny,
785
+ sourceTableName: nextSourceTableName || undefined,
786
+ mirrorTableName: existingTargetWebiny.mirrorTableName
787
+ ?? (targetEnvironment ? existingWebiny.mirrorTableName : undefined)
788
+ ?? "{stackPrefix}_s3te_content_{project}",
789
+ tenant: webinyTenant || existingTargetWebiny.tenant || (targetEnvironment ? existingWebiny.tenant : undefined) || undefined,
790
+ relevantModels: normalizeStringList([
791
+ ...(inheritedModels.length > 0 ? inheritedModels : ["staticContent", "staticCodeContent"]),
792
+ ...webinyModels
793
+ ])
794
+ };
795
+
556
796
  nextConfig.integrations = {
557
797
  ...existingIntegrations,
558
- webiny: {
559
- enabled: shouldEnableWebiny,
560
- sourceTableName: nextSourceTableName || undefined,
561
- mirrorTableName: existingWebiny.mirrorTableName ?? "{stackPrefix}_s3te_content_{project}",
562
- tenant: webinyTenant || existingWebiny.tenant || undefined,
563
- relevantModels: normalizeStringList([
564
- ...(existingModels.length > 0 ? existingModels : ["staticContent", "staticCodeContent"]),
565
- ...webinyModels
566
- ])
567
- }
798
+ webiny: targetEnvironment
799
+ ? {
800
+ ...existingWebiny,
801
+ environments: {
802
+ ...existingEnvironmentOverrides,
803
+ [targetEnvironment]: nextWebinyConfig
804
+ }
805
+ }
806
+ : {
807
+ ...existingWebiny,
808
+ ...nextWebinyConfig,
809
+ environments: existingEnvironmentOverrides
810
+ }
568
811
  };
569
812
 
570
- changes.push(shouldEnableWebiny ? "Enabled Webiny integration." : "Disabled Webiny integration.");
813
+ const scopeLabel = targetEnvironment ? ` for environment ${targetEnvironment}` : "";
814
+ changes.push(shouldEnableWebiny ? `Enabled Webiny integration${scopeLabel}.` : `Disabled Webiny integration${scopeLabel}.`);
571
815
  if (webinySourceTable) {
572
- changes.push(`Set Webiny source table to ${webinySourceTable}.`);
816
+ changes.push(`Set Webiny source table${scopeLabel} to ${webinySourceTable}.`);
573
817
  }
574
818
  if (webinyTenant) {
575
- changes.push(`Set Webiny tenant to ${webinyTenant}.`);
819
+ changes.push(`Set Webiny tenant${scopeLabel} to ${webinyTenant}.`);
576
820
  }
577
821
  if (webinyModels.length > 0) {
578
- changes.push(`Added Webiny models: ${webinyModels.join(", ")}.`);
822
+ changes.push(`Added Webiny models${scopeLabel}: ${webinyModels.join(", ")}.`);
579
823
  }
580
824
  }
581
825
 
@@ -3,7 +3,7 @@ import path from "node:path";
3
3
 
4
4
  import { assert, S3teError } from "./errors.mjs";
5
5
 
6
- const KNOWN_PLACEHOLDERS = new Set(["env", "stackPrefix", "project", "variant", "lang"]);
6
+ const KNOWN_PLACEHOLDERS = new Set(["env", "envPrefix", "stackPrefix", "project", "variant", "lang"]);
7
7
 
8
8
  function upperSnakeCase(value) {
9
9
  return value.replace(/-/g, "_").toUpperCase();
@@ -28,7 +28,7 @@ function ensureKnownPlaceholders(input, fieldPath, errors) {
28
28
  }
29
29
 
30
30
  function replacePlaceholders(input, values) {
31
- return String(input).replace(/\{(env|stackPrefix|project|variant|lang)\}/g, (_, token) => values[token]);
31
+ return String(input).replace(/\{(env|envPrefix|stackPrefix|project|variant|lang)\}/g, (_, token) => values[token]);
32
32
  }
33
33
 
34
34
  function normalizeRelativeProjectPath(relativePath) {
@@ -46,12 +46,58 @@ function isValidUpperSnake(value) {
46
46
  return /^[A-Z0-9_]+$/.test(value);
47
47
  }
48
48
 
49
+ function normalizeStringList(values, fallback = []) {
50
+ const source = values ?? fallback;
51
+ const items = Array.isArray(source) ? source : [source];
52
+ return [...new Set(items
53
+ .map((value) => String(value).trim())
54
+ .filter(Boolean))];
55
+ }
56
+
57
+ function isProductionEnvironment(environmentName) {
58
+ return String(environmentName).trim().toLowerCase() === "prod";
59
+ }
60
+
61
+ function hasProductionEnvironment(config) {
62
+ return Object.keys(config.environments ?? {}).some((environmentName) => isProductionEnvironment(environmentName));
63
+ }
64
+
65
+ function environmentResourcePrefix(environmentName) {
66
+ return isProductionEnvironment(environmentName) ? "" : `${environmentName}-`;
67
+ }
68
+
69
+ function environmentHostPrefix(config, environmentName) {
70
+ if (!hasProductionEnvironment(config) || isProductionEnvironment(environmentName)) {
71
+ return "";
72
+ }
73
+
74
+ return `${environmentName}.`;
75
+ }
76
+
77
+ function prefixHostForEnvironment(config, host, environmentName) {
78
+ const prefix = environmentHostPrefix(config, environmentName);
79
+ if (!prefix) {
80
+ return host;
81
+ }
82
+
83
+ return host.startsWith(prefix) ? host : `${prefix}${host}`;
84
+ }
85
+
86
+ function isValidConfiguredHost(value) {
87
+ const candidate = String(value).trim();
88
+ if (!candidate || candidate.includes("://") || candidate.includes("/") || candidate.includes(":")) {
89
+ return false;
90
+ }
91
+
92
+ return /^[A-Za-z0-9.-]+$/.test(candidate);
93
+ }
94
+
49
95
  function defaultTargetBucketPattern({ variant, language, languageCount, isDefaultLanguage, project }) {
50
96
  if (languageCount === 1 || isDefaultLanguage) {
51
- return `{env}-${variant}-${project}`;
97
+ return `{envPrefix}${variant}-${project}`;
52
98
  }
53
99
 
54
- return `{env}-${variant}-${project}-${language}`;
100
+ return `{envPrefix}${variant}-${project}-${language}`;
55
101
  }
56
102
 
57
103
  async function ensureDirectoryExists(projectDir, relativePath, errors) {
@@ -79,6 +125,7 @@ function createPlaceholderContext(config, environmentName, variantName, language
79
125
  const variantConfig = variantName ? config.variants[variantName] : null;
80
126
  return {
81
127
  env: environmentName,
128
+ envPrefix: environmentResourcePrefix(environmentName),
82
129
  stackPrefix: environmentConfig.stackPrefix,
83
130
  project: config.project.name,
84
131
  variant: variantName ?? "website",
@@ -86,6 +133,35 @@ function createPlaceholderContext(config, environmentName, variantName, language
86
133
  };
87
134
  }
88
135
 
136
+ function resolveWebinyConfigDefaults(webinyConfig = {}) {
137
+ return {
138
+ enabled: webinyConfig.enabled ?? false,
139
+ sourceTableName: webinyConfig.sourceTableName,
140
+ mirrorTableName: webinyConfig.mirrorTableName ?? "{stackPrefix}_s3te_content_{project}",
141
+ relevantModels: normalizeStringList(webinyConfig.relevantModels, ["staticContent", "staticCodeContent"]),
142
+ tenant: webinyConfig.tenant
143
+ };
144
+ }
145
+
146
+ function resolveProjectWebinyConfig(projectConfig) {
147
+ const baseConfig = resolveWebinyConfigDefaults(projectConfig.integrations?.webiny ?? {});
148
+ const environmentConfigs = Object.fromEntries(Object.entries(projectConfig.integrations?.webiny?.environments ?? {}).map(([environmentName, webinyConfig]) => ([
149
+ environmentName,
150
+ {
151
+ enabled: webinyConfig.enabled,
152
+ sourceTableName: webinyConfig.sourceTableName,
153
+ mirrorTableName: webinyConfig.mirrorTableName,
154
+ relevantModels: webinyConfig.relevantModels ? normalizeStringList(webinyConfig.relevantModels) : undefined,
155
+ tenant: webinyConfig.tenant
156
+ }
157
+ ])));
158
+
159
+ return {
160
+ ...baseConfig,
161
+ environments: environmentConfigs
162
+ };
163
+ }
164
+
89
165
  export async function loadProjectConfig(configPath) {
90
166
  const raw = await fs.readFile(configPath, "utf8");
91
167
  try {
@@ -148,7 +224,7 @@ export function resolveProjectConfig(projectConfig) {
148
224
  languages
149
225
  };
150
226
 
151
- awsCodeBuckets[variantName] = awsCodeBuckets[variantName] ?? "{env}-{variant}-code-{project}";
227
+ awsCodeBuckets[variantName] = awsCodeBuckets[variantName] ?? "{envPrefix}{variant}-code-{project}";
152
228
  }
153
229
 
154
230
  const aws = {
@@ -171,13 +247,7 @@ export function resolveProjectConfig(projectConfig) {
171
247
  };
172
248
 
173
249
  const integrations = {
174
- webiny: {
175
- enabled: projectConfig.integrations?.webiny?.enabled ?? false,
176
- sourceTableName: projectConfig.integrations?.webiny?.sourceTableName,
177
- mirrorTableName: projectConfig.integrations?.webiny?.mirrorTableName ?? "{stackPrefix}_s3te_content_{project}",
178
- relevantModels: projectConfig.integrations?.webiny?.relevantModels ?? ["staticContent", "staticCodeContent"],
179
- tenant: projectConfig.integrations?.webiny?.tenant
180
- }
250
+ webiny: resolveProjectWebinyConfig(projectConfig)
181
251
  };
182
252
 
183
253
  for (const [variantName, variantConfig] of Object.entries(variants)) {
@@ -219,13 +289,42 @@ export function resolveTargetBucketName(config, environmentName, variantName, la
219
289
  );
220
290
  }
221
291
 
292
+ export function resolveBaseUrl(config, environmentName, variantName, languageCode) {
293
+ return prefixHostForEnvironment(
294
+ config,
295
+ config.variants[variantName].languages[languageCode].baseUrl,
296
+ environmentName
297
+ );
298
+ }
299
+
300
+ export function resolveCloudFrontAliases(config, environmentName, variantName, languageCode) {
301
+ return config.variants[variantName].languages[languageCode].cloudFrontAliases
302
+ .map((alias) => prefixHostForEnvironment(config, alias, environmentName));
303
+ }
304
+
305
+ export function resolveEnvironmentWebinyIntegration(config, environmentName) {
306
+ const baseConfig = resolveWebinyConfigDefaults(config.integrations?.webiny ?? {});
307
+ const environmentOverride = config.integrations?.webiny?.environments?.[environmentName] ?? {};
308
+
309
+ return {
310
+ enabled: environmentOverride.enabled ?? baseConfig.enabled,
311
+ sourceTableName: environmentOverride.sourceTableName ?? baseConfig.sourceTableName,
312
+ mirrorTableName: environmentOverride.mirrorTableName ?? baseConfig.mirrorTableName,
313
+ relevantModels: environmentOverride.relevantModels
314
+ ? normalizeStringList(environmentOverride.relevantModels)
315
+ : [...baseConfig.relevantModels],
316
+ tenant: environmentOverride.tenant ?? baseConfig.tenant
317
+ };
318
+ }
319
+
222
320
  export function resolveTableNames(config, environmentName) {
223
321
  const context = createPlaceholderContext(config, environmentName);
322
+ const webinyConfig = resolveEnvironmentWebinyIntegration(config, environmentName);
224
323
  return {
225
324
  dependency: replacePlaceholders(config.aws.dependencyStore.tableName, context),
226
325
  content: replacePlaceholders(config.aws.contentStore.tableName, context),
227
326
  invalidation: replacePlaceholders(config.aws.invalidationStore.tableName, context),
228
- webinyMirror: replacePlaceholders(config.integrations.webiny.mirrorTableName, context)
327
+ webinyMirror: replacePlaceholders(webinyConfig.mirrorTableName, context)
229
328
  };
230
329
  }
231
330
 
@@ -240,6 +339,7 @@ export function resolveStackName(config, environmentName) {
240
339
 
241
340
  export function buildEnvironmentRuntimeConfig(config, environmentName, stackOutputs = {}) {
242
341
  const environmentConfig = config.environments[environmentName];
342
+ const webinyConfig = resolveEnvironmentWebinyIntegration(config, environmentName);
243
343
  const tables = resolveTableNames(config, environmentName);
244
344
  const runtimeParameterName = resolveRuntimeManifestParameterName(config, environmentName);
245
345
  const stackName = resolveStackName(config, environmentName);
@@ -249,11 +349,13 @@ export function buildEnvironmentRuntimeConfig(config, environmentName, stackOutp
249
349
  const languages = {};
250
350
  for (const [languageCode, languageConfig] of Object.entries(variantConfig.languages)) {
251
351
  const targetBucket = resolveTargetBucketName(config, environmentName, variantName, languageCode);
352
+ const baseUrl = resolveBaseUrl(config, environmentName, variantName, languageCode);
353
+ const cloudFrontAliases = resolveCloudFrontAliases(config, environmentName, variantName, languageCode);
252
354
  languages[languageCode] = {
253
355
  code: languageCode,
254
- baseUrl: languageConfig.baseUrl,
356
+ baseUrl,
255
357
  targetBucket,
256
- cloudFrontAliases: [...languageConfig.cloudFrontAliases],
358
+ cloudFrontAliases,
257
359
  webinyLocale: languageConfig.webinyLocale,
258
360
  distributionId: stackOutputs.distributionIds?.[variantName]?.[languageCode] ?? "",
259
361
  distributionDomainName: stackOutputs.distributionDomains?.[variantName]?.[languageCode] ?? ""
@@ -284,7 +386,7 @@ export function buildEnvironmentRuntimeConfig(config, environmentName, stackOutp
284
386
  rendering: { ...config.rendering },
285
387
  integrations: {
286
388
  webiny: {
287
- ...config.integrations.webiny,
389
+ ...webinyConfig,
288
390
  mirrorTableName: tables.webinyMirror
289
391
  }
290
392
  },
@@ -363,12 +465,28 @@ export async function validateAndResolveProjectConfig(projectConfig, options = {
363
465
  code: "CONFIG_SCHEMA_ERROR",
364
466
  message: `Variant ${variantName} language ${languageCode} is missing baseUrl.`
365
467
  });
468
+ } else if (!isValidConfiguredHost(languageConfig.baseUrl)) {
469
+ errors.push({
470
+ code: "CONFIG_SCHEMA_ERROR",
471
+ message: `Variant ${variantName} language ${languageCode} baseUrl must be a hostname without protocol or path.`,
472
+ details: { value: languageConfig.baseUrl }
473
+ });
366
474
  }
367
475
  if (!Array.isArray(languageConfig.cloudFrontAliases) || languageConfig.cloudFrontAliases.length === 0) {
368
476
  errors.push({
369
477
  code: "CONFIG_SCHEMA_ERROR",
370
478
  message: `Variant ${variantName} language ${languageCode} needs at least one cloudFrontAlias.`
371
479
  });
480
+ } else {
481
+ for (const alias of languageConfig.cloudFrontAliases) {
482
+ if (!isValidConfiguredHost(alias)) {
483
+ errors.push({
484
+ code: "CONFIG_SCHEMA_ERROR",
485
+ message: `Variant ${variantName} language ${languageCode} cloudFrontAliases must contain hostnames without protocol or path.`,
486
+ details: { value: alias }
487
+ });
488
+ }
489
+ }
372
490
  }
373
491
  if (languageConfig.webinyLocale !== undefined && typeof languageConfig.webinyLocale !== "string") {
374
492
  errors.push({
@@ -383,11 +501,27 @@ export async function validateAndResolveProjectConfig(projectConfig, options = {
383
501
  }
384
502
  }
385
503
 
386
- if (projectConfig.integrations?.webiny?.enabled && !projectConfig.integrations?.webiny?.sourceTableName) {
387
- errors.push({
388
- code: "CONFIG_CONFLICT_ERROR",
389
- message: "Webiny integration requires sourceTableName when enabled."
390
- });
504
+ const configuredWebiny = projectConfig.integrations?.webiny;
505
+ for (const [environmentName] of environmentEntries) {
506
+ const environmentWebinyConfig = resolveEnvironmentWebinyIntegration(resolveProjectConfig({
507
+ ...projectConfig,
508
+ environments: Object.fromEntries(environmentEntries)
509
+ }), environmentName);
510
+ if (environmentWebinyConfig.enabled && !environmentWebinyConfig.sourceTableName) {
511
+ errors.push({
512
+ code: "CONFIG_CONFLICT_ERROR",
513
+ message: `Webiny integration requires sourceTableName when enabled for environment ${environmentName}.`
514
+ });
515
+ }
516
+ }
517
+
518
+ for (const environmentName of Object.keys(configuredWebiny?.environments ?? {})) {
519
+ if (!projectConfig.environments?.[environmentName]) {
520
+ errors.push({
521
+ code: "CONFIG_CONFLICT_ERROR",
522
+ message: `integrations.webiny.environments.${environmentName} does not match a configured environment.`
523
+ });
524
+ }
391
525
  }
392
526
 
393
527
  for (const [variantName, pattern] of Object.entries(projectConfig.aws?.codeBuckets ?? {})) {
@@ -403,8 +537,13 @@ export async function validateAndResolveProjectConfig(projectConfig, options = {
403
537
  if (projectConfig.aws?.invalidationStore?.tableName) {
404
538
  ensureKnownPlaceholders(projectConfig.aws.invalidationStore.tableName, "aws.invalidationStore.tableName", errors);
405
539
  }
406
- if (projectConfig.integrations?.webiny?.mirrorTableName) {
407
- ensureKnownPlaceholders(projectConfig.integrations.webiny.mirrorTableName, "integrations.webiny.mirrorTableName", errors);
540
+ if (configuredWebiny?.mirrorTableName) {
541
+ ensureKnownPlaceholders(configuredWebiny.mirrorTableName, "integrations.webiny.mirrorTableName", errors);
542
+ }
543
+ for (const [environmentName, webinyConfig] of Object.entries(configuredWebiny?.environments ?? {})) {
544
+ if (webinyConfig.mirrorTableName) {
545
+ ensureKnownPlaceholders(webinyConfig.mirrorTableName, `integrations.webiny.environments.${environmentName}.mirrorTableName`, errors);
546
+ }
408
547
  }
409
548
 
410
549
  if (errors.length > 0) {
@@ -414,6 +553,7 @@ export async function validateAndResolveProjectConfig(projectConfig, options = {
414
553
  const resolvedConfig = resolveProjectConfig(projectConfig);
415
554
  const seenTargetBuckets = new Set();
416
555
  const seenCodeBuckets = new Set();
556
+ const seenCloudFrontAliases = new Set();
417
557
 
418
558
  for (const variantConfig of Object.values(resolvedConfig.variants)) {
419
559
  await ensureDirectoryExists(projectDir, variantConfig.sourceDir, errors);
@@ -452,6 +592,16 @@ export async function validateAndResolveProjectConfig(projectConfig, options = {
452
592
  });
453
593
  }
454
594
  seenTargetBuckets.add(targetBucket);
595
+
596
+ for (const alias of resolveCloudFrontAliases(resolvedConfig, environmentName, variantName, languageCode)) {
597
+ if (seenCloudFrontAliases.has(alias)) {
598
+ errors.push({
599
+ code: "CONFIG_CONFLICT_ERROR",
600
+ message: `Duplicate cloudFrontAlias ${alias}.`
601
+ });
602
+ }
603
+ seenCloudFrontAliases.add(alias);
604
+ }
455
605
  }
456
606
  }
457
607
  }
@@ -4,7 +4,10 @@ export { minifyHtml, repairTruncatedHtml } from "./minify.mjs";
4
4
  export {
5
5
  buildEnvironmentRuntimeConfig,
6
6
  loadProjectConfig,
7
+ resolveBaseUrl,
7
8
  resolveCodeBucketName,
9
+ resolveCloudFrontAliases,
10
+ resolveEnvironmentWebinyIntegration,
8
11
  resolveRuntimeManifestParameterName,
9
12
  resolveProjectConfig,
10
13
  resolveStackName,
@@ -4,6 +4,7 @@ import { assert, S3teError } from "./errors.mjs";
4
4
  import { getContentTypeForPath } from "./mime.mjs";
5
5
  import { minifyHtml, repairTruncatedHtml } from "./minify.mjs";
6
6
  import { readContentField, serializeContentValue } from "./content-query.mjs";
7
+ import { resolveBaseUrl } from "./config.mjs";
7
8
 
8
9
  function createWarning(code, message, sourceKey) {
9
10
  return { code, message, sourceKey };
@@ -442,7 +443,7 @@ export async function renderSourceTemplate({ config, templateRepository, content
442
443
  language: languageCode,
443
444
  sourceKey,
444
445
  outputKey: sourceWithinVariant,
445
- baseUrl: buildDefaultBaseUrl(languageConfig.baseUrl)
446
+ baseUrl: buildDefaultBaseUrl(resolveBaseUrl(config, environment, variantName, languageCode))
446
447
  };
447
448
 
448
449
  const trimmed = stripLeadingWhitespace(body);
@@ -527,7 +528,7 @@ export function createManualRenderTargets({ config, templateEntries, environment
527
528
  language: languageCode,
528
529
  sourceKey: templateEntry.key,
529
530
  outputKey,
530
- baseUrl: config.variants[variantName].languages[languageCode].baseUrl
531
+ baseUrl: resolveBaseUrl(config, environment, variantName, languageCode)
531
532
  });
532
533
  }
533
534
  }