@wraps.dev/cli 1.4.0 → 1.5.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/dist/cli.js CHANGED
@@ -146,7 +146,7 @@ var require_package = __commonJS({
146
146
  "package.json"(exports, module) {
147
147
  module.exports = {
148
148
  name: "@wraps.dev/cli",
149
- version: "1.4.0",
149
+ version: "1.5.0",
150
150
  description: "CLI for deploying Wraps email infrastructure to your AWS account",
151
151
  type: "module",
152
152
  main: "./dist/cli.js",
@@ -546,6 +546,12 @@ To remove: wraps destroy --stack ${stackName}`,
546
546
  "PULUMI_NOT_INSTALLED",
547
547
  "Install Pulumi:\n macOS: brew install pulumi/tap/pulumi\n Linux: curl -fsSL https://get.pulumi.com | sh\n Windows: choco install pulumi\n\nOr download from: https://www.pulumi.com/docs/install/",
548
548
  "https://www.pulumi.com/docs/install/"
549
+ ),
550
+ stackLocked: () => new WrapsError(
551
+ "The Pulumi stack is locked from a previous run",
552
+ "STACK_LOCKED",
553
+ "This happens when a previous deployment was interrupted.\n\nTo unlock, run:\n rm -rf ~/.wraps/pulumi/.pulumi/locks\n\nThen try your command again.",
554
+ "https://wraps.dev/docs/troubleshooting"
549
555
  )
550
556
  };
551
557
  }
@@ -3192,6 +3198,35 @@ ${pc2.bold("Dashboard:")} ${pc2.blue("https://app.wraps.dev")}`);
3192
3198
  console.log(`${pc2.bold("Docs:")} ${pc2.blue("https://wraps.dev/docs")}
3193
3199
  `);
3194
3200
  }
3201
+ function displayPreview(outputs) {
3202
+ console.log(pc2.yellow("\n--- PREVIEW MODE (no changes will be made) ---\n"));
3203
+ const changes = outputs.changeSummary;
3204
+ const summaryLines = [];
3205
+ if (changes.create && changes.create > 0) {
3206
+ summaryLines.push(` ${pc2.green("+")} ${changes.create} to create`);
3207
+ }
3208
+ if (changes.update && changes.update > 0) {
3209
+ summaryLines.push(` ${pc2.yellow("~")} ${changes.update} to update`);
3210
+ }
3211
+ if (changes.delete && changes.delete > 0) {
3212
+ summaryLines.push(` ${pc2.red("-")} ${changes.delete} to destroy`);
3213
+ }
3214
+ if (changes.same && changes.same > 0) {
3215
+ summaryLines.push(` ${pc2.dim("=")} ${changes.same} unchanged`);
3216
+ }
3217
+ if (changes.replace && changes.replace > 0) {
3218
+ summaryLines.push(` ${pc2.magenta("+-")} ${changes.replace} to replace`);
3219
+ }
3220
+ if (summaryLines.length > 0) {
3221
+ clack2.note(summaryLines.join("\n"), "Resource Changes");
3222
+ } else {
3223
+ clack2.note("No changes detected", "Resource Changes");
3224
+ }
3225
+ if (outputs.costEstimate) {
3226
+ clack2.note(outputs.costEstimate, "Estimated Monthly Cost");
3227
+ }
3228
+ console.log(pc2.yellow("\n--- END PREVIEW (no changes were made) ---\n"));
3229
+ }
3195
3230
 
3196
3231
  // src/commands/dashboard/update-role.ts
3197
3232
  async function updateRole(options) {
@@ -3792,7 +3827,7 @@ Try running: pnpm build`
3792
3827
  entryPoints: [sourcePath],
3793
3828
  bundle: true,
3794
3829
  platform: "node",
3795
- target: "node20",
3830
+ target: "node24",
3796
3831
  format: "esm",
3797
3832
  outfile: join3(outdir, "index.mjs"),
3798
3833
  external: ["@aws-sdk/*"],
@@ -3864,7 +3899,7 @@ async function deployLambdaFunctions(config2) {
3864
3899
  functionName,
3865
3900
  {
3866
3901
  name: functionName,
3867
- runtime: aws4.lambda.Runtime.NodeJS20dX,
3902
+ runtime: "nodejs24.x",
3868
3903
  handler: "index.handler",
3869
3904
  role: lambdaRole.arn,
3870
3905
  code: new pulumi3.asset.FileArchive(eventProcessorCode),
@@ -3888,7 +3923,7 @@ async function deployLambdaFunctions(config2) {
3888
3923
  }
3889
3924
  ) : new aws4.lambda.Function(functionName, {
3890
3925
  name: functionName,
3891
- runtime: aws4.lambda.Runtime.NodeJS20dX,
3926
+ runtime: "nodejs24.x",
3892
3927
  handler: "index.handler",
3893
3928
  role: lambdaRole.arn,
3894
3929
  code: new pulumi3.asset.FileArchive(eventProcessorCode),
@@ -3957,6 +3992,23 @@ async function configurationSetExists(configSetName, region) {
3957
3992
  return false;
3958
3993
  }
3959
3994
  }
3995
+ async function eventDestinationExists(configSetName, eventDestName, region) {
3996
+ try {
3997
+ const { SESv2Client: SESv2Client5, GetConfigurationSetEventDestinationsCommand } = await import("@aws-sdk/client-sesv2");
3998
+ const ses = new SESv2Client5({ region });
3999
+ const response = await ses.send(
4000
+ new GetConfigurationSetEventDestinationsCommand({
4001
+ ConfigurationSetName: configSetName
4002
+ })
4003
+ );
4004
+ return response.EventDestinations?.some((dest) => dest.Name === eventDestName) ?? false;
4005
+ } catch (error) {
4006
+ if (error.name === "NotFoundException") {
4007
+ return false;
4008
+ }
4009
+ return false;
4010
+ }
4011
+ }
3960
4012
  async function emailIdentityExists(emailIdentity, region) {
3961
4013
  try {
3962
4014
  const { SESv2Client: SESv2Client5, GetEmailIdentityCommand: GetEmailIdentityCommand4 } = await import("@aws-sdk/client-sesv2");
@@ -4010,29 +4062,37 @@ async function createSESResources(config2) {
4010
4062
  });
4011
4063
  if (config2.eventTrackingEnabled) {
4012
4064
  const eventDestName = "wraps-email-eventbridge";
4013
- new aws5.sesv2.ConfigurationSetEventDestination("wraps-email-all-events", {
4014
- configurationSetName: configSet.configurationSetName,
4015
- eventDestinationName: eventDestName,
4016
- eventDestination: {
4017
- enabled: true,
4018
- matchingEventTypes: [
4019
- "SEND",
4020
- "DELIVERY",
4021
- "OPEN",
4022
- "CLICK",
4023
- "BOUNCE",
4024
- "COMPLAINT",
4025
- "REJECT",
4026
- "RENDERING_FAILURE",
4027
- "DELIVERY_DELAY",
4028
- "SUBSCRIPTION"
4029
- ],
4030
- eventBridgeDestination: {
4031
- // SES requires default bus - cannot use custom bus
4032
- eventBusArn: defaultEventBus.arn
4065
+ new aws5.sesv2.ConfigurationSetEventDestination(
4066
+ "wraps-email-all-events",
4067
+ {
4068
+ configurationSetName: configSet.configurationSetName,
4069
+ eventDestinationName: eventDestName,
4070
+ eventDestination: {
4071
+ enabled: true,
4072
+ matchingEventTypes: [
4073
+ "SEND",
4074
+ "DELIVERY",
4075
+ "OPEN",
4076
+ "CLICK",
4077
+ "BOUNCE",
4078
+ "COMPLAINT",
4079
+ "REJECT",
4080
+ "RENDERING_FAILURE",
4081
+ "DELIVERY_DELAY",
4082
+ "SUBSCRIPTION"
4083
+ ],
4084
+ eventBridgeDestination: {
4085
+ // SES requires default bus - cannot use custom bus
4086
+ eventBusArn: defaultEventBus.arn
4087
+ }
4033
4088
  }
4089
+ },
4090
+ {
4091
+ // Import existing resource if it already exists in AWS
4092
+ // This prevents AlreadyExistsException when the resource exists but isn't in Pulumi state
4093
+ import: config2.importExistingEventDestination ? `wraps-email-tracking|${eventDestName}` : void 0
4034
4094
  }
4035
- });
4095
+ );
4036
4096
  }
4037
4097
  let domainIdentity;
4038
4098
  let dkimTokens;
@@ -4244,6 +4304,11 @@ async function deployEmailStack(config2) {
4244
4304
  }
4245
4305
  let sesResources;
4246
4306
  if (emailConfig.tracking?.enabled || emailConfig.eventTracking?.enabled) {
4307
+ const shouldImportEventDest = emailConfig.eventTracking?.enabled && await eventDestinationExists(
4308
+ "wraps-email-tracking",
4309
+ "wraps-email-eventbridge",
4310
+ config2.region
4311
+ );
4247
4312
  sesResources = await createSESResources({
4248
4313
  domain: emailConfig.domain,
4249
4314
  mailFromDomain: emailConfig.mailFromDomain,
@@ -4252,8 +4317,10 @@ async function deployEmailStack(config2) {
4252
4317
  eventTypes: emailConfig.eventTracking?.events,
4253
4318
  eventTrackingEnabled: emailConfig.eventTracking?.enabled,
4254
4319
  // Pass flag to create EventBridge destination
4255
- tlsRequired: emailConfig.tlsRequired
4320
+ tlsRequired: emailConfig.tlsRequired,
4256
4321
  // Require TLS encryption for all emails
4322
+ importExistingEventDestination: shouldImportEventDest
4323
+ // Import if exists to avoid AlreadyExistsException
4257
4324
  });
4258
4325
  }
4259
4326
  let dynamoTables;
@@ -4318,6 +4385,7 @@ async function deployEmailStack(config2) {
4318
4385
 
4319
4386
  // src/commands/email/config.ts
4320
4387
  init_aws();
4388
+ init_errors();
4321
4389
 
4322
4390
  // src/utils/shared/pulumi.ts
4323
4391
  init_esm_shims();
@@ -4350,7 +4418,11 @@ async function ensurePulumiInstalled() {
4350
4418
 
4351
4419
  // src/commands/email/config.ts
4352
4420
  async function config(options) {
4353
- clack3.intro(pc4.bold("Wraps Config - Apply CLI Updates to Infrastructure"));
4421
+ clack3.intro(
4422
+ pc4.bold(
4423
+ options.preview ? "Wraps Config Preview" : "Wraps Config - Apply CLI Updates to Infrastructure"
4424
+ )
4425
+ );
4354
4426
  const progress = new DeploymentProgress();
4355
4427
  const wasAutoInstalled = await progress.execute(
4356
4428
  "Checking Pulumi CLI installation",
@@ -4427,7 +4499,7 @@ ${pc4.bold("Current Configuration:")}
4427
4499
  "Your current configuration will be preserved - no features will be added or removed"
4428
4500
  );
4429
4501
  console.log("");
4430
- if (!options.yes) {
4502
+ if (!(options.yes || options.preview)) {
4431
4503
  const confirmed = await clack3.confirm({
4432
4504
  message: "Proceed with update?",
4433
4505
  initialValue: true
@@ -4447,6 +4519,61 @@ ${pc4.bold("Current Configuration:")}
4447
4519
  vercel: vercelConfig,
4448
4520
  emailConfig: config2
4449
4521
  };
4522
+ if (options.preview) {
4523
+ try {
4524
+ const previewResult = await progress.execute(
4525
+ "Generating update preview",
4526
+ async () => {
4527
+ await ensurePulumiWorkDir();
4528
+ const stack = await pulumi5.automation.LocalWorkspace.createOrSelectStack(
4529
+ {
4530
+ stackName: metadata.services.email?.pulumiStackName || `wraps-${identity.accountId}-${region}`,
4531
+ projectName: "wraps-email",
4532
+ program: async () => {
4533
+ const result2 = await deployEmailStack(stackConfig);
4534
+ return {
4535
+ roleArn: result2.roleArn,
4536
+ configSetName: result2.configSetName,
4537
+ tableName: result2.tableName,
4538
+ region: result2.region,
4539
+ lambdaFunctions: result2.lambdaFunctions,
4540
+ domain: result2.domain,
4541
+ dkimTokens: result2.dkimTokens,
4542
+ customTrackingDomain: result2.customTrackingDomain
4543
+ };
4544
+ }
4545
+ },
4546
+ {
4547
+ workDir: getPulumiWorkDir(),
4548
+ envVars: {
4549
+ PULUMI_CONFIG_PASSPHRASE: "",
4550
+ AWS_REGION: region
4551
+ },
4552
+ secretsProvider: "passphrase"
4553
+ }
4554
+ );
4555
+ await stack.setConfig("aws:region", { value: region });
4556
+ await stack.refresh({ onOutput: () => {
4557
+ } });
4558
+ const result = await stack.preview({ diff: true });
4559
+ return result;
4560
+ }
4561
+ );
4562
+ displayPreview({
4563
+ changeSummary: previewResult.changeSummary,
4564
+ commandName: "wraps email config"
4565
+ });
4566
+ clack3.outro(
4567
+ pc4.green("Preview complete. Run without --preview to update.")
4568
+ );
4569
+ return;
4570
+ } catch (error) {
4571
+ if (error.message?.includes("stack is currently locked")) {
4572
+ throw errors.stackLocked();
4573
+ }
4574
+ throw new Error(`Preview failed: ${error.message}`);
4575
+ }
4576
+ }
4450
4577
  let outputs;
4451
4578
  try {
4452
4579
  outputs = await progress.execute(
@@ -4484,6 +4611,8 @@ ${pc4.bold("Current Configuration:")}
4484
4611
  metadata.services.email?.pulumiStackName || `wraps-${identity.accountId}-${region}`
4485
4612
  );
4486
4613
  await stack.setConfig("aws:region", { value: region });
4614
+ await stack.refresh({ onOutput: () => {
4615
+ } });
4487
4616
  const upResult = await stack.up({ onOutput: () => {
4488
4617
  } });
4489
4618
  const pulumiOutputs = upResult.outputs;
@@ -4500,12 +4629,8 @@ ${pc4.bold("Current Configuration:")}
4500
4629
  }
4501
4630
  );
4502
4631
  } catch (error) {
4503
- clack3.log.error("Infrastructure update failed");
4504
4632
  if (error.message?.includes("stack is currently locked")) {
4505
- clack3.log.warn("\nThe Pulumi stack is locked from a previous run.");
4506
- clack3.log.info("To fix this, run:");
4507
- clack3.log.info(` ${pc4.cyan("rm -rf ~/.wraps/pulumi/.pulumi/locks")}`);
4508
- clack3.log.info("\nThen try running wraps update again.");
4633
+ throw errors.stackLocked();
4509
4634
  }
4510
4635
  throw new Error(`Pulumi update failed: ${error.message}`);
4511
4636
  }
@@ -4546,6 +4671,7 @@ import * as pulumi6 from "@pulumi/pulumi";
4546
4671
  import pc6 from "picocolors";
4547
4672
  init_presets();
4548
4673
  init_aws();
4674
+ init_errors();
4549
4675
  init_prompts();
4550
4676
 
4551
4677
  // src/utils/shared/scanner.ts
@@ -4776,7 +4902,11 @@ async function scanAWSResources(region) {
4776
4902
 
4777
4903
  // src/commands/email/connect.ts
4778
4904
  async function connect(options) {
4779
- clack5.intro(pc6.bold("Wraps Connect - Link Existing Infrastructure"));
4905
+ clack5.intro(
4906
+ pc6.bold(
4907
+ options.preview ? "Wraps Connect Preview" : "Wraps Connect - Link Existing Infrastructure"
4908
+ )
4909
+ );
4780
4910
  const progress = new DeploymentProgress();
4781
4911
  const wasAutoInstalled = await progress.execute(
4782
4912
  "Checking Pulumi CLI installation",
@@ -4854,7 +4984,7 @@ async function connect(options) {
4854
4984
  if (domainIdentities.length > 0) {
4855
4985
  emailConfig.domain = domainIdentities[0];
4856
4986
  }
4857
- if (!options.yes) {
4987
+ if (!(options.yes || options.preview)) {
4858
4988
  const confirmed = await confirmConnect();
4859
4989
  if (!confirmed) {
4860
4990
  clack5.cancel("Connection cancelled.");
@@ -4867,6 +4997,59 @@ async function connect(options) {
4867
4997
  vercel: vercelConfig,
4868
4998
  emailConfig
4869
4999
  };
5000
+ if (options.preview) {
5001
+ try {
5002
+ const previewResult = await progress.execute(
5003
+ "Generating infrastructure preview",
5004
+ async () => {
5005
+ await ensurePulumiWorkDir();
5006
+ const stack = await pulumi6.automation.LocalWorkspace.createOrSelectStack(
5007
+ {
5008
+ stackName: `wraps-${identity.accountId}-${region}`,
5009
+ projectName: "wraps-email",
5010
+ program: async () => {
5011
+ const result2 = await deployEmailStack(stackConfig);
5012
+ return {
5013
+ roleArn: result2.roleArn,
5014
+ configSetName: result2.configSetName,
5015
+ tableName: result2.tableName,
5016
+ region: result2.region,
5017
+ lambdaFunctions: result2.lambdaFunctions,
5018
+ domain: result2.domain,
5019
+ dkimTokens: result2.dkimTokens,
5020
+ customTrackingDomain: result2.customTrackingDomain
5021
+ };
5022
+ }
5023
+ },
5024
+ {
5025
+ workDir: getPulumiWorkDir(),
5026
+ envVars: {
5027
+ PULUMI_CONFIG_PASSPHRASE: "",
5028
+ AWS_REGION: region
5029
+ },
5030
+ secretsProvider: "passphrase"
5031
+ }
5032
+ );
5033
+ await stack.setConfig("aws:region", { value: region });
5034
+ const result = await stack.preview({ diff: true });
5035
+ return result;
5036
+ }
5037
+ );
5038
+ displayPreview({
5039
+ changeSummary: previewResult.changeSummary,
5040
+ commandName: "wraps email connect"
5041
+ });
5042
+ clack5.outro(
5043
+ pc6.green("Preview complete. Run without --preview to connect.")
5044
+ );
5045
+ return;
5046
+ } catch (error) {
5047
+ if (error.message?.includes("stack is currently locked")) {
5048
+ throw errors.stackLocked();
5049
+ }
5050
+ throw new Error(`Preview failed: ${error.message}`);
5051
+ }
5052
+ }
4870
5053
  let outputs;
4871
5054
  try {
4872
5055
  outputs = await progress.execute(
@@ -4920,12 +5103,8 @@ async function connect(options) {
4920
5103
  }
4921
5104
  );
4922
5105
  } catch (error) {
4923
- clack5.log.error("Infrastructure deployment failed");
4924
5106
  if (error.message?.includes("stack is currently locked")) {
4925
- clack5.log.warn("\nThe Pulumi stack is locked from a previous run.");
4926
- clack5.log.info("To fix this, run:");
4927
- clack5.log.info(` ${pc6.cyan("rm -rf ~/.wraps/pulumi/.pulumi/locks")}`);
4928
- clack5.log.info("\nThen try running wraps email connect again.");
5107
+ throw errors.stackLocked();
4929
5108
  }
4930
5109
  throw new Error(`Pulumi deployment failed: ${error.message}`);
4931
5110
  }
@@ -5423,9 +5602,14 @@ import pc8 from "picocolors";
5423
5602
  init_costs();
5424
5603
  init_presets();
5425
5604
  init_aws();
5605
+ init_errors();
5426
5606
  init_prompts();
5427
5607
  async function init(options) {
5428
- clack7.intro(pc8.bold("Wraps Email Infrastructure Setup"));
5608
+ clack7.intro(
5609
+ pc8.bold(
5610
+ options.preview ? "Wraps Email Infrastructure Preview" : "Wraps Email Infrastructure Setup"
5611
+ )
5612
+ );
5429
5613
  const progress = new DeploymentProgress();
5430
5614
  const wasAutoInstalled = await progress.execute(
5431
5615
  "Checking Pulumi CLI installation",
@@ -5508,7 +5692,7 @@ ${pc8.yellow(pc8.bold("Configuration Warnings:"))}`);
5508
5692
  if (vercelConfig) {
5509
5693
  metadata.vercel = vercelConfig;
5510
5694
  }
5511
- if (!options.yes) {
5695
+ if (!(options.yes || options.preview)) {
5512
5696
  const confirmed = await confirmDeploy();
5513
5697
  if (!confirmed) {
5514
5698
  clack7.cancel("Deployment cancelled.");
@@ -5521,6 +5705,64 @@ ${pc8.yellow(pc8.bold("Configuration Warnings:"))}`);
5521
5705
  vercel: vercelConfig,
5522
5706
  emailConfig
5523
5707
  };
5708
+ if (options.preview) {
5709
+ try {
5710
+ const previewResult = await progress.execute(
5711
+ "Generating infrastructure preview",
5712
+ async () => {
5713
+ await ensurePulumiWorkDir();
5714
+ const stack = await pulumi7.automation.LocalWorkspace.createOrSelectStack(
5715
+ {
5716
+ stackName: `wraps-${identity.accountId}-${region}`,
5717
+ projectName: "wraps-email",
5718
+ program: async () => {
5719
+ const result2 = await deployEmailStack(stackConfig);
5720
+ return {
5721
+ roleArn: result2.roleArn,
5722
+ configSetName: result2.configSetName,
5723
+ tableName: result2.tableName,
5724
+ region: result2.region,
5725
+ lambdaFunctions: result2.lambdaFunctions,
5726
+ domain: result2.domain,
5727
+ dkimTokens: result2.dkimTokens,
5728
+ customTrackingDomain: result2.customTrackingDomain,
5729
+ mailFromDomain: result2.mailFromDomain,
5730
+ archiveArn: result2.archiveArn,
5731
+ archivingEnabled: result2.archivingEnabled,
5732
+ archiveRetention: result2.archiveRetention
5733
+ };
5734
+ }
5735
+ },
5736
+ {
5737
+ workDir: getPulumiWorkDir(),
5738
+ envVars: {
5739
+ PULUMI_CONFIG_PASSPHRASE: "",
5740
+ AWS_REGION: region
5741
+ },
5742
+ secretsProvider: "passphrase"
5743
+ }
5744
+ );
5745
+ await stack.setConfig("aws:region", { value: region });
5746
+ const result = await stack.preview({ diff: true });
5747
+ return result;
5748
+ }
5749
+ );
5750
+ displayPreview({
5751
+ changeSummary: previewResult.changeSummary,
5752
+ costEstimate: costSummary,
5753
+ commandName: "wraps email init"
5754
+ });
5755
+ clack7.outro(
5756
+ pc8.green("Preview complete. Run without --preview to deploy.")
5757
+ );
5758
+ return;
5759
+ } catch (error) {
5760
+ if (error.message?.includes("stack is currently locked")) {
5761
+ throw errors.stackLocked();
5762
+ }
5763
+ throw new Error(`Preview failed: ${error.message}`);
5764
+ }
5765
+ }
5524
5766
  let outputs;
5525
5767
  try {
5526
5768
  outputs = await progress.execute(
@@ -5584,12 +5826,8 @@ ${pc8.yellow(pc8.bold("Configuration Warnings:"))}`);
5584
5826
  }
5585
5827
  );
5586
5828
  } catch (error) {
5587
- clack7.log.error("Infrastructure deployment failed");
5588
5829
  if (error.message?.includes("stack is currently locked")) {
5589
- clack7.log.warn("\nThe Pulumi stack is locked from a previous run.");
5590
- clack7.log.info("To fix this, run:");
5591
- clack7.log.info(` ${pc8.cyan("rm -rf ~/.wraps/pulumi/.pulumi/locks")}`);
5592
- clack7.log.info("\nThen try running wraps email init again.");
5830
+ throw errors.stackLocked();
5593
5831
  }
5594
5832
  throw new Error(`Pulumi deployment failed: ${error.message}`);
5595
5833
  }
@@ -5650,7 +5888,11 @@ import * as clack8 from "@clack/prompts";
5650
5888
  import * as pulumi8 from "@pulumi/pulumi";
5651
5889
  import pc9 from "picocolors";
5652
5890
  async function restore(options) {
5653
- clack8.intro(pc9.bold("Wraps Restore - Remove Wraps Infrastructure"));
5891
+ clack8.intro(
5892
+ pc9.bold(
5893
+ options.preview ? "Wraps Restore Preview" : "Wraps Restore - Remove Wraps Infrastructure"
5894
+ )
5895
+ );
5654
5896
  clack8.log.info(
5655
5897
  `${pc9.yellow("Note:")} This will remove all Wraps-managed infrastructure.`
5656
5898
  );
@@ -5697,7 +5939,7 @@ ${pc9.bold("The following Wraps resources will be removed:")}
5697
5939
  }
5698
5940
  console.log(` ${pc9.cyan("\u2713")} IAM Role (wraps-email-role)`);
5699
5941
  console.log("");
5700
- if (!options.force) {
5942
+ if (!(options.force || options.preview)) {
5701
5943
  const confirmed = await clack8.confirm({
5702
5944
  message: "Proceed with removal? This cannot be undone.",
5703
5945
  initialValue: false
@@ -5707,6 +5949,50 @@ ${pc9.bold("The following Wraps resources will be removed:")}
5707
5949
  process.exit(0);
5708
5950
  }
5709
5951
  }
5952
+ if (options.preview) {
5953
+ if (metadata.services.email?.pulumiStackName) {
5954
+ try {
5955
+ const previewResult = await progress.execute(
5956
+ "Generating removal preview",
5957
+ async () => {
5958
+ const stack = await pulumi8.automation.LocalWorkspace.selectStack(
5959
+ {
5960
+ stackName: metadata.services.email.pulumiStackName,
5961
+ projectName: "wraps-email",
5962
+ program: async () => {
5963
+ }
5964
+ // Empty program for destroy
5965
+ },
5966
+ {
5967
+ workDir: getPulumiWorkDir(),
5968
+ envVars: {
5969
+ PULUMI_CONFIG_PASSPHRASE: "",
5970
+ AWS_REGION: region
5971
+ },
5972
+ secretsProvider: "passphrase"
5973
+ }
5974
+ );
5975
+ const result = await stack.preview({ diff: true });
5976
+ return result;
5977
+ }
5978
+ );
5979
+ displayPreview({
5980
+ changeSummary: previewResult.changeSummary,
5981
+ costEstimate: "Monthly cost after removal: $0.00",
5982
+ commandName: "wraps email restore"
5983
+ });
5984
+ clack8.outro(
5985
+ pc9.green(
5986
+ "Preview complete. Run without --preview to remove infrastructure."
5987
+ )
5988
+ );
5989
+ return;
5990
+ } catch (error) {
5991
+ throw new Error(`Preview failed: ${error.message}`);
5992
+ }
5993
+ }
5994
+ return;
5995
+ }
5710
5996
  if (metadata.services.email?.pulumiStackName) {
5711
5997
  await progress.execute("Removing Wraps infrastructure", async () => {
5712
5998
  try {
@@ -5762,9 +6048,14 @@ import pc10 from "picocolors";
5762
6048
  init_costs();
5763
6049
  init_presets();
5764
6050
  init_aws();
6051
+ init_errors();
5765
6052
  init_prompts();
5766
6053
  async function upgrade(options) {
5767
- clack9.intro(pc10.bold("Wraps Upgrade - Enhance Your Email Infrastructure"));
6054
+ clack9.intro(
6055
+ pc10.bold(
6056
+ options.preview ? "Wraps Upgrade Preview" : "Wraps Upgrade - Enhance Your Email Infrastructure"
6057
+ )
6058
+ );
5768
6059
  const progress = new DeploymentProgress();
5769
6060
  const wasAutoInstalled = await progress.execute(
5770
6061
  "Checking Pulumi CLI installation",
@@ -6394,7 +6685,7 @@ ${pc10.bold("Cost Impact:")}`);
6394
6685
  );
6395
6686
  }
6396
6687
  console.log("");
6397
- if (!options.yes) {
6688
+ if (!(options.yes || options.preview)) {
6398
6689
  const confirmed = await clack9.confirm({
6399
6690
  message: "Proceed with upgrade?",
6400
6691
  initialValue: true
@@ -6416,6 +6707,73 @@ ${pc10.bold("Cost Impact:")}`);
6416
6707
  vercel: vercelConfig,
6417
6708
  emailConfig: updatedConfig
6418
6709
  };
6710
+ if (options.preview) {
6711
+ try {
6712
+ const previewResult = await progress.execute(
6713
+ "Generating upgrade preview",
6714
+ async () => {
6715
+ await ensurePulumiWorkDir();
6716
+ const stack = await pulumi9.automation.LocalWorkspace.createOrSelectStack(
6717
+ {
6718
+ stackName: metadata.services.email?.pulumiStackName || `wraps-${identity.accountId}-${region}`,
6719
+ projectName: "wraps-email",
6720
+ program: async () => {
6721
+ const result2 = await deployEmailStack(stackConfig);
6722
+ return {
6723
+ roleArn: result2.roleArn,
6724
+ configSetName: result2.configSetName,
6725
+ tableName: result2.tableName,
6726
+ region: result2.region,
6727
+ lambdaFunctions: result2.lambdaFunctions,
6728
+ domain: result2.domain,
6729
+ dkimTokens: result2.dkimTokens,
6730
+ customTrackingDomain: result2.customTrackingDomain,
6731
+ httpsTrackingEnabled: result2.httpsTrackingEnabled,
6732
+ cloudFrontDomain: result2.cloudFrontDomain,
6733
+ acmCertificateValidationRecords: result2.acmCertificateValidationRecords,
6734
+ archiveArn: result2.archiveArn,
6735
+ archivingEnabled: result2.archivingEnabled,
6736
+ archiveRetention: result2.archiveRetention
6737
+ };
6738
+ }
6739
+ },
6740
+ {
6741
+ workDir: getPulumiWorkDir(),
6742
+ envVars: {
6743
+ PULUMI_CONFIG_PASSPHRASE: "",
6744
+ AWS_REGION: region
6745
+ },
6746
+ secretsProvider: "passphrase"
6747
+ }
6748
+ );
6749
+ await stack.setConfig("aws:region", { value: region });
6750
+ await stack.refresh({ onOutput: () => {
6751
+ } });
6752
+ const result = await stack.preview({ diff: true });
6753
+ return result;
6754
+ }
6755
+ );
6756
+ const costComparison = [
6757
+ `Current: ${formatCost(currentCostData.total.monthly)}/mo`,
6758
+ `After upgrade: ${formatCost(newCostData.total.monthly)}/mo`,
6759
+ costDiff > 0 ? `Change: +${formatCost(costDiff)}/mo` : costDiff < 0 ? `Change: -${formatCost(Math.abs(costDiff))}/mo` : "Change: No cost difference"
6760
+ ].join("\n");
6761
+ displayPreview({
6762
+ changeSummary: previewResult.changeSummary,
6763
+ costEstimate: costComparison,
6764
+ commandName: "wraps email upgrade"
6765
+ });
6766
+ clack9.outro(
6767
+ pc10.green("Preview complete. Run without --preview to upgrade.")
6768
+ );
6769
+ return;
6770
+ } catch (error) {
6771
+ if (error.message?.includes("stack is currently locked")) {
6772
+ throw errors.stackLocked();
6773
+ }
6774
+ throw new Error(`Preview failed: ${error.message}`);
6775
+ }
6776
+ }
6419
6777
  let outputs;
6420
6778
  try {
6421
6779
  outputs = await progress.execute(
@@ -6483,12 +6841,8 @@ ${pc10.bold("Cost Impact:")}`);
6483
6841
  }
6484
6842
  );
6485
6843
  } catch (error) {
6486
- clack9.log.error("Infrastructure upgrade failed");
6487
6844
  if (error.message?.includes("stack is currently locked")) {
6488
- clack9.log.warn("\nThe Pulumi stack is locked from a previous run.");
6489
- clack9.log.info("To fix this, run:");
6490
- clack9.log.info(` ${pc10.cyan("rm -rf ~/.wraps/pulumi/.pulumi/locks")}`);
6491
- clack9.log.info("\nThen try running wraps upgrade again.");
6845
+ throw errors.stackLocked();
6492
6846
  }
6493
6847
  throw new Error(`Pulumi upgrade failed: ${error.message}`);
6494
6848
  }
@@ -7885,14 +8239,18 @@ import * as clack11 from "@clack/prompts";
7885
8239
  import * as pulumi11 from "@pulumi/pulumi";
7886
8240
  import pc12 from "picocolors";
7887
8241
  async function destroy(options) {
7888
- clack11.intro(pc12.bold("Wraps Email Infrastructure Teardown"));
8242
+ clack11.intro(
8243
+ pc12.bold(
8244
+ options.preview ? "Wraps Destruction Preview" : "Wraps Email Infrastructure Teardown"
8245
+ )
8246
+ );
7889
8247
  const progress = new DeploymentProgress();
7890
8248
  const identity = await progress.execute(
7891
8249
  "Validating AWS credentials",
7892
8250
  async () => validateAWSCredentials()
7893
8251
  );
7894
8252
  const region = await getAWSRegion();
7895
- if (!options.force) {
8253
+ if (!(options.force || options.preview)) {
7896
8254
  const confirmed = await clack11.confirm({
7897
8255
  message: pc12.red(
7898
8256
  "Are you sure you want to destroy all Wraps infrastructure?"
@@ -7904,6 +8262,44 @@ async function destroy(options) {
7904
8262
  process.exit(0);
7905
8263
  }
7906
8264
  }
8265
+ if (options.preview) {
8266
+ try {
8267
+ const previewResult = await progress.execute(
8268
+ "Generating destruction preview",
8269
+ async () => {
8270
+ await ensurePulumiWorkDir();
8271
+ const stackName = `wraps-${identity.accountId}-${region}`;
8272
+ let stack;
8273
+ try {
8274
+ stack = await pulumi11.automation.LocalWorkspace.selectStack({
8275
+ stackName,
8276
+ workDir: getPulumiWorkDir()
8277
+ });
8278
+ } catch (_error) {
8279
+ throw new Error("No Wraps infrastructure found to preview");
8280
+ }
8281
+ const result = await stack.preview({ diff: true });
8282
+ return result;
8283
+ }
8284
+ );
8285
+ displayPreview({
8286
+ changeSummary: previewResult.changeSummary,
8287
+ costEstimate: "Monthly cost after destruction: $0.00",
8288
+ commandName: "wraps destroy"
8289
+ });
8290
+ clack11.outro(
8291
+ pc12.green("Preview complete. Run without --preview to destroy.")
8292
+ );
8293
+ return;
8294
+ } catch (error) {
8295
+ progress.stop();
8296
+ if (error.message.includes("No Wraps infrastructure found")) {
8297
+ clack11.log.warn("No Wraps infrastructure found to preview");
8298
+ process.exit(0);
8299
+ }
8300
+ throw new Error(`Preview failed: ${error.message}`);
8301
+ }
8302
+ }
7907
8303
  try {
7908
8304
  await progress.execute(
7909
8305
  "Destroying infrastructure (this may take 2-3 minutes)",
@@ -8142,7 +8538,9 @@ function showHelp() {
8142
8538
  ` ${pc15.cyan("email connect")} Connect to existing AWS SES`
8143
8539
  );
8144
8540
  console.log(` ${pc15.cyan("email domains verify")} Verify domain DNS records`);
8145
- console.log(` ${pc15.cyan("email config")} Update infrastructure`);
8541
+ console.log(
8542
+ ` ${pc15.cyan("email sync")} Apply CLI updates to infrastructure`
8543
+ );
8146
8544
  console.log(` ${pc15.cyan("email upgrade")} Add features`);
8147
8545
  console.log(
8148
8546
  ` ${pc15.cyan("email restore")} Restore original configuration
@@ -8172,6 +8570,9 @@ function showHelp() {
8172
8570
  console.log(` ${pc15.dim("--preset")} Configuration preset`);
8173
8571
  console.log(` ${pc15.dim("-y, --yes")} Skip confirmation prompts`);
8174
8572
  console.log(` ${pc15.dim("-f, --force")} Force destructive operations`);
8573
+ console.log(
8574
+ ` ${pc15.dim("--preview")} Preview changes without deploying`
8575
+ );
8175
8576
  console.log(` ${pc15.dim("-v, --version")} Show version number
8176
8577
  `);
8177
8578
  console.log(
@@ -8231,6 +8632,11 @@ args.options([
8231
8632
  name: "noOpen",
8232
8633
  description: "Don't open browser automatically",
8233
8634
  defaultValue: false
8635
+ },
8636
+ {
8637
+ name: "preview",
8638
+ description: "Preview changes without deploying",
8639
+ defaultValue: false
8234
8640
  }
8235
8641
  ]);
8236
8642
  var flags = args.parse(process.argv);
@@ -8292,13 +8698,15 @@ Check back soon or follow our progress at ${pc15.cyan("https://github.com/wraps-
8292
8698
  region: flags.region,
8293
8699
  domain: flags.domain,
8294
8700
  preset: flags.preset,
8295
- yes: flags.yes
8701
+ yes: flags.yes,
8702
+ preview: flags.preview
8296
8703
  });
8297
8704
  } else {
8298
8705
  await connect({
8299
8706
  provider: flags.provider,
8300
8707
  region: flags.region,
8301
- yes: flags.yes
8708
+ yes: flags.yes,
8709
+ preview: flags.preview
8302
8710
  });
8303
8711
  }
8304
8712
  }
@@ -8336,32 +8744,38 @@ async function run() {
8336
8744
  region: flags.region,
8337
8745
  domain: flags.domain,
8338
8746
  preset: flags.preset,
8339
- yes: flags.yes
8747
+ yes: flags.yes,
8748
+ preview: flags.preview
8340
8749
  });
8341
8750
  break;
8342
8751
  case "connect":
8343
8752
  await connect({
8344
8753
  provider: flags.provider,
8345
8754
  region: flags.region,
8346
- yes: flags.yes
8755
+ yes: flags.yes,
8756
+ preview: flags.preview
8347
8757
  });
8348
8758
  break;
8349
8759
  case "config":
8760
+ case "sync":
8350
8761
  await config({
8351
8762
  region: flags.region,
8352
- yes: flags.yes
8763
+ yes: flags.yes,
8764
+ preview: flags.preview
8353
8765
  });
8354
8766
  break;
8355
8767
  case "upgrade":
8356
8768
  await upgrade({
8357
8769
  region: flags.region,
8358
- yes: flags.yes
8770
+ yes: flags.yes,
8771
+ preview: flags.preview
8359
8772
  });
8360
8773
  break;
8361
8774
  case "restore":
8362
8775
  await restore({
8363
8776
  region: flags.region,
8364
- force: flags.force
8777
+ force: flags.force,
8778
+ preview: flags.preview
8365
8779
  });
8366
8780
  break;
8367
8781
  case "domains": {
@@ -8503,7 +8917,8 @@ Check back soon or follow our progress at ${pc15.cyan("https://github.com/wraps-
8503
8917
  break;
8504
8918
  case "destroy":
8505
8919
  await destroy({
8506
- force: flags.force
8920
+ force: flags.force,
8921
+ preview: flags.preview
8507
8922
  });
8508
8923
  break;
8509
8924
  case "completion":