@projectdochelp/s3te 3.2.3 → 3.3.1

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
@@ -66,7 +66,7 @@ That source sync is not limited to Lambda code. It includes your `.html`, `.part
66
66
 
67
67
  The persistent environment stack contains the long-lived AWS resources such as buckets, Lambda functions, DynamoDB tables, CloudFront distributions and the runtime manifest parameter. The temporary deploy stack exists only so CloudFormation can consume the packaged Lambda artifacts cleanly.
68
68
 
69
- If optional runtime features such as `sitemap` or Webiny are enabled in `s3te.config.json`, the same environment stack also carries those extra Lambdas and event bindings.
69
+ If optional runtime features are enabled in `s3te.config.json`, S3TE extends the deployed AWS runtime accordingly. `sitemap` is added to the main environment stack. Webiny is deployed as a separate option stack so retrofitting CMS support does not require CloudFront resources to move with it.
70
70
 
71
71
  </details>
72
72
 
@@ -484,7 +484,7 @@ Once Webiny is installed and the stack is deployed with Webiny enabled, CMS cont
484
484
  | `s3te sync --env <name>` | Uploads current project sources into the configured code buckets. |
485
485
  | `s3te doctor --env <name>` | Checks local machine and AWS access before deploy. |
486
486
  | `s3te deploy --env <name>` | Deploys or updates the AWS environment and syncs source files. |
487
- | `s3te migrate` | Updates older project configs and can retrofit optional features such as `sitemap` or Webiny into an existing S3TE project. |
487
+ | `s3te option <webiny|sitemap>` | Writes or updates optional feature configuration such as Webiny or sitemap support in an existing S3TE project. |
488
488
 
489
489
  </details>
490
490
 
@@ -929,11 +929,11 @@ Add this block to `s3te.config.json`:
929
929
 
930
930
  The top-level `enabled` acts as the default. `integrations.sitemap.environments.<env>.enabled` can override that for a single environment.
931
931
 
932
- If you prefer the CLI path, this does the same retrofit:
932
+ If you prefer the CLI path, this does the same option update:
933
933
 
934
934
  ```bash
935
- npx s3te migrate --enable-sitemap --write
936
- npx s3te migrate --env test --enable-sitemap --write
935
+ npx s3te option sitemap --enable --write
936
+ npx s3te option sitemap --env test --enable --write
937
937
  ```
938
938
 
939
939
  After enabling or disabling `sitemap`, redeploy the affected environment once:
@@ -989,23 +989,23 @@ This section assumes that S3TE is already installed and deployed. The S3TE-speci
989
989
  3. Manually enable DynamoDB Streams on that Webiny table before the first S3TE deploy with Webiny enabled.
990
990
  Use `NEW_AND_OLD_IMAGES`.
991
991
  Without that stream, `s3te deploy --env <name>` cannot wire the Webiny trigger and fails because the table has no `LatestStreamArn`.
992
- 4. Upgrade your existing S3TE config for Webiny:
992
+ 4. Write the Webiny option into your existing S3TE config:
993
993
 
994
994
  ```bash
995
- npx s3te migrate --enable-webiny --webiny-source-table webiny-1234567 --webiny-tenant root --webiny-model article --write
995
+ npx s3te option webiny --enable --source-table webiny-1234567 --tenant root --model article --write
996
996
  ```
997
997
 
998
- `staticContent` and `staticCodeContent` are kept automatically. Add `--webiny-model` once per custom model you want S3TE to mirror.
998
+ `staticContent` and `staticCodeContent` are kept automatically. Add `--model` once per custom model you want S3TE to mirror.
999
999
 
1000
- `--webiny-model article` means:
1000
+ `--model article` means:
1001
1001
 
1002
1002
  - `article` is the technical Webiny model ID, not the human-readable label shown in the CMS UI.
1003
1003
  - S3TE adds that model ID to `integrations.webiny.relevantModels` in `s3te.config.json`.
1004
1004
  - Only Webiny stream records whose model is listed in `relevantModels` are mirrored into the S3TE content table and can trigger rerendering.
1005
- - If you omit `--webiny-model`, only the built-in defaults `staticContent` and `staticCodeContent` are mirrored.
1006
- - You can pass the flag multiple times for multiple models, for example `--webiny-model article --webiny-model news --webiny-model event`.
1005
+ - If you omit `--model`, only the built-in defaults `staticContent` and `staticCodeContent` are mirrored.
1006
+ - You can pass the flag multiple times for multiple models, for example `--model article --model news --model event`.
1007
1007
 
1008
- That makes the migration example above equivalent to a config that contains:
1008
+ That makes the option example above equivalent to a config that contains:
1009
1009
 
1010
1010
  ```json
1011
1011
  "relevantModels": ["article", "staticContent", "staticCodeContent"]
@@ -1013,11 +1013,11 @@ That makes the migration example above equivalent to a config that contains:
1013
1013
 
1014
1014
  Use this for every Webiny model whose entries should be available to S3TE template commands like `dbitem`, `dbmulti`, `dbmultifile`, `dbmultifileitem`, or `dbpart`.
1015
1015
 
1016
- If different environments should read from different Webiny installations or tenants, run the migration per environment:
1016
+ If different environments should read from different Webiny installations or tenants, run the option command per environment:
1017
1017
 
1018
1018
  ```bash
1019
- npx s3te migrate --env test --enable-webiny --webiny-source-table webiny-test-1234567 --webiny-tenant preview --write
1020
- npx s3te migrate --env prod --enable-webiny --webiny-source-table webiny-live-1234567 --webiny-tenant root --write
1019
+ npx s3te option webiny --env test --enable --source-table webiny-test-1234567 --tenant preview --write
1020
+ npx s3te option webiny --env prod --enable --source-table webiny-live-1234567 --tenant root --write
1021
1021
  ```
1022
1022
 
1023
1023
  5. Verify again that DynamoDB Streams are enabled on the Webiny source table with `NEW_AND_OLD_IMAGES`.
