@go-to-k/cdkd 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -2401,7 +2401,9 @@ function toYaml(obj, indent = 0) {
2401
2401
  let result = "\n";
2402
2402
  for (const [key, value] of entries) {
2403
2403
  const safeKey = key.includes(" ") ? `"${key}"` : key;
2404
- if (typeof value === "object" && value !== null) {
2404
+ const isContainer = typeof value === "object" && value !== null;
2405
+ const isEmptyContainer = isContainer && (Array.isArray(value) ? value.length === 0 : Object.keys(value).length === 0);
2406
+ if (isContainer && !isEmptyContainer) {
2405
2407
  result += `${prefix}${safeKey}:${toYaml(value, indent + 1)}`;
2406
2408
  } else {
2407
2409
  result += `${prefix}${safeKey}: ${toYaml(value, indent + 1).trimStart()}`;
@@ -2584,17 +2586,20 @@ async function listCommand(patterns, options) {
2584
2586
  }
2585
2587
  if (options.showDependencies) {
2586
2588
  const records = sorted.map((s) => ({
2587
- id: s.displayName,
2589
+ id: formatDisplayId(s),
2588
2590
  dependencies: [...s.dependencyNames]
2589
2591
  }));
2590
2592
  emitStructured(records, options.json);
2591
2593
  return;
2592
2594
  }
2593
2595
  for (const stack of sorted) {
2594
- process.stdout.write(`${stack.displayName}
2596
+ process.stdout.write(`${formatDisplayId(stack)}
2595
2597
  `);
2596
2598
  }
2597
2599
  }
2600
+ function formatDisplayId(stack) {
2601
+ return stack.displayName === stack.stackName ? stack.displayName : `${stack.displayName} (${stack.stackName})`;
2602
+ }
2598
2603
  function emitStructured(payload, asJson) {
2599
2604
  if (asJson) {
2600
2605
  process.stdout.write(`${JSON.stringify(payload, null, 2)}
@@ -3200,6 +3205,14 @@ import {
3200
3205
  ListObjectsV2Command,
3201
3206
  NoSuchKey
3202
3207
  } from "@aws-sdk/client-s3";
3208
+
3209
+ // src/types/state.ts
3210
+ var STATE_SCHEMA_VERSION_LEGACY = 1;
3211
+ var STATE_SCHEMA_VERSION_CURRENT = 2;
3212
+
3213
+ // src/state/s3-state-backend.ts
3214
+ var LEGACY_KEY_DEPTH = 2;
3215
+ var NEW_KEY_DEPTH = 3;
3203
3216
  var S3StateBackend = class {
3204
3217
  constructor(s3Client, config) {
3205
3218
  this.s3Client = s3Client;
@@ -3207,9 +3220,16 @@ var S3StateBackend = class {
3207
3220
  }
3208
3221
  logger = getLogger().child("S3StateBackend");
3209
3222
  /**
3210
- * Get the S3 key for a stack's state file
3223
+ * Get the new (region-scoped) S3 key for a stack's state file.
3211
3224
  */
3212
- getStateKey(stackName) {
3225
+ getStateKey(stackName, region) {
3226
+ return `${this.config.prefix}/${stackName}/${region}/state.json`;
3227
+ }
3228
+ /**
3229
+ * Get the legacy (pre-region-prefix) S3 key for a stack's state file.
3230
+ * Used for backwards-compatible reads and for the migration delete.
3231
+ */
3232
+ getLegacyStateKey(stackName) {
3213
3233
  return `${this.config.prefix}/${stackName}/state.json`;
3214
3234
  }
3215
3235
  /**
@@ -3235,101 +3255,143 @@ var S3StateBackend = class {
3235
3255
  }
3236
3256
  }
3237
3257
  /**
3238
- * Check if state exists for a stack
3239
- */
3240
- async stateExists(stackName) {
3241
- const key = this.getStateKey(stackName);
3242
- try {
3243
- await this.s3Client.send(
3244
- new HeadObjectCommand2({
3245
- Bucket: this.config.bucket,
3246
- Key: key
3247
- })
3248
- );
3258
+ * Check if state exists for a stack in the given region.
3259
+ *
3260
+ * Returns true for either layout: the new region-scoped key, or the legacy
3261
+ * key when its embedded `region` matches the requested region. This lets
3262
+ * `cdkd state rm <stack> --region X` and `cdkd destroy <stack>` see legacy
3263
+ * state without forcing a write-through migration first.
3264
+ */
3265
+ async stateExists(stackName, region) {
3266
+ const newKey = this.getStateKey(stackName, region);
3267
+ if (await this.headObject(newKey)) {
3249
3268
  return true;
3250
- } catch (error) {
3251
- if (error instanceof NoSuchKey || error.name === "NotFound") {
3252
- return false;
3253
- }
3254
- throw new StateError(
3255
- `Failed to check if state exists for stack '${stackName}': ${error instanceof Error ? error.message : String(error)}`,
3256
- error instanceof Error ? error : void 0
3257
- );
3258
3269
  }
3270
+ return this.legacyMatchesRegion(stackName, region);
3259
3271
  }
3260
3272
  /**
3261
- * Get state for a stack
3273
+ * Get state for a stack, transparently falling back to the legacy key.
3262
3274
  *
3263
- * Note: S3 returns ETag with surrounding quotes (e.g., "abc123").
3264
- * We preserve the quotes as they are required for IfMatch conditions.
3275
+ * Lookup order:
3276
+ * 1. `{prefix}/{stackName}/{region}/state.json` (current `version: 2` key).
3277
+ * 2. `{prefix}/{stackName}/state.json` (legacy `version: 1` key) — only
3278
+ * accepted if its embedded `region` matches the requested region.
3279
+ *
3280
+ * When a legacy hit is returned, `migrationPending` is `true`. Callers that
3281
+ * subsequently `saveState` automatically migrate by writing the new key and
3282
+ * deleting the legacy one (see `saveState`'s `legacyMigration` argument).
3283
+ *
3284
+ * Note: S3 returns ETag with surrounding quotes (e.g., `"abc123"`). We
3285
+ * preserve the quotes — they are required for `IfMatch` conditions.
3265
3286
  */
3266
- async getState(stackName) {
3267
- const key = this.getStateKey(stackName);
3287
+ async getState(stackName, region) {
3288
+ const newKey = this.getStateKey(stackName, region);
3268
3289
  try {
3269
- this.logger.debug(`Getting state for stack: ${stackName}`);
3290
+ this.logger.debug(`Getting state for stack: ${stackName} (${region})`);
3270
3291
  const response = await this.s3Client.send(
3271
3292
  new GetObjectCommand({
3272
3293
  Bucket: this.config.bucket,
3273
- Key: key
3294
+ Key: newKey
3274
3295
  })
3275
3296
  );
3276
3297
  if (!response.Body) {
3277
- throw new StateError(`State file for stack '${stackName}' has no body`);
3298
+ throw new StateError(`State file for stack '${stackName}' (${region}) has no body`);
3278
3299
  }
3279
3300
  if (!response.ETag) {
3280
- throw new StateError(`State file for stack '${stackName}' has no ETag`);
3301
+ throw new StateError(`State file for stack '${stackName}' (${region}) has no ETag`);
3281
3302
  }
3282
3303
  const bodyString = await response.Body.transformToString();
3283
- const state = JSON.parse(bodyString);
3284
- this.logger.debug(`Retrieved state for stack: ${stackName}, ETag: ${response.ETag}`);
3285
- return {
3286
- state,
3287
- etag: response.ETag
3288
- };
3304
+ const state = this.parseStateBody(bodyString, stackName);
3305
+ this.logger.debug(`Retrieved state: ${stackName} (${region}), ETag: ${response.ETag}`);
3306
+ return { state, etag: response.ETag };
3289
3307
  } catch (error) {
3290
- if (error instanceof NoSuchKey || error.name === "NoSuchKey") {
3291
- this.logger.debug(`No existing state for stack: ${stackName}`);
3292
- return null;
3293
- }
3294
- if (error instanceof StateError) {
3295
- throw error;
3308
+ if (!isNoSuchKey(error)) {
3309
+ if (error instanceof StateError)
3310
+ throw error;
3311
+ throw new StateError(
3312
+ `Failed to get state for stack '${stackName}' (${region}): ${error instanceof Error ? error.message : String(error)}`,
3313
+ error instanceof Error ? error : void 0
3314
+ );
3296
3315
  }
3297
- throw new StateError(
3298
- `Failed to get state for stack '${stackName}': ${error instanceof Error ? error.message : String(error)}`,
3299
- error instanceof Error ? error : void 0
3316
+ this.logger.debug(`No state at new key for stack: ${stackName} (${region})`);
3317
+ }
3318
+ const legacy = await this.tryGetLegacy(stackName, region);
3319
+ if (legacy) {
3320
+ this.logger.warn(
3321
+ `Loaded legacy state for stack '${stackName}' from '${this.getLegacyStateKey(stackName)}'. It will be migrated to the region-scoped layout on next save.`
3300
3322
  );
3323
+ return { ...legacy, migrationPending: true };
3301
3324
  }
3325
+ return null;
3302
3326
  }
3303
3327
  /**
3304
- * Save state for a stack with optimistic locking
3328
+ * Save state for a stack with optimistic locking.
3329
+ *
3330
+ * Always writes to the new region-scoped key. The state body is rewritten
3331
+ * with `version: 2` and the supplied region.
3332
+ *
3333
+ * If the caller observed `migrationPending: true` from `getState`, it
3334
+ * should pass the legacy ETag back via `expectedEtag` AND set
3335
+ * `migrateLegacy: true`. After the new key is written successfully, the
3336
+ * legacy key is deleted to complete migration. The legacy delete is a
3337
+ * best-effort follow-up — a failure is logged but does not unwind the new
3338
+ * write.
3305
3339
  *
3306
3340
  * @param stackName Stack name
3341
+ * @param region Target region (load-bearing — part of the S3 key)
3307
3342
  * @param state State to save
3308
- * @param expectedEtag Expected ETag for optimistic locking (optional for new state).
3309
- * Must include quotes if provided (e.g., "abc123")
3310
- * @returns New ETag (with quotes, e.g., "abc123")
3311
- */
3312
- async saveState(stackName, state, expectedEtag) {
3313
- const key = this.getStateKey(stackName);
3343
+ * @param options Optimistic-lock ETag + legacy-migration flag
3344
+ * @returns New ETag (with quotes, e.g., `"abc123"`)
3345
+ */
3346
+ async saveState(stackName, region, state, options = {}) {
3347
+ const newKey = this.getStateKey(stackName, region);
3348
+ const { expectedEtag, migrateLegacy } = options;
3349
+ const body = {
3350
+ ...state,
3351
+ version: STATE_SCHEMA_VERSION_CURRENT,
3352
+ stackName,
3353
+ region
3354
+ };
3314
3355
  try {
3315
3356
  this.logger.debug(
3316
- `Saving state for stack: ${stackName}${expectedEtag ? `, expected ETag: ${expectedEtag}` : ""}`
3357
+ `Saving state: ${stackName} (${region})${expectedEtag ? `, expected ETag: ${expectedEtag}` : ""}`
3317
3358
  );
3318
- const body = JSON.stringify(state, null, 2);
3359
+ const bodyString = JSON.stringify(body, null, 2);
3319
3360
  const response = await this.s3Client.send(
3320
3361
  new PutObjectCommand2({
3321
3362
  Bucket: this.config.bucket,
3322
- Key: key,
3323
- Body: body,
3324
- ContentLength: Buffer.byteLength(body),
3363
+ Key: newKey,
3364
+ Body: bodyString,
3365
+ ContentLength: Buffer.byteLength(bodyString),
3325
3366
  ContentType: "application/json",
3326
- ...expectedEtag && { IfMatch: expectedEtag }
3367
+ // The legacy ETag is for a different key; only forward it when we're
3368
+ // updating in-place at the new key.
3369
+ ...!migrateLegacy && expectedEtag && { IfMatch: expectedEtag }
3327
3370
  })
3328
3371
  );
3329
3372
  if (!response.ETag) {
3330
- throw new StateError(`No ETag returned after saving state for stack '${stackName}'`);
3373
+ throw new StateError(
3374
+ `No ETag returned after saving state for stack '${stackName}' (${region})`
3375
+ );
3376
+ }
3377
+ this.logger.debug(`State saved: ${stackName} (${region}), new ETag: ${response.ETag}`);
3378
+ if (migrateLegacy) {
3379
+ try {
3380
+ await this.s3Client.send(
3381
+ new DeleteObjectCommand({
3382
+ Bucket: this.config.bucket,
3383
+ Key: this.getLegacyStateKey(stackName)
3384
+ })
3385
+ );
3386
+ this.logger.info(
3387
+ `Migrated state for stack '${stackName}' to region-scoped layout (${region})`
3388
+ );
3389
+ } catch (deleteError) {
3390
+ this.logger.warn(
3391
+ `Migrated stack '${stackName}' to new key, but failed to delete legacy key: ${deleteError instanceof Error ? deleteError.message : String(deleteError)}`
3392
+ );
3393
+ }
3331
3394
  }
3332
- this.logger.debug(`State saved for stack: ${stackName}, new ETag: ${response.ETag}`);
3333
3395
  return response.ETag;
3334
3396
  } catch (error) {
3335
3397
  if (error.name === "PreconditionFailed") {
@@ -3338,63 +3400,230 @@ var S3StateBackend = class {
3338
3400
  );
3339
3401
  }
3340
3402
  throw new StateError(
3341
- `Failed to save state for stack '${stackName}': ${error instanceof Error ? error.message : String(error)}`,
3403
+ `Failed to save state for stack '${stackName}' (${region}): ${error instanceof Error ? error.message : String(error)}`,
3342
3404
  error instanceof Error ? error : void 0
3343
3405
  );
3344
3406
  }
3345
3407
  }
3346
3408
  /**
3347
- * Delete state for a stack
3409
+ * Delete state for a stack in the given region.
3410
+ *
3411
+ * Removes both the new key and the legacy key (if present). Legacy removal
3412
+ * is region-conditional: a legacy state file with a different `region`
3413
+ * field is left alone.
3348
3414
  */
3349
- async deleteState(stackName) {
3350
- const key = this.getStateKey(stackName);
3415
+ async deleteState(stackName, region) {
3351
3416
  try {
3352
- this.logger.debug(`Deleting state for stack: ${stackName}`);
3417
+ this.logger.debug(`Deleting state: ${stackName} (${region})`);
3353
3418
  await this.s3Client.send(
3354
3419
  new DeleteObjectCommand({
3355
3420
  Bucket: this.config.bucket,
3356
- Key: key
3421
+ Key: this.getStateKey(stackName, region)
3357
3422
  })
3358
3423
  );
3359
- this.logger.debug(`State deleted for stack: ${stackName}`);
3424
+ if (await this.legacyMatchesRegion(stackName, region)) {
3425
+ await this.s3Client.send(
3426
+ new DeleteObjectCommand({
3427
+ Bucket: this.config.bucket,
3428
+ Key: this.getLegacyStateKey(stackName)
3429
+ })
3430
+ );
3431
+ this.logger.debug(`Deleted legacy state for stack: ${stackName}`);
3432
+ }
3433
+ this.logger.debug(`State deleted: ${stackName} (${region})`);
3360
3434
  } catch (error) {
3361
3435
  throw new StateError(
3362
- `Failed to delete state for stack '${stackName}': ${error instanceof Error ? error.message : String(error)}`,
3436
+ `Failed to delete state for stack '${stackName}' (${region}): ${error instanceof Error ? error.message : String(error)}`,
3363
3437
  error instanceof Error ? error : void 0
3364
3438
  );
3365
3439
  }
3366
3440
  }
3367
3441
  /**
3368
- * List all stacks with state
3442
+ * List all stacks with state in the bucket.
3443
+ *
3444
+ * Returns one `{stackName, region}` pair per state file. Both layouts
3445
+ * are enumerated:
3446
+ *
3447
+ * - `{prefix}/{stackName}/{region}/state.json` (new) — `region` is the
3448
+ * path segment.
3449
+ * - `{prefix}/{stackName}/state.json` (legacy) — `region` is read from the
3450
+ * state body when present, otherwise `undefined`.
3451
+ *
3452
+ * Pairs are deduplicated by `(stackName, region)` so a stack mid-migration
3453
+ * shows up exactly once.
3369
3454
  */
3370
3455
  async listStacks() {
3371
3456
  try {
3372
3457
  this.logger.debug("Listing all stacks");
3458
+ const prefix = `${this.config.prefix}/`;
3459
+ const refs = [];
3460
+ const seen = /* @__PURE__ */ new Set();
3461
+ let continuationToken;
3462
+ do {
3463
+ const response = await this.s3Client.send(
3464
+ new ListObjectsV2Command({
3465
+ Bucket: this.config.bucket,
3466
+ Prefix: prefix,
3467
+ ...continuationToken && { ContinuationToken: continuationToken }
3468
+ })
3469
+ );
3470
+ for (const obj of response.Contents ?? []) {
3471
+ const key = obj.Key;
3472
+ if (!key)
3473
+ continue;
3474
+ if (!key.endsWith("/state.json"))
3475
+ continue;
3476
+ const rest = key.slice(prefix.length);
3477
+ const segments = rest.split("/");
3478
+ if (segments.length === NEW_KEY_DEPTH) {
3479
+ const [stackName, region] = segments;
3480
+ if (!stackName || !region)
3481
+ continue;
3482
+ const dedupeKey = `${stackName}\0${region}`;
3483
+ if (!seen.has(dedupeKey)) {
3484
+ seen.add(dedupeKey);
3485
+ refs.push({ stackName, region });
3486
+ }
3487
+ continue;
3488
+ }
3489
+ if (segments.length === LEGACY_KEY_DEPTH) {
3490
+ const [stackName] = segments;
3491
+ if (!stackName)
3492
+ continue;
3493
+ const region = await this.readLegacyRegion(stackName);
3494
+ const dedupeKey = `${stackName}\0${region ?? ""}`;
3495
+ if (!seen.has(dedupeKey)) {
3496
+ seen.add(dedupeKey);
3497
+ refs.push({ stackName, ...region ? { region } : {} });
3498
+ }
3499
+ }
3500
+ }
3501
+ continuationToken = response.IsTruncated ? response.NextContinuationToken : void 0;
3502
+ } while (continuationToken);
3503
+ this.logger.debug(`Found ${refs.length} stack(s) across regions`);
3504
+ return refs;
3505
+ } catch (error) {
3506
+ throw new StateError(
3507
+ `Failed to list stacks: ${error instanceof Error ? error.message : String(error)}`,
3508
+ error instanceof Error ? error : void 0
3509
+ );
3510
+ }
3511
+ }
3512
+ /**
3513
+ * HeadObject probe — returns true on 200, false on NotFound. Other errors
3514
+ * propagate so we don't accidentally swallow IAM denials.
3515
+ */
3516
+ async headObject(key) {
3517
+ try {
3518
+ await this.s3Client.send(
3519
+ new HeadObjectCommand2({
3520
+ Bucket: this.config.bucket,
3521
+ Key: key
3522
+ })
3523
+ );
3524
+ return true;
3525
+ } catch (error) {
3526
+ if (isNoSuchKey(error) || error.name === "NotFound") {
3527
+ return false;
3528
+ }
3529
+ throw error;
3530
+ }
3531
+ }
3532
+ /**
3533
+ * Read the legacy state's `region` field. Used for region matching during
3534
+ * `stateExists` / `deleteState` and for assigning a region to legacy
3535
+ * entries during `listStacks`.
3536
+ */
3537
+ async readLegacyRegion(stackName) {
3538
+ try {
3373
3539
  const response = await this.s3Client.send(
3374
- new ListObjectsV2Command({
3540
+ new GetObjectCommand({
3375
3541
  Bucket: this.config.bucket,
3376
- Prefix: `${this.config.prefix}/`,
3377
- Delimiter: "/"
3542
+ Key: this.getLegacyStateKey(stackName)
3378
3543
  })
3379
3544
  );
3380
- if (!response.CommonPrefixes) {
3381
- return [];
3545
+ if (!response.Body)
3546
+ return void 0;
3547
+ const bodyString = await response.Body.transformToString();
3548
+ const state = JSON.parse(bodyString);
3549
+ return typeof state.region === "string" ? state.region : void 0;
3550
+ } catch (error) {
3551
+ if (isNoSuchKey(error))
3552
+ return void 0;
3553
+ this.logger.debug(
3554
+ `Could not read legacy state region for '${stackName}': ${error instanceof Error ? error.message : String(error)}`
3555
+ );
3556
+ return void 0;
3557
+ }
3558
+ }
3559
+ async legacyMatchesRegion(stackName, region) {
3560
+ const legacyRegion = await this.readLegacyRegion(stackName);
3561
+ return legacyRegion === region;
3562
+ }
3563
+ /**
3564
+ * Try to read the legacy `version: 1` state. Returns null when the legacy
3565
+ * key is missing or its embedded region does not match the caller's region.
3566
+ */
3567
+ async tryGetLegacy(stackName, region) {
3568
+ try {
3569
+ const response = await this.s3Client.send(
3570
+ new GetObjectCommand({
3571
+ Bucket: this.config.bucket,
3572
+ Key: this.getLegacyStateKey(stackName)
3573
+ })
3574
+ );
3575
+ if (!response.Body || !response.ETag) {
3576
+ return null;
3577
+ }
3578
+ const bodyString = await response.Body.transformToString();
3579
+ const state = this.parseStateBody(bodyString, stackName);
3580
+ if (state.region && state.region !== region) {
3581
+ this.logger.debug(
3582
+ `Legacy state for stack '${stackName}' has region '${state.region}', not '${region}' \u2014 skipping legacy fallback.`
3583
+ );
3584
+ return null;
3382
3585
  }
3383
- const stackNames = response.CommonPrefixes.map((prefix) => {
3384
- const prefixStr = prefix.Prefix || "";
3385
- const parts = prefixStr.split("/");
3386
- return parts[parts.length - 2];
3387
- }).filter((name) => Boolean(name));
3388
- this.logger.debug(`Found ${stackNames.length} stacks`);
3389
- return stackNames;
3586
+ return { state, etag: response.ETag };
3390
3587
  } catch (error) {
3588
+ if (isNoSuchKey(error))
3589
+ return null;
3391
3590
  throw new StateError(
3392
- `Failed to list stacks: ${error instanceof Error ? error.message : String(error)}`,
3591
+ `Failed to get legacy state for stack '${stackName}': ${error instanceof Error ? error.message : String(error)}`,
3592
+ error instanceof Error ? error : void 0
3593
+ );
3594
+ }
3595
+ }
3596
+ /**
3597
+ * Parse a state body and validate the schema version. Future-proofs against
3598
+ * a binary that predates schema version `N` reading a `version: N+1` blob:
3599
+ * the old binary would otherwise treat unknown fields as defaults and
3600
+ * silently lose data on the next save.
3601
+ */
3602
+ parseStateBody(bodyString, stackName) {
3603
+ let parsed;
3604
+ try {
3605
+ parsed = JSON.parse(bodyString);
3606
+ } catch (error) {
3607
+ throw new StateError(
3608
+ `State file for stack '${stackName}' is not valid JSON: ${error instanceof Error ? error.message : String(error)}`,
3393
3609
  error instanceof Error ? error : void 0
3394
3610
  );
3395
3611
  }
3612
+ const v = parsed.version;
3613
+ if (v !== STATE_SCHEMA_VERSION_LEGACY && v !== STATE_SCHEMA_VERSION_CURRENT && v !== void 0) {
3614
+ throw new StateError(
3615
+ `Unsupported state schema version ${String(v)} for stack '${stackName}'. This cdkd binary supports versions ${String(STATE_SCHEMA_VERSION_LEGACY)} and ${String(STATE_SCHEMA_VERSION_CURRENT)}. Upgrade cdkd to a version that supports schema ${String(v)}.`
3616
+ );
3617
+ }
3618
+ return parsed;
3396
3619
  }
3397
3620
  };
3621
+ function isNoSuchKey(error) {
3622
+ if (error instanceof NoSuchKey)
3623
+ return true;
3624
+ const name = error?.name;
3625
+ return name === "NoSuchKey";
3626
+ }
3398
3627
 
3399
3628
  // src/state/lock-manager.ts
3400
3629
  import {
@@ -3415,10 +3644,24 @@ var LockManager = class {
3415
3644
  logger = getLogger().child("LockManager");
3416
3645
  ttlMs;
3417
3646
  /**
3418
- * Get the S3 key for a stack's lock file
3647
+ * Get the S3 key for a stack's lock file.
3648
+ *
3649
+ * Locks are region-scoped, mirroring the state key layout
3650
+ * (`{prefix}/{stackName}/{region}/lock.json`). Two regions of the same
3651
+ * stackName can therefore be operated on in parallel without contention,
3652
+ * matching cdkd's parallel execution model.
3653
+ *
3654
+ * The `region` argument is required for new callers; for backwards
3655
+ * compatibility with `state list --long` (which only sees stack names),
3656
+ * passing `undefined` falls back to the legacy `{prefix}/{stackName}/lock.json`
3657
+ * key — that mode is purely for legacy lock cleanup and is NOT used by
3658
+ * deploy / destroy / diff anymore.
3419
3659
  */
3420
- getLockKey(stackName) {
3421
- return `${this.config.prefix}/${stackName}/lock.json`;
3660
+ getLockKey(stackName, region) {
3661
+ if (region === void 0) {
3662
+ return `${this.config.prefix}/${stackName}/lock.json`;
3663
+ }
3664
+ return `${this.config.prefix}/${stackName}/${region}/lock.json`;
3422
3665
  }
3423
3666
  /**
3424
3667
  * Get default lock owner identifier
@@ -3457,11 +3700,12 @@ var LockManager = class {
3457
3700
  * If an expired lock exists, it will be cleaned up and re-acquired.
3458
3701
  *
3459
3702
  * @param stackName Stack name
3703
+ * @param region Target region (lock key is region-scoped)
3460
3704
  * @param owner Lock owner identifier (defaults to user@hostname:pid)
3461
3705
  * @param operation Operation being performed (e.g., "deploy", "destroy")
3462
3706
  */
3463
- async acquireLock(stackName, owner, operation) {
3464
- const key = this.getLockKey(stackName);
3707
+ async acquireLock(stackName, region, owner, operation) {
3708
+ const key = this.getLockKey(stackName, region);
3465
3709
  const lockOwner = owner || this.getDefaultOwner();
3466
3710
  const now = Date.now();
3467
3711
  const lockInfo = {
@@ -3471,7 +3715,7 @@ var LockManager = class {
3471
3715
  ...operation && { operation }
3472
3716
  };
3473
3717
  try {
3474
- this.logger.debug(`Attempting to acquire lock for stack: ${stackName}`);
3718
+ this.logger.debug(`Attempting to acquire lock for stack: ${stackName} (${region})`);
3475
3719
  const lockBody = JSON.stringify(lockInfo, null, 2);
3476
3720
  await this.s3Client.send(
3477
3721
  new PutObjectCommand3({
@@ -3484,17 +3728,17 @@ var LockManager = class {
3484
3728
  // Only succeed if object doesn't exist
3485
3729
  })
3486
3730
  );
3487
- this.logger.debug(`Lock acquired for stack: ${stackName}, owner: ${lockOwner}`);
3731
+ this.logger.debug(`Lock acquired for stack: ${stackName} (${region}), owner: ${lockOwner}`);
3488
3732
  return true;
3489
3733
  } catch (error) {
3490
3734
  if (error instanceof S3ServiceException && error.name === "PreconditionFailed") {
3491
- this.logger.debug(`Lock already exists for stack: ${stackName}`);
3492
- const existingLock = await this.getLockInfo(stackName);
3735
+ this.logger.debug(`Lock already exists for stack: ${stackName} (${region})`);
3736
+ const existingLock = await this.getLockInfo(stackName, region);
3493
3737
  if (existingLock && this.isLockExpired(existingLock)) {
3494
3738
  this.logger.info(
3495
- `Expired lock detected for stack: ${stackName} (owner: ${existingLock.owner}, expired ${this.formatDuration(now - existingLock.expiresAt)} ago). Cleaning up...`
3739
+ `Expired lock detected for stack: ${stackName} (${region}, owner: ${existingLock.owner}, expired ${this.formatDuration(now - existingLock.expiresAt)} ago). Cleaning up...`
3496
3740
  );
3497
- await this.deleteLock(stackName);
3741
+ await this.deleteLock(stackName, region);
3498
3742
  try {
3499
3743
  const retryBody = JSON.stringify(lockInfo, null, 2);
3500
3744
  await this.s3Client.send(
@@ -3508,13 +3752,13 @@ var LockManager = class {
3508
3752
  })
3509
3753
  );
3510
3754
  this.logger.debug(
3511
- `Lock acquired for stack: ${stackName} after expired lock cleanup, owner: ${lockOwner}`
3755
+ `Lock acquired for stack: ${stackName} (${region}) after expired lock cleanup, owner: ${lockOwner}`
3512
3756
  );
3513
3757
  return true;
3514
3758
  } catch (retryError) {
3515
3759
  if (retryError instanceof S3ServiceException && retryError.name === "PreconditionFailed") {
3516
3760
  this.logger.debug(
3517
- `Lock was acquired by another process during expired lock cleanup for stack: ${stackName}`
3761
+ `Lock was acquired by another process during expired lock cleanup for stack: ${stackName} (${region})`
3518
3762
  );
3519
3763
  return false;
3520
3764
  }
@@ -3524,16 +3768,20 @@ var LockManager = class {
3524
3768
  return false;
3525
3769
  }
3526
3770
  throw new LockError(
3527
- `Failed to acquire lock for stack '${stackName}': ${error instanceof Error ? error.message : String(error)}`,
3771
+ `Failed to acquire lock for stack '${stackName}' (${region}): ${error instanceof Error ? error.message : String(error)}`,
3528
3772
  error instanceof Error ? error : void 0
3529
3773
  );
3530
3774
  }
3531
3775
  }
3532
3776
  /**
3533
- * Get current lock information
3777
+ * Get current lock information.
3778
+ *
3779
+ * `region` is required for the new region-scoped lock layout. Pass
3780
+ * `undefined` only to inspect a legacy `{prefix}/{stackName}/lock.json`
3781
+ * file (e.g. for state-listing tools that don't yet know the region).
3534
3782
  */
3535
- async getLockInfo(stackName) {
3536
- const key = this.getLockKey(stackName);
3783
+ async getLockInfo(stackName, region) {
3784
+ const key = this.getLockKey(stackName, region);
3537
3785
  try {
3538
3786
  this.logger.debug(`Getting lock info for stack: ${stackName}`);
3539
3787
  const response = await this.s3Client.send(
@@ -3571,27 +3819,27 @@ var LockManager = class {
3571
3819
  * not for acquisition decisions — use `acquireLock` for that, which has its
3572
3820
  * own expired-lock cleanup logic.
3573
3821
  */
3574
- async isLocked(stackName) {
3575
- const lockInfo = await this.getLockInfo(stackName);
3822
+ async isLocked(stackName, region) {
3823
+ const lockInfo = await this.getLockInfo(stackName, region);
3576
3824
  return lockInfo !== null;
3577
3825
  }
3578
3826
  /**
3579
3827
  * Release a lock for a stack
3580
3828
  */
3581
- async releaseLock(stackName) {
3582
- const key = this.getLockKey(stackName);
3829
+ async releaseLock(stackName, region) {
3830
+ const key = this.getLockKey(stackName, region);
3583
3831
  try {
3584
- this.logger.debug(`Releasing lock for stack: ${stackName}`);
3832
+ this.logger.debug(`Releasing lock for stack: ${stackName} (${region})`);
3585
3833
  await this.s3Client.send(
3586
3834
  new DeleteObjectCommand2({
3587
3835
  Bucket: this.config.bucket,
3588
3836
  Key: key
3589
3837
  })
3590
3838
  );
3591
- this.logger.debug(`Lock released for stack: ${stackName}`);
3839
+ this.logger.debug(`Lock released for stack: ${stackName} (${region})`);
3592
3840
  } catch (error) {
3593
3841
  throw new LockError(
3594
- `Failed to release lock for stack '${stackName}': ${error instanceof Error ? error.message : String(error)}`,
3842
+ `Failed to release lock for stack '${stackName}' (${region}): ${error instanceof Error ? error.message : String(error)}`,
3595
3843
  error instanceof Error ? error : void 0
3596
3844
  );
3597
3845
  }
@@ -3601,23 +3849,28 @@ var LockManager = class {
3601
3849
  *
3602
3850
  * This is intended for CLI usage (e.g., --force-unlock flag) when a lock
3603
3851
  * is stuck and needs manual intervention.
3852
+ *
3853
+ * Pass `region: undefined` to operate on a legacy
3854
+ * `{prefix}/{stackName}/lock.json` file.
3604
3855
  */
3605
- async forceReleaseLock(stackName) {
3606
- const lockInfo = await this.getLockInfo(stackName);
3856
+ async forceReleaseLock(stackName, region) {
3857
+ const lockInfo = await this.getLockInfo(stackName, region);
3607
3858
  if (!lockInfo) {
3608
- this.logger.warn(`No lock to force release for stack: ${stackName}`);
3859
+ this.logger.warn(
3860
+ `No lock to force release for stack: ${stackName}${region ? ` (${region})` : ""}`
3861
+ );
3609
3862
  return;
3610
3863
  }
3611
3864
  this.logger.warn(
3612
- `Force releasing lock for stack: ${stackName}, owner: ${lockInfo.owner}${lockInfo.operation ? `, operation: ${lockInfo.operation}` : ""}, expired: ${this.isLockExpired(lockInfo)}`
3865
+ `Force releasing lock for stack: ${stackName}${region ? ` (${region})` : ""}, owner: ${lockInfo.owner}${lockInfo.operation ? `, operation: ${lockInfo.operation}` : ""}, expired: ${this.isLockExpired(lockInfo)}`
3613
3866
  );
3614
- await this.deleteLock(stackName);
3867
+ await this.deleteLock(stackName, region);
3615
3868
  }
3616
3869
  /**
3617
3870
  * Internal method to delete the lock file from S3
3618
3871
  */
3619
- async deleteLock(stackName) {
3620
- const key = this.getLockKey(stackName);
3872
+ async deleteLock(stackName, region) {
3873
+ const key = this.getLockKey(stackName, region);
3621
3874
  await this.s3Client.send(
3622
3875
  new DeleteObjectCommand2({
3623
3876
  Bucket: this.config.bucket,
@@ -3638,28 +3891,28 @@ var LockManager = class {
3638
3891
  * @param maxRetries Maximum number of retries (default: 3)
3639
3892
  * @param retryDelay Delay between retries in milliseconds (default: 2000)
3640
3893
  */
3641
- async acquireLockWithRetry(stackName, owner, operation, maxRetries = 3, retryDelay = 2e3) {
3894
+ async acquireLockWithRetry(stackName, region, owner, operation, maxRetries = 3, retryDelay = 2e3) {
3642
3895
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
3643
- const acquired = await this.acquireLock(stackName, owner, operation);
3896
+ const acquired = await this.acquireLock(stackName, region, owner, operation);
3644
3897
  if (acquired) {
3645
3898
  return;
3646
3899
  }
3647
- const lockInfo2 = await this.getLockInfo(stackName);
3900
+ const lockInfo2 = await this.getLockInfo(stackName, region);
3648
3901
  if (lockInfo2) {
3649
3902
  const remainingMs = lockInfo2.expiresAt - Date.now();
3650
3903
  if (attempt < maxRetries) {
3651
3904
  this.logger.info(
3652
- `Stack '${stackName}' is locked by ${lockInfo2.owner}${lockInfo2.operation ? ` (operation: ${lockInfo2.operation})` : ""}. Lock expires in ${this.formatDuration(remainingMs)}. Retrying in ${this.formatDuration(retryDelay)}... (attempt ${attempt + 1}/${maxRetries})`
3905
+ `Stack '${stackName}' (${region}) is locked by ${lockInfo2.owner}${lockInfo2.operation ? ` (operation: ${lockInfo2.operation})` : ""}. Lock expires in ${this.formatDuration(remainingMs)}. Retrying in ${this.formatDuration(retryDelay)}... (attempt ${attempt + 1}/${maxRetries})`
3653
3906
  );
3654
3907
  await new Promise((resolve4) => setTimeout(resolve4, retryDelay));
3655
3908
  continue;
3656
3909
  }
3657
3910
  }
3658
3911
  }
3659
- const lockInfo = await this.getLockInfo(stackName);
3912
+ const lockInfo = await this.getLockInfo(stackName, region);
3660
3913
  const expiresIn = lockInfo ? this.formatDuration(lockInfo.expiresAt - Date.now()) : "unknown";
3661
3914
  throw new LockError(
3662
- `Failed to acquire lock for stack '${stackName}' after ${maxRetries + 1} attempts. ` + (lockInfo ? `Locked by: ${lockInfo.owner}${lockInfo.operation ? `, operation: ${lockInfo.operation}` : ""}, expires in: ${expiresIn}. Use --force-unlock to manually release the lock.` : "Lock exists but could not read lock info.")
3915
+ `Failed to acquire lock for stack '${stackName}' (${region}) after ${maxRetries + 1} attempts. ` + (lockInfo ? `Locked by: ${lockInfo.owner}${lockInfo.operation ? `, operation: ${lockInfo.operation}` : ""}, expires in: ${expiresIn}. Use --force-unlock to manually release the lock.` : "Lock exists but could not read lock info.")
3663
3916
  );
3664
3917
  }
3665
3918
  };
@@ -5442,35 +5695,45 @@ var IntrinsicFunctionResolver = class {
5442
5695
  }
5443
5696
  this.logger.debug(`Resolving Fn::ImportValue: ${exportName}`);
5444
5697
  const allStacks = await context.stateBackend.listStacks();
5445
- this.logger.debug(`Found ${allStacks.length} stacks to search for export: ${exportName}`);
5446
- for (const stackName of allStacks) {
5447
- if (context.stackName && stackName === context.stackName) {
5448
- this.logger.debug(`Skipping current stack: ${stackName}`);
5698
+ this.logger.debug(
5699
+ `Found ${allStacks.length} state record(s) to search for export: ${exportName}`
5700
+ );
5701
+ for (const ref of allStacks) {
5702
+ const { stackName: refStack, region: refRegion } = ref;
5703
+ if (context.stackName && refStack === context.stackName) {
5704
+ this.logger.debug(`Skipping current stack: ${refStack}`);
5449
5705
  continue;
5450
5706
  }
5451
5707
  try {
5452
- const stateData = await context.stateBackend.getState(stackName);
5708
+ const lookupRegion = refRegion ?? this.resolverRegion ?? "";
5709
+ if (!lookupRegion) {
5710
+ this.logger.debug(
5711
+ `No region available for stack '${refStack}' \u2014 skipping (cdkd cannot read state without a region)`
5712
+ );
5713
+ continue;
5714
+ }
5715
+ const stateData = await context.stateBackend.getState(refStack, lookupRegion);
5453
5716
  if (!stateData) {
5454
- this.logger.debug(`No state found for stack: ${stackName}`);
5717
+ this.logger.debug(`No state found for stack: ${refStack} (${lookupRegion})`);
5455
5718
  continue;
5456
5719
  }
5457
5720
  const { state } = stateData;
5458
5721
  if (state.outputs && exportName in state.outputs) {
5459
5722
  const value = state.outputs[exportName];
5460
5723
  this.logger.info(
5461
- `Resolved Fn::ImportValue: ${exportName} = ${JSON.stringify(value)} (from stack: ${stackName})`
5724
+ `Resolved Fn::ImportValue: ${exportName} = ${JSON.stringify(value)} (from stack: ${refStack} / ${lookupRegion})`
5462
5725
  );
5463
5726
  return value;
5464
5727
  }
5465
5728
  } catch (error) {
5466
5729
  this.logger.warn(
5467
- `Failed to read state for stack ${stackName}: ${error instanceof Error ? error.message : String(error)}`
5730
+ `Failed to read state for stack ${refStack}: ${error instanceof Error ? error.message : String(error)}`
5468
5731
  );
5469
5732
  continue;
5470
5733
  }
5471
5734
  }
5472
5735
  throw new Error(
5473
- `Fn::ImportValue: export '${exportName}' not found in any stack. Searched ${allStacks.length} stacks. Make sure the exporting stack has been deployed and the Output has an Export.Name property.`
5736
+ `Fn::ImportValue: export '${exportName}' not found in any stack. Searched ${allStacks.length} state record(s). Make sure the exporting stack has been deployed and the Output has an Export.Name property.`
5474
5737
  );
5475
5738
  }
5476
5739
  /**
@@ -26251,7 +26514,11 @@ var DeployEngine = class {
26251
26514
  logger = getLogger().child("DeployEngine");
26252
26515
  resolver;
26253
26516
  interrupted = false;
26254
- /** Target region for this stack (saved in state for cross-region destroy) */
26517
+ /**
26518
+ * Target region for this stack. Required — load-bearing for the
26519
+ * region-prefixed S3 state key and recorded in state.json for
26520
+ * cross-region destroy.
26521
+ */
26255
26522
  stackRegion;
26256
26523
  /**
26257
26524
  * Deploy a CloudFormation template
@@ -26260,7 +26527,7 @@ var DeployEngine = class {
26260
26527
  const startTime = Date.now();
26261
26528
  this.logger.debug(`Starting deployment for stack: ${stackName}`);
26262
26529
  setCurrentStackName(stackName);
26263
- await this.lockManager.acquireLockWithRetry(stackName, void 0, "deploy");
26530
+ await this.lockManager.acquireLockWithRetry(stackName, this.stackRegion, void 0, "deploy");
26264
26531
  const renderer = getLiveRenderer();
26265
26532
  renderer.start();
26266
26533
  this.interrupted = false;
@@ -26274,16 +26541,17 @@ var DeployEngine = class {
26274
26541
  };
26275
26542
  process.on("SIGINT", sigintHandler);
26276
26543
  try {
26277
- const currentStateData = await this.stateBackend.getState(stackName);
26544
+ const currentStateData = await this.stateBackend.getState(stackName, this.stackRegion);
26278
26545
  const currentState = currentStateData?.state ?? {
26279
- version: 1,
26280
- ...this.stackRegion && { region: this.stackRegion },
26546
+ version: STATE_SCHEMA_VERSION_CURRENT,
26547
+ region: this.stackRegion,
26281
26548
  stackName,
26282
26549
  resources: {},
26283
26550
  outputs: {},
26284
26551
  lastModified: Date.now()
26285
26552
  };
26286
26553
  const currentEtag = currentStateData?.etag;
26554
+ const migrationPending = currentStateData?.migrationPending ?? false;
26287
26555
  this.logger.debug(
26288
26556
  `Loaded current state: ${Object.keys(currentState.resources).length} resources`
26289
26557
  );
@@ -26369,9 +26637,10 @@ var DeployEngine = class {
26369
26637
  parameterValues,
26370
26638
  conditions,
26371
26639
  currentEtag,
26372
- progress
26640
+ progress,
26641
+ migrationPending
26373
26642
  );
26374
- const newEtag = await this.stateBackend.saveState(stackName, newState);
26643
+ const newEtag = await this.stateBackend.saveState(stackName, this.stackRegion, newState);
26375
26644
  this.logger.debug(`State saved (ETag: ${newEtag})`);
26376
26645
  const durationMs = Date.now() - startTime;
26377
26646
  const unchangedCount = this.diffCalculator.filterByType(changes, "NO_CHANGE").length + actualCounts.skipped;
@@ -26387,7 +26656,7 @@ var DeployEngine = class {
26387
26656
  renderer.stop();
26388
26657
  process.removeListener("SIGINT", sigintHandler);
26389
26658
  try {
26390
- await this.lockManager.releaseLock(stackName);
26659
+ await this.lockManager.releaseLock(stackName, this.stackRegion);
26391
26660
  this.logger.debug("Lock released");
26392
26661
  } catch (lockError) {
26393
26662
  this.logger.warn(
@@ -26405,11 +26674,12 @@ var DeployEngine = class {
26405
26674
  * - DELETE follows reverse dependency order (a node starts as soon as all
26406
26675
  * resources that depend ON it have finished deleting)
26407
26676
  */
26408
- async executeDeployment(template, currentState, changes, dag, executionLevels, stackName, parameterValues, conditions, currentEtag, progress) {
26677
+ async executeDeployment(template, currentState, changes, dag, executionLevels, stackName, parameterValues, conditions, currentEtag, progress, migrationPending = false) {
26409
26678
  const concurrency = this.options.concurrency;
26410
26679
  const newResources = { ...currentState.resources };
26411
26680
  const actualCounts = { created: 0, updated: 0, deleted: 0, skipped: 0 };
26412
26681
  const completedOperations = [];
26682
+ let pendingMigration = migrationPending;
26413
26683
  let saveChain = Promise.resolve();
26414
26684
  const saveStateAfterResource = (logicalId) => {
26415
26685
  if (currentEtag === void 0)
@@ -26417,14 +26687,23 @@ var DeployEngine = class {
26417
26687
  saveChain = saveChain.then(async () => {
26418
26688
  try {
26419
26689
  const partialState = {
26420
- version: 1,
26421
- ...this.stackRegion && { region: this.stackRegion },
26690
+ version: STATE_SCHEMA_VERSION_CURRENT,
26691
+ region: this.stackRegion,
26422
26692
  stackName: currentState.stackName,
26423
26693
  resources: newResources,
26424
26694
  outputs: currentState.outputs,
26425
26695
  lastModified: Date.now()
26426
26696
  };
26427
- currentEtag = await this.stateBackend.saveState(stackName, partialState, currentEtag);
26697
+ const migrate = pendingMigration;
26698
+ const expectedEtag = migrate ? void 0 : currentEtag;
26699
+ currentEtag = await this.stateBackend.saveState(
26700
+ stackName,
26701
+ this.stackRegion,
26702
+ partialState,
26703
+ { ...expectedEtag !== void 0 && { expectedEtag }, migrateLegacy: migrate }
26704
+ );
26705
+ if (migrate)
26706
+ pendingMigration = false;
26428
26707
  this.logger.debug(`State saved after ${logicalId}`);
26429
26708
  } catch (error) {
26430
26709
  this.logger.warn(
@@ -26558,14 +26837,23 @@ var DeployEngine = class {
26558
26837
  } catch (error) {
26559
26838
  try {
26560
26839
  const preRollbackState = {
26561
- version: 1,
26562
- ...this.stackRegion && { region: this.stackRegion },
26840
+ version: STATE_SCHEMA_VERSION_CURRENT,
26841
+ region: this.stackRegion,
26563
26842
  stackName: currentState.stackName,
26564
26843
  resources: newResources,
26565
26844
  outputs: currentState.outputs,
26566
26845
  lastModified: Date.now()
26567
26846
  };
26568
- currentEtag = await this.stateBackend.saveState(stackName, preRollbackState, currentEtag);
26847
+ const migrate = pendingMigration;
26848
+ const expectedEtag = migrate ? void 0 : currentEtag;
26849
+ currentEtag = await this.stateBackend.saveState(
26850
+ stackName,
26851
+ this.stackRegion,
26852
+ preRollbackState,
26853
+ { ...expectedEtag !== void 0 && { expectedEtag }, migrateLegacy: migrate }
26854
+ );
26855
+ if (migrate)
26856
+ pendingMigration = false;
26569
26857
  this.logger.debug("Partial state saved before rollback (orphaned resource tracking)");
26570
26858
  } catch (saveError) {
26571
26859
  this.logger.warn(
@@ -26586,31 +26874,35 @@ var DeployEngine = class {
26586
26874
  }
26587
26875
  try {
26588
26876
  const postRollbackState = {
26589
- version: 1,
26590
- ...this.stackRegion && { region: this.stackRegion },
26877
+ version: STATE_SCHEMA_VERSION_CURRENT,
26878
+ region: this.stackRegion,
26591
26879
  stackName: currentState.stackName,
26592
26880
  resources: newResources,
26593
26881
  outputs: currentState.outputs,
26594
26882
  lastModified: Date.now()
26595
26883
  };
26596
- await this.stateBackend.saveState(stackName, postRollbackState, currentEtag);
26884
+ await this.stateBackend.saveState(stackName, this.stackRegion, postRollbackState, {
26885
+ ...currentEtag !== void 0 && { expectedEtag: currentEtag }
26886
+ });
26597
26887
  this.logger.debug("State saved after deployment failure");
26598
26888
  } catch (saveError) {
26599
26889
  this.logger.debug(
26600
26890
  `Retrying state save after rollback (ETag mismatch): ${saveError instanceof Error ? saveError.message : String(saveError)}`
26601
26891
  );
26602
26892
  try {
26603
- const freshState = await this.stateBackend.getState(stackName);
26893
+ const freshState = await this.stateBackend.getState(stackName, this.stackRegion);
26604
26894
  const freshEtag = freshState?.etag;
26605
26895
  const postRollbackState = {
26606
- version: 1,
26607
- ...this.stackRegion && { region: this.stackRegion },
26896
+ version: STATE_SCHEMA_VERSION_CURRENT,
26897
+ region: this.stackRegion,
26608
26898
  stackName: currentState.stackName,
26609
26899
  resources: newResources,
26610
26900
  outputs: currentState.outputs,
26611
26901
  lastModified: Date.now()
26612
26902
  };
26613
- await this.stateBackend.saveState(stackName, postRollbackState, freshEtag);
26903
+ await this.stateBackend.saveState(stackName, this.stackRegion, postRollbackState, {
26904
+ ...freshEtag !== void 0 && { expectedEtag: freshEtag }
26905
+ });
26614
26906
  this.logger.debug("State saved after deployment failure (retry succeeded)");
26615
26907
  } catch (retryError) {
26616
26908
  this.logger.warn(
@@ -26629,8 +26921,8 @@ var DeployEngine = class {
26629
26921
  );
26630
26922
  return {
26631
26923
  state: {
26632
- version: 1,
26633
- ...this.stackRegion && { region: this.stackRegion },
26924
+ version: STATE_SCHEMA_VERSION_CURRENT,
26925
+ region: this.stackRegion,
26634
26926
  stackName: currentState.stackName,
26635
26927
  resources: newResources,
26636
26928
  outputs,
@@ -27637,19 +27929,18 @@ async function diffCommand(stacks, options) {
27637
27929
  logger.info(`
27638
27930
  Calculating diff for stack: ${stackInfo.stackName}`);
27639
27931
  const template = stackInfo.template;
27640
- let currentState;
27641
- const stateResult = await stateBackend.getState(stackInfo.stackName);
27642
- if (stateResult) {
27643
- currentState = stateResult.state;
27644
- } else {
27645
- logger.debug(`No existing state for ${stackInfo.stackName}`);
27646
- currentState = {
27647
- stackName: stackInfo.stackName,
27648
- resources: {},
27649
- outputs: {},
27650
- version: 1,
27651
- lastModified: Date.now()
27652
- };
27932
+ const stackRegion = stackInfo.region || region;
27933
+ const stateResult = await stateBackend.getState(stackInfo.stackName, stackRegion);
27934
+ const currentState = stateResult ? stateResult.state : {
27935
+ stackName: stackInfo.stackName,
27936
+ region: stackRegion,
27937
+ resources: {},
27938
+ outputs: {},
27939
+ version: STATE_SCHEMA_VERSION_CURRENT,
27940
+ lastModified: Date.now()
27941
+ };
27942
+ if (!stateResult) {
27943
+ logger.debug(`No existing state for ${stackInfo.stackName} (${stackRegion})`);
27653
27944
  }
27654
27945
  const diffResolveFn = (value) => intrinsicResolver.resolve(value, {
27655
27946
  template,
@@ -27771,19 +28062,27 @@ async function destroyCommand(stackArgs, options) {
27771
28062
  });
27772
28063
  appStacks = result.stacks.map((s) => ({
27773
28064
  stackName: s.stackName,
27774
- displayName: s.displayName
28065
+ displayName: s.displayName,
28066
+ ...s.region && { region: s.region }
27775
28067
  }));
27776
28068
  } catch {
27777
28069
  logger.debug("Could not synthesize app, falling back to state-based stack list");
27778
28070
  }
27779
28071
  }
27780
- const allStateStacks = await stateBackend.listStacks();
28072
+ const allStateRefs = await stateBackend.listStacks();
27781
28073
  let candidateStacks;
27782
28074
  if (appStacks.length > 0) {
27783
- const stateSet = new Set(allStateStacks);
27784
- candidateStacks = appStacks.filter((s) => stateSet.has(s.stackName));
28075
+ const stateNames = new Set(allStateRefs.map((r) => r.stackName));
28076
+ candidateStacks = appStacks.filter((s) => stateNames.has(s.stackName));
27785
28077
  } else if (stackArgs.length > 0 || options.stack || options.all) {
27786
- candidateStacks = allStateStacks.map((name) => ({ stackName: name }));
28078
+ const seen = /* @__PURE__ */ new Set();
28079
+ candidateStacks = [];
28080
+ for (const ref of allStateRefs) {
28081
+ if (seen.has(ref.stackName))
28082
+ continue;
28083
+ seen.add(ref.stackName);
28084
+ candidateStacks.push({ stackName: ref.stackName });
28085
+ }
27787
28086
  } else {
27788
28087
  throw new Error(
27789
28088
  "Could not determine which stacks belong to this app. Specify stack names explicitly, use --all, or ensure --app / cdk.json is configured."
@@ -27810,10 +28109,38 @@ async function destroyCommand(stackArgs, options) {
27810
28109
  return;
27811
28110
  }
27812
28111
  logger.info(`Found ${stackNames.length} stack(s) to destroy: ${stackNames.join(", ")}`);
28112
+ const stateRefsByName = /* @__PURE__ */ new Map();
28113
+ for (const ref of allStateRefs) {
28114
+ const arr = stateRefsByName.get(ref.stackName) ?? [];
28115
+ arr.push(ref);
28116
+ stateRefsByName.set(ref.stackName, arr);
28117
+ }
27813
28118
  for (const stackName of stackNames) {
27814
28119
  logger.info(`
27815
28120
  Preparing to destroy stack: ${stackName}`);
27816
- const stateResult = await stateBackend.getState(stackName);
28121
+ const refs = stateRefsByName.get(stackName) ?? [];
28122
+ const synthStack = appStacks.find((s) => s.stackName === stackName);
28123
+ const synthRegion = synthStack?.region;
28124
+ let stackTargetRegion;
28125
+ if (refs.length === 0) {
28126
+ logger.warn(`No state found for stack ${stackName}, skipping`);
28127
+ continue;
28128
+ } else if (refs.length === 1) {
28129
+ const onlyRegion = refs[0]?.region;
28130
+ if (!onlyRegion) {
28131
+ stackTargetRegion = region;
28132
+ } else {
28133
+ stackTargetRegion = onlyRegion;
28134
+ }
28135
+ } else if (synthRegion && refs.some((r) => r.region === synthRegion)) {
28136
+ stackTargetRegion = synthRegion;
28137
+ } else {
28138
+ const regions = refs.map((r) => r.region ?? "(legacy)").join(", ");
28139
+ throw new Error(
28140
+ `Stack '${stackName}' has state in multiple regions: ${regions}. Use 'cdkd state rm ${stackName} --region <region>' to remove cdkd's record for one region, or run destroy from a CDK app whose env.region matches one of them.`
28141
+ );
28142
+ }
28143
+ const stateResult = await stateBackend.getState(stackName, stackTargetRegion);
27817
28144
  if (!stateResult) {
27818
28145
  logger.warn(`No state found for stack ${stackName}, skipping`);
27819
28146
  continue;
@@ -27822,7 +28149,7 @@ Preparing to destroy stack: ${stackName}`);
27822
28149
  const resourceCount = Object.keys(currentState.resources).length;
27823
28150
  if (resourceCount === 0) {
27824
28151
  logger.info(`Stack ${stackName} has no resources, cleaning up state...`);
27825
- await stateBackend.deleteState(stackName);
28152
+ await stateBackend.deleteState(stackName, stackTargetRegion);
27826
28153
  logger.info("\u2713 State deleted");
27827
28154
  continue;
27828
28155
  }
@@ -27847,7 +28174,7 @@ Are you sure you want to destroy stack "${stackName}" and delete all ${resourceC
27847
28174
  continue;
27848
28175
  }
27849
28176
  }
27850
- const stackRegion = currentState.region;
28177
+ const stackRegion = stackTargetRegion;
27851
28178
  let destroyProviderRegistry = providerRegistry;
27852
28179
  let destroyAwsClients;
27853
28180
  if (stackRegion && stackRegion !== region) {
@@ -27865,7 +28192,7 @@ Are you sure you want to destroy stack "${stackName}" and delete all ${resourceC
27865
28192
  }
27866
28193
  logger.info(`
27867
28194
  Acquiring lock for stack ${stackName}...`);
27868
- await lockManager.acquireLock(stackName, "destroy");
28195
+ await lockManager.acquireLock(stackName, stackRegion, void 0, "destroy");
27869
28196
  const renderer = getLiveRenderer();
27870
28197
  renderer.start();
27871
28198
  try {
@@ -27980,7 +28307,7 @@ Acquiring lock for stack ${stackName}...`);
27980
28307
  await Promise.all(deletePromises);
27981
28308
  }
27982
28309
  if (errorCount === 0) {
27983
- await stateBackend.deleteState(stackName);
28310
+ await stateBackend.deleteState(stackName, stackRegion);
27984
28311
  logger.debug("State deleted");
27985
28312
  } else {
27986
28313
  logger.warn(`${errorCount} resource(s) failed to delete. State preserved.`);
@@ -27992,7 +28319,7 @@ Acquiring lock for stack ${stackName}...`);
27992
28319
  } finally {
27993
28320
  renderer.stop();
27994
28321
  logger.debug("Releasing lock...");
27995
- await lockManager.releaseLock(stackName);
28322
+ await lockManager.releaseLock(stackName, stackRegion);
27996
28323
  if (destroyAwsClients) {
27997
28324
  destroyAwsClients.destroy();
27998
28325
  process.env["AWS_REGION"] = region;
@@ -28053,7 +28380,7 @@ function createPublishAssetsCommand() {
28053
28380
  }
28054
28381
 
28055
28382
  // src/cli/commands/force-unlock.ts
28056
- import { Command as Command8 } from "commander";
28383
+ import { Command as Command8, Option as Option3 } from "commander";
28057
28384
  init_aws_clients();
28058
28385
  async function forceUnlockCommand(stackArgs, options) {
28059
28386
  const logger = getLogger();
@@ -28077,17 +28404,33 @@ async function forceUnlockCommand(stackArgs, options) {
28077
28404
  prefix: options.statePrefix
28078
28405
  };
28079
28406
  const lockManager = new LockManager(awsClients.s3, stateConfig);
28407
+ const stateBackend = new S3StateBackend(awsClients.s3, stateConfig);
28080
28408
  for (const stackName of stackPatterns) {
28081
- logger.info(`Force-unlocking stack: ${stackName}`);
28082
- try {
28083
- await lockManager.forceReleaseLock(stackName);
28084
- logger.info(`\u2713 Lock released for stack: ${stackName}`);
28085
- } catch (error) {
28086
- const message = error instanceof Error ? error.message : String(error);
28087
- if (message.includes("No lock found") || message.includes("NoSuchKey")) {
28088
- logger.info(`No lock found for stack: ${stackName}`);
28409
+ let regionsToTry;
28410
+ if (options.stackRegion) {
28411
+ regionsToTry = [options.stackRegion];
28412
+ } else {
28413
+ const refs = await stateBackend.listStacks();
28414
+ const matched = refs.filter((r) => r.stackName === stackName);
28415
+ if (matched.length === 0) {
28416
+ regionsToTry = [region];
28089
28417
  } else {
28090
- logger.error(`Failed to unlock stack ${stackName}: ${message}`);
28418
+ regionsToTry = matched.map((r) => r.region);
28419
+ }
28420
+ }
28421
+ for (const r of regionsToTry) {
28422
+ const where = r ? `${stackName} (${r})` : `${stackName} (legacy lock key)`;
28423
+ logger.info(`Force-unlocking stack: ${where}`);
28424
+ try {
28425
+ await lockManager.forceReleaseLock(stackName, r);
28426
+ logger.info(`\u2713 Lock released for stack: ${where}`);
28427
+ } catch (error) {
28428
+ const message = error instanceof Error ? error.message : String(error);
28429
+ if (message.includes("No lock found") || message.includes("NoSuchKey")) {
28430
+ logger.info(`No lock found for stack: ${where}`);
28431
+ } else {
28432
+ logger.error(`Failed to unlock stack ${where}: ${message}`);
28433
+ }
28091
28434
  }
28092
28435
  }
28093
28436
  }
@@ -28096,15 +28439,47 @@ async function forceUnlockCommand(stackArgs, options) {
28096
28439
  }
28097
28440
  }
28098
28441
  function createForceUnlockCommand() {
28099
- const cmd = new Command8("force-unlock").description("Force-release a stale lock on a stack").argument("[stacks...]", "Stack name(s) to unlock").action(withErrorHandling(forceUnlockCommand));
28442
+ const cmd = new Command8("force-unlock").description("Force-release a stale lock on a stack").argument("[stacks...]", "Stack name(s) to unlock").addOption(
28443
+ new Option3(
28444
+ "--stack-region <region>",
28445
+ "Stack region whose lock to release (use when the same stack name has locks in multiple regions). Defaults to all regions where the stack has state."
28446
+ )
28447
+ ).action(withErrorHandling(forceUnlockCommand));
28100
28448
  [...commonOptions, ...stateOptions, ...stackOptions].forEach((opt) => cmd.addOption(opt));
28101
28449
  return cmd;
28102
28450
  }
28103
28451
 
28104
28452
  // src/cli/commands/state.ts
28105
28453
  import * as readline2 from "node:readline/promises";
28106
- import { Command as Command9 } from "commander";
28454
+ import { Command as Command9, Option as Option4 } from "commander";
28107
28455
  init_aws_clients();
28456
+ function formatStackRef(ref) {
28457
+ return ref.region ? `${ref.stackName} (${ref.region})` : ref.stackName;
28458
+ }
28459
+ function resolveSingleRegion(stackName, refs, requestedRegion) {
28460
+ const matches = refs.filter((r) => r.stackName === stackName);
28461
+ if (matches.length === 0) {
28462
+ throw new Error(
28463
+ `No state found for stack '${stackName}'. Run 'cdkd state list' to see available stacks.`
28464
+ );
28465
+ }
28466
+ if (requestedRegion) {
28467
+ const ref = matches.find((r) => r.region === requestedRegion);
28468
+ if (!ref) {
28469
+ const seen = matches.map((r) => r.region ?? "(legacy)").join(", ");
28470
+ throw new Error(
28471
+ `No state found for stack '${stackName}' in region '${requestedRegion}'. Available regions: ${seen}.`
28472
+ );
28473
+ }
28474
+ return ref;
28475
+ }
28476
+ if (matches.length === 1)
28477
+ return matches[0];
28478
+ const regions = matches.map((r) => r.region ?? "(legacy)").join(", ");
28479
+ throw new Error(
28480
+ `Stack '${stackName}' has state in multiple regions: ${regions}. Re-run with --region <region> to disambiguate.`
28481
+ );
28482
+ }
28108
28483
  async function setupStateBackend(options) {
28109
28484
  const awsClients = new AwsClients({
28110
28485
  ...options.region && { region: options.region },
@@ -28126,34 +28501,52 @@ async function setupStateBackend(options) {
28126
28501
  dispose: () => awsClients.destroy()
28127
28502
  };
28128
28503
  }
28504
+ function sortRefs(refs) {
28505
+ return refs.slice().sort((a, b) => {
28506
+ if (a.stackName < b.stackName)
28507
+ return -1;
28508
+ if (a.stackName > b.stackName)
28509
+ return 1;
28510
+ const ar = a.region ?? "\uFFFF";
28511
+ const br = b.region ?? "\uFFFF";
28512
+ if (ar < br)
28513
+ return -1;
28514
+ if (ar > br)
28515
+ return 1;
28516
+ return 0;
28517
+ });
28518
+ }
28129
28519
  async function stateListCommand(options) {
28130
28520
  const logger = getLogger();
28131
28521
  if (options.verbose)
28132
28522
  logger.setLevel("debug");
28133
28523
  const setup = await setupStateBackend(options);
28134
28524
  try {
28135
- const stackNames = (await setup.stateBackend.listStacks()).slice().sort();
28525
+ const refs = sortRefs(await setup.stateBackend.listStacks());
28136
28526
  if (!options.long && !options.json) {
28137
- for (const name of stackNames) {
28138
- process.stdout.write(`${name}
28527
+ for (const ref of refs) {
28528
+ process.stdout.write(`${formatStackRef(ref)}
28139
28529
  `);
28140
28530
  }
28141
28531
  return;
28142
28532
  }
28143
28533
  if (options.json && !options.long) {
28144
- process.stdout.write(`${JSON.stringify(stackNames, null, 2)}
28534
+ const payload = refs.map((r) => ({ stackName: r.stackName, region: r.region ?? null }));
28535
+ process.stdout.write(`${JSON.stringify(payload, null, 2)}
28145
28536
  `);
28146
28537
  return;
28147
28538
  }
28148
28539
  const details = await Promise.all(
28149
- stackNames.map(async (stackName) => {
28540
+ refs.map(async (ref) => {
28541
+ const lookupRegion = ref.region ?? "";
28150
28542
  const [stateResult, locked] = await Promise.all([
28151
- setup.stateBackend.getState(stackName),
28152
- setup.lockManager.isLocked(stackName)
28543
+ lookupRegion ? setup.stateBackend.getState(ref.stackName, lookupRegion) : Promise.resolve(null),
28544
+ setup.lockManager.isLocked(ref.stackName, ref.region)
28153
28545
  ]);
28154
28546
  const state = stateResult?.state;
28155
28547
  return {
28156
- stackName,
28548
+ stackName: ref.stackName,
28549
+ region: ref.region ?? null,
28157
28550
  resourceCount: state ? Object.keys(state.resources).length : 0,
28158
28551
  lastModified: state && typeof state.lastModified === "number" ? new Date(state.lastModified).toISOString() : null,
28159
28552
  locked
@@ -28167,7 +28560,13 @@ async function stateListCommand(options) {
28167
28560
  }
28168
28561
  const lines = [];
28169
28562
  for (const detail of details) {
28170
- lines.push(detail.stackName);
28563
+ lines.push(
28564
+ formatStackRef({
28565
+ stackName: detail.stackName,
28566
+ ...detail.region ? { region: detail.region } : {}
28567
+ })
28568
+ );
28569
+ lines.push(` Region: ${detail.region ?? "(legacy)"}`);
28171
28570
  lines.push(` Resources: ${detail.resourceCount}`);
28172
28571
  lines.push(` Last Modified: ${detail.lastModified ?? "unknown"}`);
28173
28572
  lines.push(` Lock: ${detail.locked ? "locked" : "unlocked"}`);
@@ -28195,10 +28594,17 @@ async function stateResourcesCommand(stackName, options) {
28195
28594
  logger.setLevel("debug");
28196
28595
  const setup = await setupStateBackend(options);
28197
28596
  try {
28198
- const stateResult = await setup.stateBackend.getState(stackName);
28597
+ const refs = await setup.stateBackend.listStacks();
28598
+ const ref = resolveSingleRegion(stackName, refs, options.stackRegion);
28599
+ if (!ref.region) {
28600
+ throw new Error(
28601
+ `Stack '${stackName}' has only a legacy state record without a region. Run 'cdkd deploy ${stackName}' (or any cdkd write) to migrate it to the region-scoped layout, then re-run this command.`
28602
+ );
28603
+ }
28604
+ const stateResult = await setup.stateBackend.getState(stackName, ref.region);
28199
28605
  if (!stateResult) {
28200
28606
  throw new Error(
28201
- `No state found for stack '${stackName}' in s3://${setup.bucket}/${setup.prefix}/. Run 'cdkd state list' to see available stacks.`
28607
+ `No state found for stack '${stackName}' (${ref.region}) in s3://${setup.bucket}/${setup.prefix}/. Run 'cdkd state list' to see available stacks.`
28202
28608
  );
28203
28609
  }
28204
28610
  const resources = stateResult.state.resources ?? {};
@@ -28281,7 +28687,7 @@ function formatLockSummary(lockInfo) {
28281
28687
  return `locked by ${lockInfo.owner}${opStr}, ${expiresStr}`;
28282
28688
  }
28283
28689
  function createStateResourcesCommand() {
28284
- const cmd = new Command9("resources").description("List resources recorded in a stack's state").argument("<stack>", "Stack name (physical CloudFormation name)").option("-l, --long", "Include dependencies and attributes per resource", false).option("--json", "Output as JSON", false).action(withErrorHandling(stateResourcesCommand));
28690
+ const cmd = new Command9("resources").description("List resources recorded in a stack's state").argument("<stack>", "Stack name (physical CloudFormation name)").option("-l, --long", "Include dependencies and attributes per resource", false).option("--json", "Output as JSON", false).addOption(stackRegionOption()).action(withErrorHandling(stateResourcesCommand));
28285
28691
  [...commonOptions, ...stateOptions].forEach((opt) => cmd.addOption(opt));
28286
28692
  return cmd;
28287
28693
  }
@@ -28291,13 +28697,20 @@ async function stateShowCommand(stackName, options) {
28291
28697
  logger.setLevel("debug");
28292
28698
  const setup = await setupStateBackend(options);
28293
28699
  try {
28700
+ const refs = await setup.stateBackend.listStacks();
28701
+ const ref = resolveSingleRegion(stackName, refs, options.stackRegion);
28702
+ if (!ref.region) {
28703
+ throw new Error(
28704
+ `Stack '${stackName}' has only a legacy state record without a region. Run 'cdkd deploy ${stackName}' (or any cdkd write) to migrate it to the region-scoped layout, then re-run this command.`
28705
+ );
28706
+ }
28294
28707
  const [stateResult, lockInfo] = await Promise.all([
28295
- setup.stateBackend.getState(stackName),
28296
- setup.lockManager.getLockInfo(stackName)
28708
+ setup.stateBackend.getState(stackName, ref.region),
28709
+ setup.lockManager.getLockInfo(stackName, ref.region)
28297
28710
  ]);
28298
28711
  if (!stateResult) {
28299
28712
  throw new Error(
28300
- `No state found for stack '${stackName}' in s3://${setup.bucket}/${setup.prefix}/. Run 'cdkd state list' to see available stacks.`
28713
+ `No state found for stack '${stackName}' (${ref.region}) in s3://${setup.bucket}/${setup.prefix}/. Run 'cdkd state list' to see available stacks.`
28301
28714
  );
28302
28715
  }
28303
28716
  if (options.json) {
@@ -28361,7 +28774,7 @@ async function stateShowCommand(stackName, options) {
28361
28774
  }
28362
28775
  }
28363
28776
  function createStateShowCommand() {
28364
- const cmd = new Command9("show").description("Show the full cdkd state record for a stack (metadata, outputs, resources)").argument("<stack>", "Stack name (physical CloudFormation name)").option("--json", "Output the raw state and lock as JSON", false).action(withErrorHandling(stateShowCommand));
28777
+ const cmd = new Command9("show").description("Show the full cdkd state record for a stack (metadata, outputs, resources)").argument("<stack>", "Stack name (physical CloudFormation name)").option("--json", "Output the raw state and lock as JSON", false).addOption(stackRegionOption()).action(withErrorHandling(stateShowCommand));
28365
28778
  [...commonOptions, ...stateOptions].forEach((opt) => cmd.addOption(opt));
28366
28779
  return cmd;
28367
28780
  }
@@ -28374,24 +28787,36 @@ async function stateRmCommand(stackArgs, options) {
28374
28787
  }
28375
28788
  const setup = await setupStateBackend(options);
28376
28789
  try {
28790
+ const refs = await setup.stateBackend.listStacks();
28377
28791
  for (const stackName of stackArgs) {
28378
- const exists = await setup.stateBackend.stateExists(stackName);
28379
- if (!exists) {
28792
+ const stackRefs = refs.filter((r) => r.stackName === stackName);
28793
+ if (stackRefs.length === 0) {
28380
28794
  logger.info(`No state found for stack: ${stackName}, skipping`);
28381
28795
  continue;
28382
28796
  }
28797
+ const targets = options.stackRegion ? stackRefs.filter((r) => r.region === options.stackRegion) : stackRefs;
28798
+ if (targets.length === 0) {
28799
+ const seen = stackRefs.map((r) => r.region ?? "(legacy)").join(", ");
28800
+ throw new Error(
28801
+ `No state found for stack '${stackName}' in region '${options.stackRegion}'. Available regions: ${seen}.`
28802
+ );
28803
+ }
28383
28804
  if (!options.force) {
28384
- const locked = await setup.lockManager.isLocked(stackName);
28385
- if (locked) {
28386
- throw new Error(
28387
- `Stack '${stackName}' is locked. Run 'cdkd force-unlock ${stackName}' first, or pass --force to remove anyway.`
28388
- );
28805
+ for (const target of targets) {
28806
+ const locked = await setup.lockManager.isLocked(stackName, target.region);
28807
+ if (locked) {
28808
+ const where = target.region ?? "(legacy)";
28809
+ throw new Error(
28810
+ `Stack '${stackName}' (${where}) is locked. Run 'cdkd force-unlock ${stackName}${target.region ? ` --stack-region ${target.region}` : ""}' first, or pass --force to remove anyway.`
28811
+ );
28812
+ }
28389
28813
  }
28390
28814
  }
28391
28815
  if (!options.yes && !options.force) {
28816
+ const targetList = targets.map((t) => formatStackRef(t)).join(", ");
28392
28817
  process.stdout.write(
28393
28818
  `
28394
- WARNING: This removes cdkd's state record for '${stackName}' only. AWS resources will NOT be deleted.
28819
+ WARNING: This removes cdkd's state record for [${targetList}] only. AWS resources will NOT be deleted.
28395
28820
  Use 'cdkd destroy ${stackName}' if you want to delete the actual resources.
28396
28821
 
28397
28822
  `
@@ -28401,7 +28826,7 @@ Use 'cdkd destroy ${stackName}' if you want to delete the actual resources.
28401
28826
  output: process.stdout
28402
28827
  });
28403
28828
  const answer = await rl.question(
28404
- `Remove state for stack '${stackName}' from s3://${setup.bucket}/${setup.prefix}/? (y/N): `
28829
+ `Remove state for ${targetList} from s3://${setup.bucket}/${setup.prefix}/? (y/N): `
28405
28830
  );
28406
28831
  rl.close();
28407
28832
  const trimmed = answer.trim().toLowerCase();
@@ -28410,16 +28835,28 @@ Use 'cdkd destroy ${stackName}' if you want to delete the actual resources.
28410
28835
  continue;
28411
28836
  }
28412
28837
  }
28413
- await setup.stateBackend.deleteState(stackName);
28414
- await setup.lockManager.forceReleaseLock(stackName);
28415
- logger.info(`\u2713 Removed state for stack: ${stackName}`);
28838
+ for (const target of targets) {
28839
+ if (target.region) {
28840
+ await setup.stateBackend.deleteState(stackName, target.region);
28841
+ await setup.lockManager.forceReleaseLock(stackName, target.region);
28842
+ } else {
28843
+ await setup.lockManager.forceReleaseLock(stackName, void 0);
28844
+ }
28845
+ logger.info(`\u2713 Removed state for stack: ${formatStackRef(target)}`);
28846
+ }
28416
28847
  }
28417
28848
  } finally {
28418
28849
  setup.dispose();
28419
28850
  }
28420
28851
  }
28852
+ function stackRegionOption() {
28853
+ return new Option4(
28854
+ "--stack-region <region>",
28855
+ "Region of the stack record to operate on. Required when the same stack name has state in multiple regions."
28856
+ );
28857
+ }
28421
28858
  function createStateRmCommand() {
28422
- const cmd = new Command9("rm").description("Remove cdkd state for one or more stacks (does NOT delete AWS resources)").argument("<stacks...>", "Stack name(s) to remove from state").option("-f, --force", "Skip confirmation and remove even if the stack is locked", false).action(withErrorHandling(stateRmCommand));
28859
+ const cmd = new Command9("rm").description("Remove cdkd state for one or more stacks (does NOT delete AWS resources)").argument("<stacks...>", "Stack name(s) to remove from state").option("-f, --force", "Skip confirmation and remove even if the stack is locked", false).addOption(stackRegionOption()).action(withErrorHandling(stateRmCommand));
28423
28860
  [...commonOptions, ...stateOptions].forEach((opt) => cmd.addOption(opt));
28424
28861
  return cmd;
28425
28862
  }
@@ -28457,7 +28894,7 @@ function reorderArgs(argv) {
28457
28894
  }
28458
28895
  async function main() {
28459
28896
  const program = new Command10();
28460
- program.name("cdkd").description("CDK Direct - Deploy AWS CDK apps directly via SDK/Cloud Control API").version("0.6.0");
28897
+ program.name("cdkd").description("CDK Direct - Deploy AWS CDK apps directly via SDK/Cloud Control API").version("0.8.0");
28461
28898
  program.addCommand(createBootstrapCommand());
28462
28899
  program.addCommand(createSynthCommand());
28463
28900
  program.addCommand(createListCommand());