@projectdochelp/s3te 3.2.2 → 3.3.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 CHANGED
@@ -17,7 +17,7 @@ This README is the user guide for the rewrite generation. The deeper implementat
17
17
  - [Usage](#usage)
18
18
  - [Daily Workflow](#daily-workflow)
19
19
  - [CLI Commands](#cli-commands)
20
- - [Template Commands](#template-commands)
20
+ - [Template Commands](#template-commands)
21
21
  - [Optional: Sitemap](#optional-sitemap)
22
22
  - [Optional: Webiny CMS](#optional-webiny-cms)
23
23
 
@@ -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,50 +484,142 @@ 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
 
491
- ### Template Commands
491
+ ## Template Commands
492
492
 
493
- These are the core S3TE commands you will use even in a plain HTML-only project.
493
+ S3TE uses literal HTML-like tags inside your `.html` and `.part` files. The tags are case-sensitive, always lowercase, and never use attributes. JSON-based commands must contain valid JSON and reject unknown properties.
494
+
495
+ The commands below are grouped by purpose:
496
+
497
+ - `Core Features` work in every S3TE project.
498
+ - `Webiny Features` are the content-driven commands. Despite the name, they also work with local content files under `offline/content/` when you are not using Webiny yet.
499
+
500
+ ### Core Features
494
501
 
495
502
  <details>
496
- <summary><code>&lt;part&gt;</code> - reuse a partial file</summary>
503
+ <summary><code>&lt;part&gt;</code> - reuse a partial file from <code>partDir</code></summary>
504
+
505
+ **Action**
506
+
507
+ Loads another template fragment from the current variant's `partDir`, renders it recursively, and inserts the result at the current position.
508
+
509
+ **Syntax**
497
510
 
498
511
  ```html
499
512
  <part>head.part</part>
500
513
  ```
501
514
 
515
+ The payload must be a relative path inside `partDir`. Leading `/` and `..` are invalid.
516
+
517
+ **Example**
518
+
519
+ ```html
520
+ <head>
521
+ <part>head.part</part>
522
+ </head>
523
+ ```
524
+
502
525
  </details>
503
526
 
504
527
  <details>
505
528
  <summary><code>&lt;if&gt;</code> - render inline HTML only when a condition matches</summary>
506
529
 
530
+ **Action**
531
+
532
+ Evaluates one inline JSON rule and renders its `template` only when the rule matches the current render target.
533
+
534
+ **Syntax**
535
+
507
536
  ```html
508
537
  <if>{
509
538
  "env": "prod",
539
+ "file": "index.html",
540
+ "not": false,
510
541
  "template": "<meta name='robots' content='all'>"
511
542
  }</if>
512
543
  ```
513
544
 
545
+ Supported JSON properties:
546
+
547
+ - `env` optional: matches the current environment name case-insensitively
548
+ - `file` optional: matches the current output filename, for example `index.html`
549
+ - `not` optional: inverts the final result when `true`
550
+ - `template` required: inline HTML to render when the rule matches
551
+
552
+ If both `env` and `file` are present, both must match.
553
+
554
+ If both conditions are omitted, the tag behaves like an inline template include and always renders its `template`.
555
+
556
+ **Example**
557
+
558
+ ```html
559
+ <if>{
560
+ "env": "prod",
561
+ "template": "<meta name='robots' content='all'>"
562
+ }</if>
563
+ <if>{
564
+ "env": "test",
565
+ "template": "<meta name='robots' content='noindex'>"
566
+ }</if>
567
+ ```
568
+
514
569
  </details>
515
570
 
516
571
  <details>
517
572
  <summary><code>&lt;fileattribute&gt;</code> - print metadata of the current output file</summary>
518
573
 
574
+ **Action**
575
+
576
+ Prints metadata of the file currently being rendered.
577
+
578
+ **Syntax**
579
+
519
580
  ```html
520
581
  <fileattribute>filename</fileattribute>
521
582
  ```
522
583
 
584
+ Currently supported values:
585
+
586
+ - `filename`: the output key relative to the target bucket, for example `news/article-one.html`
587
+
588
+ **Example**
589
+
590
+ ```html
591
+ <link rel="canonical" href="https://<lang>baseurl</lang>/<fileattribute>filename</fileattribute>">
592
+ ```
593
+
523
594
  </details>
524
595
 
525
596
  <details>
526
- <summary><code>&lt;lang&gt;</code> - print the current language metadata</summary>
597
+ <summary><code>&lt;lang&gt;</code> - print current language metadata</summary>
598
+
599
+ **Action**
600
+
601
+ Prints language-related metadata of the current render target.
602
+
603
+ **Syntax**
604
+
605
+ ```html
606
+ <lang>2</lang>
607
+ <lang>baseurl</lang>
608
+ ```
609
+
610
+ Currently supported values:
611
+
612
+ - `2`: the current language code such as `en` or `de`
613
+ - `baseurl`: the resolved base hostname for the current language and environment
614
+
615
+ **Example**
527
616
 
528
617
  ```html
529
618
  <html lang="<lang>2</lang>">
530
- <link rel="canonical" href="https://<lang>baseurl</lang>">
619
+ <head>
620
+ <link rel="canonical" href="https://<lang>baseurl</lang>">
621
+ </head>
622
+ </html>
531
623
  ```
532
624
 
533
625
  </details>
@@ -535,6 +627,12 @@ These are the core S3TE commands you will use even in a plain HTML-only project.
535
627
  <details>
536
628
  <summary><code>&lt;switchlang&gt;</code> - choose inline content by language</summary>
537
629
 
630
+ **Action**
631
+
632
+ Selects the block whose tag name matches the current language and renders only that block.
633
+
634
+ **Syntax**
635
+
538
636
  ```html
539
637
  <switchlang>
540
638
  <de>Willkommen</de>
@@ -542,9 +640,270 @@ These are the core S3TE commands you will use even in a plain HTML-only project.
542
640
  </switchlang>
543
641
  ```
544
642
 
643
+ There is no fallback to `defaultLanguage`. If the current language block is missing, S3TE renders an empty string and records a warning.
644
+
645
+ **Example**
646
+
647
+ ```html
648
+ <p>
649
+ <switchlang>
650
+ <de>Dein ultimatives Website-Werkzeug</de>
651
+ <en>Your ultimate website tool</en>
652
+ </switchlang>
653
+ </p>
654
+ ```
655
+
545
656
  </details>
546
657
 
547
- If you also want content-driven commands such as `dbmulti` or `dbmultifile`, continue with the optional Webiny section below. The same commands can also read from local `offline/content/*.json` files when you are not using Webiny yet.
658
+ ### Webiny Features
659
+
660
+ These commands read from the resolved content repository. With Webiny enabled, that means mirrored Webiny content. Without Webiny, the same commands can read from local JSON files under `offline/content/`.
661
+
662
+ <details>
663
+ <summary><code>&lt;dbpart&gt;</code> - insert one content fragment by <code>contentId</code></summary>
664
+
665
+ **Action**
666
+
667
+ Loads a single content item by `contentId` and inserts its content fragment.
668
+
669
+ S3TE first tries the language-specific field `content&lt;lang&gt;`, for example `contentde`, and falls back to `content` if the language-specific field does not exist.
670
+
671
+ **Syntax**
672
+
673
+ ```html
674
+ <dbpart>impressum</dbpart>
675
+ ```
676
+
677
+ The payload is the content ID, not the internal database record ID.
678
+
679
+ **Example**
680
+
681
+ ```html
682
+ <body>
683
+ <dbpart>impressum</dbpart>
684
+ </body>
685
+ ```
686
+
687
+ </details>
688
+
689
+ <details>
690
+ <summary><code>&lt;dbmulti&gt;</code> - render one inline template for multiple content items</summary>
691
+
692
+ **Action**
693
+
694
+ Queries matching content items and renders the given inline template once for each result.
695
+
696
+ **Syntax**
697
+
698
+ ```html
699
+ <dbmulti>{
700
+ "filter": [
701
+ {"forWebsite": {"BOOL": true}}
702
+ ],
703
+ "filtertype": "equals",
704
+ "limit": 3,
705
+ "template": "<article><h2><dbitem>headline</dbitem></h2></article>"
706
+ }</dbmulti>
707
+ ```
708
+
709
+ Supported JSON properties:
710
+
711
+ - `filter` required: array of legacy DynamoDB-style filter clauses
712
+ - `filtertype` optional: `equals` or `contains`, default is `equals`
713
+ - `limit` optional: maximum number of items to render
714
+ - `template` required: inline template rendered once per match
715
+
716
+ Filter notes:
717
+
718
+ - every filter clause contains exactly one field
719
+ - multiple clauses are combined with logical `AND`
720
+ - `__typename` matches the content model, for example `article`
721
+ - supported legacy value wrappers are `S`, `N`, `BOOL`, `NULL`, and `L`
722
+ - results are sorted deterministically; numeric `order` comes first, then `contentId`, then `id`
723
+
724
+ **Example**
725
+
726
+ ```html
727
+ <dbmulti>{
728
+ "filter": [
729
+ {"__typename": {"S": "article"}},
730
+ {"forWebsite": {"BOOL": true}}
731
+ ],
732
+ "limit": 3,
733
+ "template": "<a href='article-<dbitem>slug</dbitem>.html'><h2><dbitem>headline</dbitem></h2></a>"
734
+ }</dbmulti>
735
+ ```
736
+
737
+ </details>
738
+
739
+ <details>
740
+ <summary><code>&lt;dbmultifile&gt;</code> - generate one output file per content item</summary>
741
+
742
+ **Action**
743
+
744
+ Turns one source template into multiple output files. The content items are selected by filter, and each item produces one rendered file.
745
+
746
+ **Syntax**
747
+
748
+ ```html
749
+ <dbmultifile>{
750
+ "filenamesuffix": "slug",
751
+ "filter": [
752
+ {"__typename": {"S": "article"}}
753
+ ],
754
+ "limit": 10
755
+ }</dbmultifile>
756
+ <!doctype html>
757
+ <html>
758
+ <body>
759
+ <h1><dbmultifileitem>headline</dbmultifileitem></h1>
760
+ </body>
761
+ </html>
762
+ ```
763
+
764
+ Supported JSON properties:
765
+
766
+ - `filenamesuffix` required: field whose value becomes the filename suffix
767
+ - `filter` required: array of legacy DynamoDB-style filter clauses
768
+ - `filtertype` optional: `equals` or `contains`, default is `equals`
769
+ - `limit` optional: maximum number of files to generate
770
+
771
+ Rules:
772
+
773
+ - `dbmultifile` must be the first non-whitespace construct in the file
774
+ - the control block itself is not part of the output
775
+ - generated filenames follow the pattern `<basename>-<suffix>.<ext>`
776
+ - the suffix must not be empty and must not contain `/`, `\\`, or `:`
777
+ - suffixes must be unique within that template
778
+
779
+ **Example**
780
+
781
+ If the source file is `article.html` and the current item has `"slug": "first-article"`, the generated output becomes `article-first-article.html`.
782
+
783
+ ```html
784
+ <dbmultifile>{
785
+ "filenamesuffix": "slug",
786
+ "filter": [
787
+ {"__typename": {"S": "article"}}
788
+ ]
789
+ }</dbmultifile>
790
+ <article>
791
+ <h1><dbmultifileitem>headline</dbmultifileitem></h1>
792
+ </article>
793
+ ```
794
+
795
+ </details>
796
+
797
+ <details>
798
+ <summary><code>&lt;dbitem&gt;</code> - print one field of the current content item</summary>
799
+
800
+ **Action**
801
+
802
+ Reads one field from the current content item. This works inside `dbmulti` templates and inside `dbmultifile` bodies.
803
+
804
+ **Syntax**
805
+
806
+ ```html
807
+ <dbitem>headline</dbitem>
808
+ ```
809
+
810
+ Special field names:
811
+
812
+ - `__typename`
813
+ - `contentId`
814
+ - `id`
815
+ - `locale`
816
+ - `tenant`
817
+ - `_version`
818
+ - `_lastChangedAt`
819
+
820
+ For the field name `content`, S3TE again prefers `content&lt;lang&gt;` over `content`.
821
+
822
+ If the field value is a string array, S3TE serializes it as concatenated HTML links.
823
+
824
+ **Example**
825
+
826
+ ```html
827
+ <dbmulti>{
828
+ "filter": [{"__typename": {"S": "article"}}],
829
+ "template": "<article><h2><dbitem>headline</dbitem></h2><div><dbitem>content</dbitem></div></article>"
830
+ }</dbmulti>
831
+ ```
832
+
833
+ </details>
834
+
835
+ <details>
836
+ <summary><code>&lt;dbmultifileitem&gt;</code> - print or transform fields inside a <code>dbmultifile</code> body</summary>
837
+
838
+ **Action**
839
+
840
+ Reads one field from the current content item and can apply one transformation mode. It is primarily meant for `dbmultifile` bodies, but works wherever a current content item exists.
841
+
842
+ **Syntax**
843
+
844
+ Simple field output:
845
+
846
+ ```html
847
+ <dbmultifileitem>headline</dbmultifileitem>
848
+ ```
849
+
850
+ JSON command mode:
851
+
852
+ ```html
853
+ <dbmultifileitem>{"field":"content","limit":160}</dbmultifileitem>
854
+ ```
855
+
856
+ Supported JSON properties:
857
+
858
+ - `field` required
859
+ - `limit` optional: truncate text to a maximum length and append `...`
860
+ - `limitlow` optional: choose a random length between `limitlow` and `limit`
861
+ - `format` optional: currently only `date`
862
+ - `locale` optional: used with `format: "date"`
863
+ - `divideattag` optional: cut a section out of the field value
864
+ - `startnumber` optional: 1-based occurrence number for the divide start
865
+ - `endnumber` optional: 1-based occurrence number for the divide end
866
+
867
+ Only one transform mode is allowed at a time:
868
+
869
+ - limit mode: `limit` with optional `limitlow`
870
+ - date mode: `format: "date"`
871
+ - divide mode: `divideattag`
872
+
873
+ Date mode formats `de` as `dd.mm.yyyy`. All other locales currently format as `mm/dd/yyyy`.
874
+
875
+ **Examples**
876
+
877
+ Simple field output:
878
+
879
+ ```html
880
+ <dbmultifileitem>headline</dbmultifileitem>
881
+ ```
882
+
883
+ Truncated teaser text:
884
+
885
+ ```html
886
+ <dbmultifileitem>{"field":"content","limit":160}</dbmultifileitem>
887
+ ```
888
+
889
+ Date formatting:
890
+
891
+ ```html
892
+ <dbmultifileitem>{"field":"publishedAt","format":"date","locale":"de"}</dbmultifileitem>
893
+ ```
894
+
895
+ Extract one section from a larger HTML field:
896
+
897
+ ```html
898
+ <dbmultifileitem>{
899
+ "field":"content",
900
+ "divideattag":"<h2>",
901
+ "startnumber":2,
902
+ "endnumber":3
903
+ }</dbmultifileitem>
904
+ ```
905
+
906
+ </details>
548
907
 
549
908
  ## Optional: Sitemap
550
909
 
@@ -570,11 +929,11 @@ Add this block to `s3te.config.json`:
570
929
 
571
930
  The top-level `enabled` acts as the default. `integrations.sitemap.environments.<env>.enabled` can override that for a single environment.
572
931
 
573
- If you prefer the CLI path, this does the same retrofit:
932
+ If you prefer the CLI path, this does the same option update:
574
933
 
575
934
  ```bash
576
- npx s3te migrate --enable-sitemap --write
577
- npx s3te migrate --env test --enable-sitemap --write
935
+ npx s3te option sitemap --enable --write
936
+ npx s3te option sitemap --env test --enable --write
578
937
  ```
579
938
 
580
939
  After enabling or disabling `sitemap`, redeploy the affected environment once:
@@ -609,6 +968,8 @@ You do not need Webiny to use S3TE. Start with plain HTML first. Add Webiny only
609
968
 
610
969
  The supported target for this optional path is Webiny 6.x on its standard AWS deployment model.
611
970
 
971
+ Important for the Webiny path: S3TE does not turn on DynamoDB Streams on your Webiny table for you. You must enable the stream manually on the Webiny source table. S3TE uses that stream as the trigger source for CMS-driven rerendering.
972
+
612
973
  ![S3TE with Webiny](https://user-images.githubusercontent.com/100029932/174443536-7af050de-eea7-4456-81aa-a173863b6ec9.png)
613
974
 
614
975
  <details>
@@ -625,44 +986,70 @@ This section assumes that S3TE is already installed and deployed. The S3TE-speci
625
986
 
626
987
  1. Install Webiny in AWS and finish the Webiny setup first.
627
988
  2. Find the Webiny DynamoDB table that contains the CMS entries you want S3TE to mirror.
628
- 3. Upgrade your existing S3TE config for Webiny:
989
+ 3. Manually enable DynamoDB Streams on that Webiny table before the first S3TE deploy with Webiny enabled.
990
+ Use `NEW_AND_OLD_IMAGES`.
991
+ Without that stream, `s3te deploy --env <name>` cannot wire the Webiny trigger and fails because the table has no `LatestStreamArn`.
992
+ 4. Write the Webiny option into your existing S3TE config:
629
993
 
630
994
  ```bash
631
- 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
+ ```
997
+
998
+ `staticContent` and `staticCodeContent` are kept automatically. Add `--model` once per custom model you want S3TE to mirror.
999
+
1000
+ `--model article` means:
1001
+
1002
+ - `article` is the technical Webiny model ID, not the human-readable label shown in the CMS UI.
1003
+ - S3TE adds that model ID to `integrations.webiny.relevantModels` in `s3te.config.json`.
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 `--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
+
1008
+ That makes the option example above equivalent to a config that contains:
1009
+
1010
+ ```json
1011
+ "relevantModels": ["article", "staticContent", "staticCodeContent"]
632
1012
  ```
633
1013
 
634
- `staticContent` and `staticCodeContent` are kept automatically. Add `--webiny-model` once per custom model you want S3TE to mirror.
1014
+ Use this for every Webiny model whose entries should be available to S3TE template commands like `dbitem`, `dbmulti`, `dbmultifile`, `dbmultifileitem`, or `dbpart`.
635
1015
 
636
- 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:
637
1017
 
638
1018
  ```bash
639
- npx s3te migrate --env test --enable-webiny --webiny-source-table webiny-test-1234567 --webiny-tenant preview --write
640
- 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
641
1021
  ```
642
1022
 
643
- 4. Turn on DynamoDB Streams for the Webiny source table with `NEW_AND_OLD_IMAGES`.
644
- 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" }`.
645
- 6. If your Webiny installation hosts multiple tenants, keep `integrations.webiny.tenant` set so S3TE only mirrors the intended tenant.
646
- 7. Check the project again:
1023
+ 5. Verify again that DynamoDB Streams are enabled on the Webiny source table with `NEW_AND_OLD_IMAGES`.
1024
+ You enable this manually in the AWS console on the Webiny DynamoDB table under `Exports and streams`.
1025
+ S3TE creates the Lambda event source mapping during deploy, but it does not create or enable the table stream itself.
1026
+ 6. 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" }`.
1027
+ 7. If your Webiny installation hosts multiple tenants, keep `integrations.webiny.tenant` set so S3TE only mirrors the intended tenant.
1028
+ 8. Check the project again:
647
1029
 
648
1030
  ```bash
649
1031
  npx s3te doctor --env prod
650
1032
  ```
651
1033
 
652
- 8. Redeploy the existing S3TE environment:
1034
+ 9. Redeploy the existing S3TE environment:
653
1035
 
654
1036
  ```bash
655
1037
  npx s3te deploy --env prod
656
1038
  ```
657
1039
 
658
- 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
+
1042
+ Manual versus automatic responsibilities in this step:
1043
+
1044
+ - Manual: enable DynamoDB Streams on the Webiny source table
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
659
1046
 
660
1047
  </details>
661
1048
 
662
1049
  <details>
663
- <summary>What the migration command changes</summary>
1050
+ <summary>What the option command changes</summary>
664
1051
 
665
- 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:
666
1053
 
667
1054
  Example config block:
668
1055
 
@@ -702,15 +1089,4 @@ For localized Webiny projects, the language block can also carry the mapping exp
702
1089
 
703
1090
  </details>
704
1091
 
705
- <details>
706
- <summary>Content template commands</summary>
707
-
708
- These commands are useful both with Webiny and with local JSON content files:
709
-
710
- - `dbpart`
711
- - `dbmulti`
712
- - `dbmultifile`
713
- - `dbitem`
714
- - `dbmultifileitem`
715
-
716
- </details>
1092
+ The content-driven tags are documented in [Template Commands](#template-commands), section [Webiny Features](#webiny-features).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@projectdochelp/s3te",
3
- "version": "3.2.2",
3
+ "version": "3.3.0",
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,6 +70,17 @@ function lambdaRuntimeProperties(runtimeConfig, roleRef, name, keyParameter, han
70
70
  };
71
71
  }
72
72
 
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
+
73
84
  function createExecutionRole(roleName) {
74
85
  return {
75
86
  Type: "AWS::IAM::Role",
@@ -132,14 +143,7 @@ export function buildCloudFormationTemplate({ config, environment, features = []
132
143
  const outputs = {};
133
144
  const featureSet = new Set(features);
134
145
 
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
- };
146
+ const functionNames = buildFunctionNames(runtimeConfig);
143
147
 
144
148
  const parameters = {
145
149
  ArtifactBucket: {
@@ -157,10 +161,6 @@ export function buildCloudFormationTemplate({ config, environment, features = []
157
161
  InvalidationExecutorArtifactKey: {
158
162
  Type: "String"
159
163
  },
160
- ContentMirrorArtifactKey: {
161
- Type: "String",
162
- Default: ""
163
- },
164
164
  SitemapUpdaterArtifactKey: {
165
165
  Type: "String",
166
166
  Default: ""
@@ -168,10 +168,6 @@ export function buildCloudFormationTemplate({ config, environment, features = []
168
168
  RuntimeManifestValue: {
169
169
  Type: "String",
170
170
  Default: "{}"
171
- },
172
- WebinySourceTableStreamArn: {
173
- Type: "String",
174
- Default: ""
175
171
  }
176
172
  };
177
173
 
@@ -361,39 +357,6 @@ export function buildCloudFormationTemplate({ config, environment, features = []
361
357
  }
362
358
  );
363
359
 
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
360
  if (featureSet.has("sitemap") && runtimeConfig.integrations.sitemap.enabled) {
398
361
  resources.SitemapUpdater = lambdaRuntimeProperties(
399
362
  runtimeConfig,
@@ -426,9 +389,6 @@ export function buildCloudFormationTemplate({ config, environment, features = []
426
389
  outputs.InvalidationSchedulerFunctionName = { Value: functionNames.invalidationScheduler };
427
390
  outputs.InvalidationExecutorFunctionName = { Value: functionNames.invalidationExecutor };
428
391
 
429
- if (resources.ContentMirror) {
430
- outputs.ContentMirrorFunctionName = { Value: functionNames.contentMirror };
431
- }
432
392
  if (resources.SitemapUpdater) {
433
393
  outputs.SitemapUpdaterFunctionName = { Value: functionNames.sitemapUpdater };
434
394
  }
@@ -620,6 +580,64 @@ export function buildCloudFormationTemplate({ config, environment, features = []
620
580
  };
621
581
  }
622
582
 
583
+ export function buildWebinyCloudFormationTemplate({ config, environment }) {
584
+ const runtimeConfig = buildEnvironmentRuntimeConfig(config, environment);
585
+ const functionNames = buildFunctionNames(runtimeConfig);
586
+
587
+ return {
588
+ AWSTemplateFormatVersion: "2010-09-09",
589
+ Description: `S3TE Webiny option stack for ${config.project.name} (${environment})`,
590
+ Parameters: {
591
+ ArtifactBucket: {
592
+ Type: "String"
593
+ },
594
+ ContentMirrorArtifactKey: {
595
+ Type: "String"
596
+ },
597
+ WebinySourceTableStreamArn: {
598
+ Type: "String"
599
+ }
600
+ },
601
+ Resources: {
602
+ ExecutionRole: createExecutionRole(`${runtimeConfig.stackPrefix}_s3te_webiny_lambda_runtime`),
603
+ ContentMirror: lambdaRuntimeProperties(
604
+ runtimeConfig,
605
+ "ExecutionRole",
606
+ functionNames.contentMirror,
607
+ "ContentMirrorArtifactKey",
608
+ "content-mirror",
609
+ {
610
+ Timeout: 300,
611
+ MemorySize: 512,
612
+ Environment: {
613
+ Variables: {
614
+ S3TE_ENVIRONMENT: environment,
615
+ S3TE_CONTENT_TABLE: runtimeConfig.tables.content,
616
+ S3TE_RELEVANT_MODELS: runtimeConfig.integrations.webiny.relevantModels.join(","),
617
+ S3TE_WEBINY_TENANT: runtimeConfig.integrations.webiny.tenant ?? "",
618
+ S3TE_RENDER_WORKER_NAME: functionNames.renderWorker
619
+ }
620
+ }
621
+ }
622
+ ),
623
+ ContentMirrorEventSourceMapping: {
624
+ Type: "AWS::Lambda::EventSourceMapping",
625
+ Properties: {
626
+ BatchSize: 10,
627
+ StartingPosition: "LATEST",
628
+ EventSourceArn: { Ref: "WebinySourceTableStreamArn" },
629
+ FunctionName: { Ref: "ContentMirror" }
630
+ }
631
+ }
632
+ },
633
+ Outputs: {
634
+ ContentMirrorFunctionName: {
635
+ Value: functionNames.contentMirror
636
+ }
637
+ }
638
+ };
639
+ }
640
+
623
641
  export function buildTemporaryDeployStackTemplate() {
624
642
  return {
625
643
  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,