@@ -1037,19 +1037,19 @@ npx s3te doctor --env prod
1037
1037
  npx s3te deploy --env prod
1038
1038
  ```
1039
1039
 
1040
- That deploy updates the existing environment stack and adds the Webiny mirror resources to it. You do not need a fresh S3TE installation. After that, Webiny content changes flow through the deployed AWS resources automatically; only template or asset changes still need `s3te sync --env <name>`.
1040
+ That deploy updates the existing environment stack and, when Webiny is enabled, also deploys the separate Webiny option stack for `content-mirror` and its DynamoDB stream mapping. You do not need a fresh S3TE installation. After that, Webiny content changes flow through the deployed AWS resources automatically; only template or asset changes still need `s3te sync --env <name>`.
1041
1041
 
1042
1042
  Manual versus automatic responsibilities in this step:
1043
1043
 
1044
1044
  - Manual: enable DynamoDB Streams on the Webiny source table
1045
- - Automatic during `s3te deploy`: read `LatestStreamArn`, create the Lambda event source mapping, and wire the S3TE Webiny mirror Lambda into the environment stack
1045
+ - Automatic during `s3te deploy`: read `LatestStreamArn`, create the Lambda event source mapping, and deploy the S3TE Webiny mirror Lambda in the separate Webiny option stack
1046
1046
 
1047
1047
  </details>
1048
1048
 
1049
1049
  <details>
1050
- <summary>What the migration command changes</summary>
1050
+ <summary>What the option command changes</summary>
1051
1051
 
1052
- The migration command writes or updates the `integrations.webiny` block in `s3te.config.json`. A typical result looks like this:
1052
+ The option command writes or updates the `integrations.webiny` block in `s3te.config.json`. A typical result looks like this:
1053
1053
 
1054
1054
  Example config block:
1055
1055
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@projectdochelp/s3te",
3
- "version": "3.2.3",
3
+ "version": "3.3.1",
4
4
  "description": "CLI, render core, AWS adapter, and testkit for S3TemplateEngine projects",
5
5
  "repository": {
6
6
  "type": "git",
@@ -3,6 +3,7 @@ import path from "node:path";
3
3
 
4
4
  import {
5
5
  buildEnvironmentRuntimeConfig,
6
+ resolveOptionStackName,
6
7
  resolveStackName,
7
8
  S3teError
8
9
  } from "../../core/src/index.mjs";
@@ -34,6 +35,15 @@ function temporaryStackName(stackName) {
34
35
  return `${stackName}-deploy-temp`;
35
36
  }
36
37
 
38
+ function stackDoesNotExist(error) {
39
+ const detailsText = [
40
+ error?.message,
41
+ error?.details?.stderr,
42
+ error?.details?.stdout
43
+ ].filter(Boolean).join("\n");
44
+ return /does not exist/i.test(detailsText) || /Stack with id .* does not exist/i.test(detailsText);
45
+ }
46
+
37
47
  async function uploadArtifact({ bucketName, key, bodyPath, region, profile, cwd }) {
38
48
  await runAwsCli(["s3api", "put-object", "--bucket", bucketName, "--key", key, "--body", bodyPath], {
39
49
  region,
@@ -53,6 +63,18 @@ async function describeStack({ stackName, region, profile, cwd }) {
53
63
  return JSON.parse(describedStack.stdout).Stacks?.[0];
54
64
  }
55
65
 
66
+ async function stackExists({ stackName, region, profile, cwd }) {
67
+ try {
68
+ await describeStack({ stackName, region, profile, cwd });
69
+ return true;
70
+ } catch (error) {
71
+ if (stackDoesNotExist(error)) {
72
+ return false;
73
+ }
74
+ throw error;
75
+ }
76
+ }
77
+
56
78
  async function describeStackEvents({ stackName, region, profile, cwd }) {
57
79
  const describedEvents = await runAwsCli(["cloudformation", "describe-stack-events", "--stack-name", stackName, "--output", "json"], {
58
80
  region,
@@ -331,11 +353,37 @@ async function cleanupTemporaryArtifactsStack({
331
353
  });
332
354
  }
333
355
 
356
+ async function deleteCloudFormationStackIfExists({
357
+ stackName,
358
+ region,
359
+ profile,
360
+ cwd
361
+ }) {
362
+ if (!(await stackExists({ stackName, region, profile, cwd }))) {
363
+ return false;
364
+ }
365
+
366
+ await runAwsCli(["cloudformation", "delete-stack", "--stack-name", stackName], {
367
+ region,
368
+ profile,
369
+ cwd,
370
+ errorCode: "ADAPTER_ERROR"
371
+ });
372
+
373
+ await runAwsCli(["cloudformation", "wait", "stack-delete-complete", "--stack-name", stackName], {
374
+ region,
375
+ profile,
376
+ cwd,
377
+ errorCode: "ADAPTER_ERROR"
378
+ });
379
+
380
+ return true;
381
+ }
382
+
334
383
  function buildEnvironmentStackParameters({
335
384
  artifactBucket,
336
385
  uploadedArtifacts,
337
- runtimeManifestValue,
338
- webinyStreamArn = ""
386
+ runtimeManifestValue
339
387
  }) {
340
388
  return [
341
389
  `ArtifactBucket=${artifactBucket}`,
@@ -343,9 +391,19 @@ function buildEnvironmentStackParameters({
343
391
  `RenderWorkerArtifactKey=${uploadedArtifacts.renderWorker}`,
344
392
  `InvalidationSchedulerArtifactKey=${uploadedArtifacts.invalidationScheduler}`,
345
393
  `InvalidationExecutorArtifactKey=${uploadedArtifacts.invalidationExecutor}`,
346
- `ContentMirrorArtifactKey=${uploadedArtifacts.contentMirror}`,
347
394
  `SitemapUpdaterArtifactKey=${uploadedArtifacts.sitemapUpdater}`,
348
- `RuntimeManifestValue=${runtimeManifestValue}`,
395
+ `RuntimeManifestValue=${runtimeManifestValue}`
396
+ ];
397
+ }
398
+
399
+ function buildWebinyStackParameters({
400
+ artifactBucket,
401
+ uploadedArtifacts,
402
+ webinyStreamArn
403
+ }) {
404
+ return [
405
+ `ArtifactBucket=${artifactBucket}`,
406
+ `ContentMirrorArtifactKey=${uploadedArtifacts.contentMirror}`,
349
407
  `WebinySourceTableStreamArn=${webinyStreamArn}`
350
408
  ];
351
409
  }
@@ -365,6 +423,7 @@ export async function deployAwsProject({
365
423
  const requestedFeatureSet = new Set(features);
366
424
  const featureSet = new Set(resolveRequestedFeatures(config, features, environment));
367
425
  const stackName = resolveStackName(config, environment);
426
+ const webinyStackName = resolveOptionStackName(config, environment, "webiny");
368
427
  const tempStackName = temporaryStackName(stackName);
369
428
  const runtimeManifestPath = path.join(projectDir, packageDir ?? path.join("offline", "IAAS", "package", environment), "runtime-manifest.json");
370
429
 
@@ -439,16 +498,36 @@ export async function deployAwsProject({
439
498
  parameterOverrides: buildEnvironmentStackParameters({
440
499
  artifactBucket: tempStack.artifactBucket,
441
500
  uploadedArtifacts,
442
- runtimeManifestValue: "{}",
443
- webinyStreamArn
501
+ runtimeManifestValue: "{}"
444
502
  }),
445
503
  noExecute: plan,
446
504
  stdio
447
505
  });
448
506
 
449
507
  if (plan) {
508
+ if (featureSet.has("webiny")) {
509
+ if (!packaged.manifest.webinyCloudFormationTemplate) {
510
+ throw new S3teError("ADAPTER_ERROR", "Package manifest is missing the Webiny option template.");
511
+ }
512
+ await deployCloudFormationStack({
513
+ stackName: webinyStackName,
514
+ templatePath: path.join(projectDir, packaged.manifest.webinyCloudFormationTemplate),
515
+ region: runtimeConfig.awsRegion,
516
+ profile,
517
+ cwd: projectDir,
518
+ capabilities: ["CAPABILITY_NAMED_IAM"],
519
+ parameterOverrides: buildWebinyStackParameters({
520
+ artifactBucket: tempStack.artifactBucket,
521
+ uploadedArtifacts,
522
+ webinyStreamArn
523
+ }),
524
+ noExecute: true,
525
+ stdio
526
+ });
527
+ }
450
528
  return {
451
529
  stackName,
530
+ optionalStacks: featureSet.has("webiny") ? [webinyStackName] : [],
452
531
  packageDir: packaged.manifest.packageDir,
453
532
  runtimeManifestPath: normalizeRelative(projectDir, runtimeManifestPath),
454
533
  syncedCodeBuckets: [],
@@ -482,12 +561,41 @@ export async function deployAwsProject({
482
561
  parameterOverrides: buildEnvironmentStackParameters({
483
562
  artifactBucket: tempStack.artifactBucket,
484
563
  uploadedArtifacts,
485
- runtimeManifestValue: JSON.stringify(runtimeManifest),
486
- webinyStreamArn
564
+ runtimeManifestValue: JSON.stringify(runtimeManifest)
487
565
  }),
488
566
  stdio
489
567
  });
490
568
 
569
+ let deployedOptionalStacks = [];
570
+ let removedOptionalStacks = [];
571
+ if (featureSet.has("webiny")) {
572
+ if (!packaged.manifest.webinyCloudFormationTemplate) {
573
+ throw new S3teError("ADAPTER_ERROR", "Package manifest is missing the Webiny option template.");
574
+ }
575
+ await deployCloudFormationStack({
576
+ stackName: webinyStackName,
577
+ templatePath: path.join(projectDir, packaged.manifest.webinyCloudFormationTemplate),
578
+ region: runtimeConfig.awsRegion,
579
+ profile,
580
+ cwd: projectDir,
581
+ capabilities: ["CAPABILITY_NAMED_IAM"],
582
+ parameterOverrides: buildWebinyStackParameters({
583
+ artifactBucket: tempStack.artifactBucket,
584
+ uploadedArtifacts,
585
+ webinyStreamArn
586
+ }),
587
+ stdio
588
+ });
589
+ deployedOptionalStacks = [webinyStackName];
590
+ } else if (await deleteCloudFormationStackIfExists({
591
+ stackName: webinyStackName,
592
+ region: runtimeConfig.awsRegion,
593
+ profile,
594
+ cwd: projectDir
595
+ })) {
596
+ removedOptionalStacks = [webinyStackName];
597
+ }
598
+
491
599
  const syncedCodeBuckets = noSync
492
600
  ? []
493
601
  : (await syncPreparedSources({
@@ -511,6 +619,8 @@ export async function deployAwsProject({
511
619
 
512
620
  return {
513
621
  stackName,
622
+ optionalStacks: deployedOptionalStacks,
623
+ removedOptionalStacks,
514
624
  packageDir: packaged.manifest.packageDir,
515
625
  runtimeManifestPath: normalizeRelative(projectDir, runtimeManifestPath),
516
626
  syncedCodeBuckets,
@@ -1,5 +1,5 @@
1
1
  export { writeZipArchive } from "./zip.mjs";
2
- export { buildCloudFormationTemplate, buildTemporaryDeployStackTemplate } from "./template.mjs";
2
+ export { buildCloudFormationTemplate, buildTemporaryDeployStackTemplate, buildWebinyCloudFormationTemplate } from "./template.mjs";
3
3
  export { buildAwsRuntimeManifest, extractStackOutputsMap } from "./manifest.mjs";
4
4
  export { ensureAwsCliAvailable, ensureAwsCredentials, runAwsCli } from "./aws-cli.mjs";
5
5
  export { getConfiguredFeatures, resolveRequestedFeatures } from "./features.mjs";
@@ -11,7 +11,7 @@ import {
11
11
 
12
12
  import { buildAwsRuntimeManifest } from "./manifest.mjs";
13
13
  import { resolveRequestedFeatures } from "./features.mjs";
14
- import { buildCloudFormationTemplate } from "./template.mjs";
14
+ import { buildCloudFormationTemplate, buildWebinyCloudFormationTemplate } from "./template.mjs";
15
15
  import { writeZipArchive } from "./zip.mjs";
16
16
 
17
17
  const ZIP_DATE = new Date("2020-01-01T00:00:00.000Z");
@@ -242,6 +242,7 @@ export async function packageAwsProject({
242
242
 
243
243
  const lambdaDir = path.join(packageDir, "lambda");
244
244
  const templatePath = path.join(packageDir, "cloudformation.template.json");
245
+ const webinyTemplatePath = path.join(packageDir, "cloudformation.webiny.template.json");
245
246
  const packagingManifestPath = path.join(packageDir, "manifest.json");
246
247
  const runtimeManifestSeedPath = path.join(packageDir, "runtime-manifest.base.json");
247
248
  const lambdaEntries = await collectLambdaArchiveEntries();
@@ -292,6 +293,9 @@ export async function packageAwsProject({
292
293
  const runtimeManifestSeed = buildAwsRuntimeManifest({ config, environment });
293
294
 
294
295
  await writeJsonFile(templatePath, template);
296
+ if (resolvedFeatures.includes("webiny") && runtimeConfig.integrations.webiny.enabled) {
297
+ await writeJsonFile(webinyTemplatePath, buildWebinyCloudFormationTemplate({ config, environment }));
298
+ }
295
299
  await writeJsonFile(runtimeManifestSeedPath, runtimeManifestSeed);
296
300
 
297
301
  const manifest = {
@@ -302,6 +306,9 @@ export async function packageAwsProject({
302
306
  runtimeParameterName: runtimeConfig.runtimeParameterName,
303
307
  packageDir: normalizeRelative(projectDir, packageDir),
304
308
  cloudFormationTemplate: normalizeRelative(projectDir, templatePath),
309
+ ...(resolvedFeatures.includes("webiny") && runtimeConfig.integrations.webiny.enabled
310
+ ? { webinyCloudFormationTemplate: normalizeRelative(projectDir, webinyTemplatePath) }
311
+ : {}),
305
312
  runtimeManifestSeed: normalizeRelative(projectDir, runtimeManifestSeedPath),
306
313
  lambdaArtifacts: Object.fromEntries(Object.entries(lambdaArtifacts).map(([name, artifact]) => [
307
314
  name,
@@ -70,7 +70,18 @@ function lambdaRuntimeProperties(runtimeConfig, roleRef, name, keyParameter, han
70
70
  };
71
71
  }
72
72
 
73
- function createExecutionRole(roleName) {
73
+ function buildFunctionNames(runtimeConfig) {
74
+ return {
75
+ sourceDispatcher: `${runtimeConfig.stackPrefix}_s3te_source_dispatcher`,
76
+ renderWorker: `${runtimeConfig.stackPrefix}_s3te_render_worker`,
77
+ invalidationScheduler: `${runtimeConfig.stackPrefix}_s3te_invalidation_scheduler`,
78
+ invalidationExecutor: `${runtimeConfig.stackPrefix}_s3te_invalidation_executor`,
79
+ contentMirror: `${runtimeConfig.stackPrefix}_s3te_content_mirror`,
80
+ sitemapUpdater: `${runtimeConfig.stackPrefix}_s3te_sitemap_updater`
81
+ };
82
+ }
83
+
84
+ function createExecutionRole(roleName, extraStatements = []) {
74
85
  return {
75
86
  Type: "AWS::IAM::Role",
76
87
  Properties: {
@@ -117,7 +128,8 @@ function createExecutionRole(roleName) {
117
128
  "cloudfront:CreateInvalidation"
118
129
  ],
119
130
  Resource: "*"
120
- }
131
+ },
132
+ ...extraStatements
121
133
  ]
122
134
  }
123
135
  }
@@ -132,14 +144,7 @@ export function buildCloudFormationTemplate({ config, environment, features = []
132
144
  const outputs = {};
133
145
  const featureSet = new Set(features);
134
146
 
135
- const functionNames = {
136
- sourceDispatcher: `${runtimeConfig.stackPrefix}_s3te_source_dispatcher`,
137
- renderWorker: `${runtimeConfig.stackPrefix}_s3te_render_worker`,
138
- invalidationScheduler: `${runtimeConfig.stackPrefix}_s3te_invalidation_scheduler`,
139
- invalidationExecutor: `${runtimeConfig.stackPrefix}_s3te_invalidation_executor`,
140
- contentMirror: `${runtimeConfig.stackPrefix}_s3te_content_mirror`,
141
- sitemapUpdater: `${runtimeConfig.stackPrefix}_s3te_sitemap_updater`
142
- };
147
+ const functionNames = buildFunctionNames(runtimeConfig);
143
148
 
144
149
  const parameters = {
145
150
  ArtifactBucket: {
@@ -157,10 +162,6 @@ export function buildCloudFormationTemplate({ config, environment, features = []
157
162
  InvalidationExecutorArtifactKey: {
158
163
  Type: "String"
159
164
  },
160
- ContentMirrorArtifactKey: {
161
- Type: "String",
162
- Default: ""
163
- },
164
165
  SitemapUpdaterArtifactKey: {
165
166
  Type: "String",
166
167
  Default: ""
@@ -168,10 +169,6 @@ export function buildCloudFormationTemplate({ config, environment, features = []
168
169
  RuntimeManifestValue: {
169
170
  Type: "String",
170
171
  Default: "{}"
171
- },
172
- WebinySourceTableStreamArn: {
173
- Type: "String",
174
- Default: ""
175
172
  }
176
173
  };
177
174
 
@@ -361,39 +358,6 @@ export function buildCloudFormationTemplate({ config, environment, features = []
361
358
  }
362
359
  );
363
360
 
364
- if (featureSet.has("webiny") && runtimeConfig.integrations.webiny.enabled) {
365
- resources.ContentMirror = lambdaRuntimeProperties(
366
- runtimeConfig,
367
- "ExecutionRole",
368
- functionNames.contentMirror,
369
- "ContentMirrorArtifactKey",
370
- "content-mirror",
371
- {
372
- Timeout: 300,
373
- MemorySize: 512,
374
- Environment: {
375
- Variables: {
376
- S3TE_ENVIRONMENT: environment,
377
- S3TE_CONTENT_TABLE: runtimeConfig.tables.content,
378
- S3TE_RELEVANT_MODELS: runtimeConfig.integrations.webiny.relevantModels.join(","),
379
- S3TE_WEBINY_TENANT: runtimeConfig.integrations.webiny.tenant ?? "",
380
- S3TE_RENDER_WORKER_NAME: functionNames.renderWorker
381
- }
382
- }
383
- }
384
- );
385
-
386
- resources.ContentMirrorEventSourceMapping = {
387
- Type: "AWS::Lambda::EventSourceMapping",
388
- Properties: {
389
- BatchSize: 10,
390
- StartingPosition: "LATEST",
391
- EventSourceArn: { Ref: "WebinySourceTableStreamArn" },
392
- FunctionName: { Ref: "ContentMirror" }
393
- }
394
- };
395
- }
396
-
397
361
  if (featureSet.has("sitemap") && runtimeConfig.integrations.sitemap.enabled) {
398
362
  resources.SitemapUpdater = lambdaRuntimeProperties(
399
363
  runtimeConfig,
@@ -426,9 +390,6 @@ export function buildCloudFormationTemplate({ config, environment, features = []
426
390
  outputs.InvalidationSchedulerFunctionName = { Value: functionNames.invalidationScheduler };
427
391
  outputs.InvalidationExecutorFunctionName = { Value: functionNames.invalidationExecutor };
428
392
 
429
- if (resources.ContentMirror) {
430
- outputs.ContentMirrorFunctionName = { Value: functionNames.contentMirror };
431
- }
432
393
  if (resources.SitemapUpdater) {
433
394
  outputs.SitemapUpdaterFunctionName = { Value: functionNames.sitemapUpdater };
434
395
  }
@@ -620,6 +581,81 @@ export function buildCloudFormationTemplate({ config, environment, features = []
620
581
  };
621
582
  }
622
583
 
584
+ export function buildWebinyCloudFormationTemplate({ config, environment }) {
585
+ const runtimeConfig = buildEnvironmentRuntimeConfig(config, environment);
586
+ const functionNames = buildFunctionNames(runtimeConfig);
587
+
588
+ return {
589
+ AWSTemplateFormatVersion: "2010-09-09",
590
+ Description: `S3TE Webiny option stack for ${config.project.name} (${environment})`,
591
+ Parameters: {
592
+ ArtifactBucket: {
593
+ Type: "String"
594
+ },
595
+ ContentMirrorArtifactKey: {
596
+ Type: "String"
597
+ },
598
+ WebinySourceTableStreamArn: {
599
+ Type: "String"
600
+ }
601
+ },
602
+ Resources: {
603
+ ExecutionRole: createExecutionRole(`${runtimeConfig.stackPrefix}_s3te_webiny_lambda_runtime`, [
604
+ {
605
+ Effect: "Allow",
606
+ Action: [
607
+ "dynamodb:DescribeStream",
608
+ "dynamodb:GetRecords",
609
+ "dynamodb:GetShardIterator"
610
+ ],
611
+ Resource: { Ref: "WebinySourceTableStreamArn" }
612
+ },
613
+ {
614
+ Effect: "Allow",
615
+ Action: [
616
+ "dynamodb:ListStreams"
617
+ ],
618
+ Resource: "*"
619
+ }
620
+ ]),
621
+ ContentMirror: lambdaRuntimeProperties(
622
+ runtimeConfig,
623
+ "ExecutionRole",
624
+ functionNames.contentMirror,
625
+ "ContentMirrorArtifactKey",
626
+ "content-mirror",
627
+ {
628
+ Timeout: 300,
629
+ MemorySize: 512,
630
+ Environment: {
631
+ Variables: {
632
+ S3TE_ENVIRONMENT: environment,
633
+ S3TE_CONTENT_TABLE: runtimeConfig.tables.content,
634
+ S3TE_RELEVANT_MODELS: runtimeConfig.integrations.webiny.relevantModels.join(","),
635
+ S3TE_WEBINY_TENANT: runtimeConfig.integrations.webiny.tenant ?? "",
636
+ S3TE_RENDER_WORKER_NAME: functionNames.renderWorker
637
+ }
638
+ }
639
+ }
640
+ ),
641
+ ContentMirrorEventSourceMapping: {
642
+ Type: "AWS::Lambda::EventSourceMapping",
643
+ Properties: {
644
+ BatchSize: 10,
645
+ StartingPosition: "LATEST",
646
+ EventSourceArn: { Ref: "WebinySourceTableStreamArn" },
647
+ FunctionName: { Ref: "ContentMirror" }
648
+ }
649
+ }
650
+ },
651
+ Outputs: {
652
+ ContentMirrorFunctionName: {
653
+ Value: functionNames.contentMirror
654
+ }
655
+ }
656
+ };
657
+ }
658
+
623
659
  export function buildTemporaryDeployStackTemplate() {
624
660
  return {
625
661
  AWSTemplateFormatVersion: "2010-09-09",
@@ -3,10 +3,10 @@ import fs from "node:fs/promises";
3
3
  import path from "node:path";
4
4
 
5
5
  import {
6
+ configureProjectOption,
6
7
  deployProject,
7
8
  doctorProject,
8
9
  loadResolvedConfig,
9
- migrateProject,
10
10
  packageProject,
11
11
  renderProject,
12
12
  runProjectTests,
@@ -86,7 +86,7 @@ function printHelp() {
86
86
  " sync\n" +
87
87
  " deploy\n" +
88
88
  " doctor\n" +
89
- " migrate\n"
89
+ " option <webiny|sitemap>\n"
90
90
  );
91
91
  }
92
92
 
@@ -377,30 +377,39 @@ async function main() {
377
377
  return;
378
378
  }
379
379
 
380
- if (command === "migrate") {
380
+ if (command === "option") {
381
+ const optionName = String(options._[0] ?? "").trim().toLowerCase();
382
+ if (!optionName) {
383
+ process.stderr.write("option requires a name such as webiny or sitemap\n");
384
+ process.exitCode = 1;
385
+ return;
386
+ }
381
387
  const configPath = path.resolve(cwd, options.config ?? "s3te.config.json");
382
388
  const rawConfig = JSON.parse(await fs.readFile(configPath, "utf8"));
383
- const migration = await migrateProject(configPath, rawConfig, {
389
+ const optionResult = await configureProjectOption(configPath, rawConfig, {
390
+ optionName,
384
391
  writeChanges: Boolean(options.write) && !Boolean(options["dry-run"]),
385
392
  environment: asArray(options.env)[0],
386
- enableWebiny: Boolean(options["enable-webiny"]),
387
- disableWebiny: Boolean(options["disable-webiny"]),
388
- enableSitemap: Boolean(options["enable-sitemap"]),
389
- disableSitemap: Boolean(options["disable-sitemap"]),
390
- webinySourceTable: options["webiny-source-table"],
391
- webinyTenant: options["webiny-tenant"],
392
- webinyModels: asArray(options["webiny-model"])
393
+ enable: Boolean(options.enable),
394
+ disable: Boolean(options.disable),
395
+ sourceTable: options["source-table"],
396
+ tenant: options.tenant,
397
+ models: asArray(options.model)
393
398
  });
394
399
  if (wantsJson) {
395
- printJson("migrate", true, [], [], startedAt, {
396
- configVersion: migration.config.configVersion,
400
+ printJson(`option ${optionName}`, true, [], [], startedAt, {
401
+ configVersion: optionResult.config.configVersion,
397
402
  wrote: Boolean(options.write) && !Boolean(options["dry-run"]),
398
- changes: migration.changes
403
+ changes: optionResult.changes
399
404
  });
400
405
  return;
401
406
  }
402
- process.stdout.write(options.write ? `Migrated ${configPath}\n` : `Migration preview for ${configPath}: configVersion=${migration.config.configVersion}\n`);
403
- for (const change of migration.changes) {
407
+ process.stdout.write(
408
+ options.write
409
+ ? `Updated option ${optionName} in ${configPath}\n`
410
+ : `Option preview for ${optionName} in ${configPath}: configVersion=${optionResult.config.configVersion}\n`
411
+ );
412
+ for (const change of optionResult.changes) {
404
413
  process.stdout.write(`- ${change}\n`);
405
414
  }
406
415
  return;
@@ -991,128 +991,129 @@ export async function doctorProject(projectDir, configPath, options = {}) {
991
991
  return checks;
992
992
  }
993
993
 
994
- export async function migrateProject(configPath, rawConfig, writeChanges) {
995
- const options = typeof writeChanges === "object" && writeChanges !== null
996
- ? writeChanges
997
- : { writeChanges };
994
+ export async function configureProjectOption(configPath, rawConfig, optionConfiguration) {
995
+ const options = typeof optionConfiguration === "object" && optionConfiguration !== null
996
+ ? optionConfiguration
997
+ : { writeChanges: optionConfiguration };
998
+ const optionName = String(options.optionName ?? "").trim().toLowerCase();
999
+ const targetEnvironment = options.environment ? String(options.environment).trim() : "";
998
1000
  const nextConfig = {
999
1001
  ...rawConfig,
1000
1002
  configVersion: rawConfig.configVersion ?? 1
1001
1003
  };
1002
1004
  const changes = [];
1003
1005
 
1004
- const enableWebiny = Boolean(options.enableWebiny);
1005
- const disableWebiny = Boolean(options.disableWebiny);
1006
- const enableSitemap = Boolean(options.enableSitemap);
1007
- const disableSitemap = Boolean(options.disableSitemap);
1008
- const targetEnvironment = options.environment ? String(options.environment).trim() : "";
1009
- const webinySourceTable = options.webinySourceTable ? String(options.webinySourceTable).trim() : "";
1010
- const webinyTenant = options.webinyTenant ? String(options.webinyTenant).trim() : "";
1011
- const webinyModels = normalizeStringList(options.webinyModels);
1012
-
1013
- if (enableWebiny && disableWebiny) {
1014
- throw new S3teError("CONFIG_CONFLICT_ERROR", "migrate does not allow --enable-webiny and --disable-webiny at the same time.");
1006
+ if (!optionName) {
1007
+ throw new S3teError("CONFIG_CONFLICT_ERROR", "option requires an optionName such as webiny or sitemap.");
1015
1008
  }
1016
- if (enableSitemap && disableSitemap) {
1017
- throw new S3teError("CONFIG_CONFLICT_ERROR", "migrate does not allow --enable-sitemap and --disable-sitemap at the same time.");
1009
+ if (!["webiny", "sitemap"].includes(optionName)) {
1010
+ throw new S3teError("CONFIG_CONFLICT_ERROR", `Unknown option ${optionName}. Supported options: webiny, sitemap.`);
1011
+ }
1012
+ if (targetEnvironment && !nextConfig.environments?.[targetEnvironment]) {
1013
+ throw new S3teError("CONFIG_CONFLICT_ERROR", `Unknown environment for option ${optionName}: ${targetEnvironment}.`);
1018
1014
  }
1019
1015
 
1020
- const touchesWebiny = enableWebiny || disableWebiny || Boolean(webinySourceTable) || Boolean(webinyTenant) || webinyModels.length > 0;
1021
- if (touchesWebiny) {
1022
- if (targetEnvironment && !nextConfig.environments?.[targetEnvironment]) {
1023
- throw new S3teError("CONFIG_CONFLICT_ERROR", `Unknown environment for migrate: ${targetEnvironment}.`);
1024
- }
1016
+ const enable = Boolean(options.enable);
1017
+ const disable = Boolean(options.disable);
1018
+ if (enable && disable) {
1019
+ throw new S3teError("CONFIG_CONFLICT_ERROR", `option ${optionName} does not allow --enable and --disable at the same time.`);
1020
+ }
1025
1021
 
1026
- const existingIntegrations = nextConfig.integrations ?? {};
1027
- const existingWebiny = existingIntegrations.webiny ?? {};
1028
- const existingEnvironmentOverrides = existingWebiny.environments ?? {};
1029
- const existingTargetWebiny = targetEnvironment
1030
- ? (existingEnvironmentOverrides[targetEnvironment] ?? {})
1031
- : existingWebiny;
1032
- const inheritedModels = normalizeStringList(
1033
- existingTargetWebiny.relevantModels
1034
- ?? (targetEnvironment ? existingWebiny.relevantModels : undefined)
1035
- ?? ["staticContent", "staticCodeContent"]
1036
- );
1037
- const shouldEnableWebiny = disableWebiny
1038
- ? false
1039
- : (enableWebiny || Boolean(webinySourceTable) || webinyModels.length > 0
1040
- ? true
1041
- : Boolean(targetEnvironment
1042
- ? (existingTargetWebiny.enabled ?? existingWebiny.enabled)
1043
- : existingWebiny.enabled));
1044
- const nextSourceTableName = webinySourceTable
1045
- || existingTargetWebiny.sourceTableName
1046
- || (targetEnvironment ? existingWebiny.sourceTableName : "")
1047
- || "";
1048
-
1049
- if (shouldEnableWebiny && !nextSourceTableName) {
1050
- throw new S3teError(
1051
- "CONFIG_CONFLICT_ERROR",
1052
- targetEnvironment
1053
- ? `Enabling Webiny for environment ${targetEnvironment} requires --webiny-source-table <table> or an existing sourceTableName.`
1054
- : "Enabling Webiny requires --webiny-source-table <table> or an existing integrations.webiny.sourceTableName."
1022
+ if (optionName === "webiny") {
1023
+ const webinySourceTable = options.sourceTable ? String(options.sourceTable).trim() : "";
1024
+ const webinyTenant = options.tenant ? String(options.tenant).trim() : "";
1025
+ const webinyModels = normalizeStringList(options.models);
1026
+ const touchesWebiny = enable || disable || Boolean(webinySourceTable) || Boolean(webinyTenant) || webinyModels.length > 0;
1027
+
1028
+ if (touchesWebiny) {
1029
+ const existingIntegrations = nextConfig.integrations ?? {};
1030
+ const existingWebiny = existingIntegrations.webiny ?? {};
1031
+ const existingEnvironmentOverrides = existingWebiny.environments ?? {};
1032
+ const existingTargetWebiny = targetEnvironment
1033
+ ? (existingEnvironmentOverrides[targetEnvironment] ?? {})
1034
+ : existingWebiny;
1035
+ const inheritedModels = normalizeStringList(
1036
+ existingTargetWebiny.relevantModels
1037
+ ?? (targetEnvironment ? existingWebiny.relevantModels : undefined)
1038
+ ?? ["staticContent", "staticCodeContent"]
1055
1039
  );
1056
- }
1057
-
1058
- const nextWebinyConfig = {
1059
- enabled: shouldEnableWebiny,
1060
- sourceTableName: nextSourceTableName || undefined,
1061
- mirrorTableName: existingTargetWebiny.mirrorTableName
1062
- ?? (targetEnvironment ? existingWebiny.mirrorTableName : undefined)
1063
- ?? "{stackPrefix}_s3te_content_{project}",
1064
- tenant: webinyTenant || existingTargetWebiny.tenant || (targetEnvironment ? existingWebiny.tenant : undefined) || undefined,
1065
- relevantModels: normalizeStringList([
1066
- ...(inheritedModels.length > 0 ? inheritedModels : ["staticContent", "staticCodeContent"]),
1067
- ...webinyModels
1068
- ])
1069
- };
1040
+ const shouldEnableWebiny = disable
1041
+ ? false
1042
+ : (enable || Boolean(webinySourceTable) || webinyModels.length > 0
1043
+ ? true
1044
+ : Boolean(targetEnvironment
1045
+ ? (existingTargetWebiny.enabled ?? existingWebiny.enabled)
1046
+ : existingWebiny.enabled));
1047
+ const nextSourceTableName = webinySourceTable
1048
+ || existingTargetWebiny.sourceTableName
1049
+ || (targetEnvironment ? existingWebiny.sourceTableName : "")
1050
+ || "";
1051
+
1052
+ if (shouldEnableWebiny && !nextSourceTableName) {
1053
+ throw new S3teError(
1054
+ "CONFIG_CONFLICT_ERROR",
1055
+ targetEnvironment
1056
+ ? `Enabling Webiny for environment ${targetEnvironment} requires --source-table <table> or an existing sourceTableName.`
1057
+ : "Enabling Webiny requires --source-table <table> or an existing integrations.webiny.sourceTableName."
1058
+ );
1059
+ }
1070
1060
 
1071
- nextConfig.integrations = {
1072
- ...existingIntegrations,
1073
- webiny: targetEnvironment
1074
- ? {
1075
- ...existingWebiny,
1076
- environments: {
1077
- ...existingEnvironmentOverrides,
1078
- [targetEnvironment]: nextWebinyConfig
1061
+ const nextWebinyConfig = {
1062
+ enabled: shouldEnableWebiny,
1063
+ sourceTableName: nextSourceTableName || undefined,
1064
+ mirrorTableName: existingTargetWebiny.mirrorTableName
1065
+ ?? (targetEnvironment ? existingWebiny.mirrorTableName : undefined)
1066
+ ?? "{stackPrefix}_s3te_content_{project}",
1067
+ tenant: webinyTenant || existingTargetWebiny.tenant || (targetEnvironment ? existingWebiny.tenant : undefined) || undefined,
1068
+ relevantModels: normalizeStringList([
1069
+ ...(inheritedModels.length > 0 ? inheritedModels : ["staticContent", "staticCodeContent"]),
1070
+ ...webinyModels
1071
+ ])
1072
+ };
1073
+
1074
+ nextConfig.integrations = {
1075
+ ...existingIntegrations,
1076
+ webiny: targetEnvironment
1077
+ ? {
1078
+ ...existingWebiny,
1079
+ environments: {
1080
+ ...existingEnvironmentOverrides,
1081
+ [targetEnvironment]: nextWebinyConfig
1082
+ }
1079
1083
  }
1080
- }
1081
- : {
1082
- ...existingWebiny,
1083
- ...nextWebinyConfig,
1084
- environments: existingEnvironmentOverrides
1085
- }
1086
- };
1084
+ : {
1085
+ ...existingWebiny,
1086
+ ...nextWebinyConfig,
1087
+ ...(Object.keys(existingEnvironmentOverrides).length > 0
1088
+ ? { environments: existingEnvironmentOverrides }
1089
+ : {})
1090
+ }
1091
+ };
1087
1092
 
1088
- const scopeLabel = targetEnvironment ? ` for environment ${targetEnvironment}` : "";
1089
- changes.push(shouldEnableWebiny ? `Enabled Webiny integration${scopeLabel}.` : `Disabled Webiny integration${scopeLabel}.`);
1090
- if (webinySourceTable) {
1091
- changes.push(`Set Webiny source table${scopeLabel} to ${webinySourceTable}.`);
1092
- }
1093
- if (webinyTenant) {
1094
- changes.push(`Set Webiny tenant${scopeLabel} to ${webinyTenant}.`);
1095
- }
1096
- if (webinyModels.length > 0) {
1097
- changes.push(`Added Webiny models${scopeLabel}: ${webinyModels.join(", ")}.`);
1093
+ const scopeLabel = targetEnvironment ? ` for environment ${targetEnvironment}` : "";
1094
+ changes.push(shouldEnableWebiny ? `Enabled Webiny option${scopeLabel}.` : `Disabled Webiny option${scopeLabel}.`);
1095
+ if (webinySourceTable) {
1096
+ changes.push(`Set Webiny source table${scopeLabel} to ${webinySourceTable}.`);
1097
+ }
1098
+ if (webinyTenant) {
1099
+ changes.push(`Set Webiny tenant${scopeLabel} to ${webinyTenant}.`);
1100
+ }
1101
+ if (webinyModels.length > 0) {
1102
+ changes.push(`Added Webiny models${scopeLabel}: ${webinyModels.join(", ")}.`);
1103
+ }
1098
1104
  }
1099
1105
  }
1100
1106
 
1101
- const touchesSitemap = enableSitemap || disableSitemap;
1102
- if (touchesSitemap) {
1103
- if (targetEnvironment && !nextConfig.environments?.[targetEnvironment]) {
1104
- throw new S3teError("CONFIG_CONFLICT_ERROR", `Unknown environment for migrate: ${targetEnvironment}.`);
1105
- }
1106
-
1107
+ if (optionName === "sitemap") {
1107
1108
  const existingIntegrations = nextConfig.integrations ?? {};
1108
1109
  const existingSitemap = existingIntegrations.sitemap ?? {};
1109
1110
  const existingEnvironmentOverrides = existingSitemap.environments ?? {};
1110
1111
  const existingTargetSitemap = targetEnvironment
1111
1112
  ? (existingEnvironmentOverrides[targetEnvironment] ?? {})
1112
1113
  : existingSitemap;
1113
- const nextEnabled = disableSitemap
1114
+ const nextEnabled = disable
1114
1115
  ? false
1115
- : (enableSitemap || Boolean(targetEnvironment
1116
+ : (enable || Boolean(targetEnvironment
1116
1117
  ? (existingTargetSitemap.enabled ?? existingSitemap.enabled)
1117
1118
  : existingSitemap.enabled));
1118
1119
 
@@ -1132,12 +1133,14 @@ export async function migrateProject(configPath, rawConfig, writeChanges) {
1132
1133
  : {
1133
1134
  ...existingSitemap,
1134
1135
  enabled: nextEnabled,
1135
- environments: existingEnvironmentOverrides
1136
+ ...(Object.keys(existingEnvironmentOverrides).length > 0
1137
+ ? { environments: existingEnvironmentOverrides }
1138
+ : {})
1136
1139
  }
1137
1140
  };
1138
1141
 
1139
1142
  const scopeLabel = targetEnvironment ? ` for environment ${targetEnvironment}` : "";
1140
- changes.push(nextEnabled ? `Enabled sitemap integration${scopeLabel}.` : `Disabled sitemap integration${scopeLabel}.`);
1143
+ changes.push(nextEnabled ? `Enabled sitemap option${scopeLabel}.` : `Disabled sitemap option${scopeLabel}.`);
1141
1144
  }
1142
1145
 
1143
1146
  if (options.writeChanges) {
@@ -383,6 +383,10 @@ export function resolveStackName(config, environmentName) {
383
383
  return `${config.environments[environmentName].stackPrefix}-s3te-${config.project.name}`;
384
384
  }
385
385
 
386
+ export function resolveOptionStackName(config, environmentName, optionName) {
387
+ return `${resolveStackName(config, environmentName)}-${String(optionName).trim().toLowerCase()}`;
388
+ }
389
+
386
390
  export function buildEnvironmentRuntimeConfig(config, environmentName, stackOutputs = {}) {
387
391
  const environmentConfig = config.environments[environmentName];
388
392
  const webinyConfig = resolveEnvironmentWebinyIntegration(config, environmentName);
@@ -9,6 +9,7 @@ export {
9
9
  resolveCloudFrontAliases,
10
10
  resolveEnvironmentSitemapIntegration,
11
11
  resolveEnvironmentWebinyIntegration,
12
+ resolveOptionStackName,
12
13
  resolveRuntimeManifestParameterName,
13
14
  resolveProjectConfig,
14
15
  resolveStackName,