@go-to-k/cdkd 0.281.22 → 0.281.24

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
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
- import { $ as configStringRefusal, $t as MIGRATE_TMP_PREFIX, A as green, At as validateContainerRepoName, B as slowCcOperationTimeoutMs, Bt as getDefaultStateBucketName, C as MULTI_REGION_RECREATE_BLOCKED_TYPES, Cn as PartialFailureError, Ct as rewriteTemplateAssetReferences, D as bold, Dn as StackHasActiveImportsError, Dt as getBootstrapMarkerKey, E as formatResourceLine, En as ResourceUpdateNotSupportedError, Et as ensureAssetStorage, F as clearOnUpdateRemoval, Ft as runDockerStreaming, G as getAccountInfo, Gt as resolveSkipPrefix, H as isTerminationProtectionPropagationError, Ht as resolveApp, I as ProviderRegistry, It as AssetManifestLoader, J as normalizeAwsTagsToCfn, Jt as resolveUseCdkBootstrapAssets, K as refStateLookupFromResource, Kt as resolveStateBucketWithDefault, L as findActionableSilentDrops, Lt as getDockerImageBySourceHash, M as yellow, Mt as formatDockerLoginError, N as IAMRoleProvider, Nn as normalizeAwsError, Nt as getDockerCmd, O as cyan, On as StackTerminationProtectionError, Ot as parseBootstrapMarker, P as collectInlinePolicyNamesManagedBySiblings, Pn as withErrorHandling, Pt as runDockerForeground, Q as configBooleanRefusal, Qt as CFN_TEMPLATE_URL_LIMIT, R as findSilentDropProperties, Rt as Synthesizer, S as extractDeploymentEventError, Sn as NestedStackChildDirectDestroyError, St as loadPublishableAssetManifest, T as renderStatefulReason, Tn as ResourceTimeoutError, Tt as BOOTSTRAP_MARKER_PREFIX, U as IntrinsicFunctionResolver, Ut as resolveAutoAssetStorage, V as disableInstanceApiTermination, Vt as getLegacyStateBucketName, W as cfnRefValueFromPhysicalId, Wt as resolveCaptureObservedState, X as assertRegionMatch, Xt as warnDeprecatedNoPrefixCliFlag, Y as resolveExplicitPhysicalId, Yt as stateBucketExistenceConfirmed, Z as coerceCfnBoolean, Zt as CFN_TEMPLATE_BODY_LIMIT, _ as createPreDeleteFinalSnapshot, _n as LocalInvokeBuildError$1, _t as AssetPublisher, a as DeploymentEventsStore, an as processStackMessages, at as applyRoleArnIfSet, b as unsupportedFinalSnapshotError, bt as buildAssetRedirectMap, c as replayFailedOperations, cn as AwsClients, ct as withRetry, d as IMPLICIT_DELETE_DEPENDENCIES, dn as setAwsClients, dt as DagBuilder, en as findLargeInlineResources, et as readConfigString, f as computeImplicitDeleteEdges, ft as TemplateParser, g as ccRoutedFinalSnapshotError, gn as DeployCancelledError, gt as shouldRetainResource, h as buildFinalSnapshotIdentifier, ht as rebuildClientForBucketRegion, i as DeploymentEventsReader, in as AssemblyReader, it as requireConfigString, j as red, jt as buildDockerImage, k as gray, kt as validateAssetBucketName, l as replayRollback, ln as getAwsClients, lt as isRetryableTransientError, m as PRE_DELETE_SNAPSHOT_TYPES, mt as S3StateBackend, n as DEFAULT_RESOURCE_WARN_AFTER_MS, nn as expectedOwnerParam, nt as requireConfigArray, o as planFailedOps, ot as DiffCalculator, p as ATOMIC_FINAL_SNAPSHOT_TYPES, pn as CdkdError, pt as LockManager, q as WAFv2WebACLProvider, qt as resolveStateBucketWithDefaultAndSource, r as DeployEngine, rn as derivePartitionAndUrlSuffix, rt as requireConfigObject, s as planRollback, sn as resolveBucketRegion, st as describeTypeWithThrottleRetry, t as DEFAULT_RESOURCE_TIMEOUT_MS, tn as uploadCfnTemplate, tt as replayWarn, u as withResourceDeadline, un as resetAwsClients, ut as isThrottlingError, v as isFinalSnapshotError, vn as LocalMigrateError, vt as stringifyValue, w as isStatefulRecreateTargetSync, wn as ProvisioningError, wt as AssetModeResolver, x as makeCanonicalizePropertiesFn, xn as MissingCdkCliError, xt as createAssetRedirectResolver, y as refusesFinalSnapshot, yn as LocalStartServiceError, yt as WorkGraph, z as CloudControlProvider, zt as synthesisStatusMessage } from "./deploy-engine-DQd1PUUv.js";
2
+ import { $ as configStringRefusal, $t as MIGRATE_TMP_PREFIX, A as green, At as validateContainerRepoName, B as slowCcOperationTimeoutMs, Bt as getDefaultStateBucketName, C as MULTI_REGION_RECREATE_BLOCKED_TYPES, Cn as PartialFailureError, Ct as rewriteTemplateAssetReferences, D as bold, Dn as StackHasActiveImportsError, Dt as getBootstrapMarkerKey, E as formatResourceLine, En as ResourceUpdateNotSupportedError, Et as ensureAssetStorage, F as clearOnUpdateRemoval, Ft as runDockerStreaming, G as getAccountInfo, Gt as resolveSkipPrefix, H as isTerminationProtectionPropagationError, Ht as resolveApp, I as ProviderRegistry, It as AssetManifestLoader, J as normalizeAwsTagsToCfn, Jt as resolveUseCdkBootstrapAssets, K as refStateLookupFromResource, Kt as resolveStateBucketWithDefault, L as findActionableSilentDrops, Lt as getDockerImageBySourceHash, M as yellow, Mt as formatDockerLoginError, N as IAMRoleProvider, Nn as normalizeAwsError, Nt as getDockerCmd, O as cyan, On as StackTerminationProtectionError, Ot as parseBootstrapMarker, P as collectInlinePolicyNamesManagedBySiblings, Pn as withErrorHandling, Pt as runDockerForeground, Q as configBooleanRefusal, Qt as CFN_TEMPLATE_URL_LIMIT, R as findSilentDropProperties, Rt as Synthesizer, S as extractDeploymentEventError, Sn as NestedStackChildDirectDestroyError, St as loadPublishableAssetManifest, T as renderStatefulReason, Tn as ResourceTimeoutError, Tt as BOOTSTRAP_MARKER_PREFIX, U as IntrinsicFunctionResolver, Ut as resolveAutoAssetStorage, V as disableInstanceApiTermination, Vt as getLegacyStateBucketName, W as cfnRefValueFromPhysicalId, Wt as resolveCaptureObservedState, X as assertRegionMatch, Xt as warnDeprecatedNoPrefixCliFlag, Y as resolveExplicitPhysicalId, Yt as stateBucketExistenceConfirmed, Z as coerceCfnBoolean, Zt as CFN_TEMPLATE_BODY_LIMIT, _ as createPreDeleteFinalSnapshot, _n as LocalInvokeBuildError$1, _t as AssetPublisher, a as DeploymentEventsStore, an as processStackMessages, at as applyRoleArnIfSet, b as unsupportedFinalSnapshotError, bt as buildAssetRedirectMap, c as replayFailedOperations, cn as AwsClients, ct as withRetry, d as IMPLICIT_DELETE_DEPENDENCIES, dn as setAwsClients, dt as DagBuilder, en as findLargeInlineResources, et as readConfigString, f as computeImplicitDeleteEdges, ft as TemplateParser, g as ccRoutedFinalSnapshotError, gn as DeployCancelledError, gt as shouldRetainResource, h as buildFinalSnapshotIdentifier, ht as rebuildClientForBucketRegion, i as DeploymentEventsReader, in as AssemblyReader, it as requireConfigString, j as red, jt as buildDockerImage, k as gray, kt as validateAssetBucketName, l as replayRollback, ln as getAwsClients, lt as isRetryableTransientError, m as PRE_DELETE_SNAPSHOT_TYPES, mt as S3StateBackend, n as DEFAULT_RESOURCE_WARN_AFTER_MS, nn as expectedOwnerParam, nt as requireConfigArray, o as planFailedOps, ot as DiffCalculator, p as ATOMIC_FINAL_SNAPSHOT_TYPES, pn as CdkdError, pt as LockManager, q as WAFv2WebACLProvider, qt as resolveStateBucketWithDefaultAndSource, r as DeployEngine, rn as derivePartitionAndUrlSuffix, rt as requireConfigObject, s as planRollback, sn as resolveBucketRegion, st as describeTypeWithThrottleRetry, t as DEFAULT_RESOURCE_TIMEOUT_MS, tn as uploadCfnTemplate, tt as replayWarn, u as withResourceDeadline, un as resetAwsClients, ut as isThrottlingError, v as isFinalSnapshotError, vn as LocalMigrateError, vt as stringifyValue, w as isStatefulRecreateTargetSync, wn as ProvisioningError, wt as AssetModeResolver, x as makeCanonicalizePropertiesFn, xn as MissingCdkCliError, xt as createAssetRedirectResolver, y as refusesFinalSnapshot, yn as LocalStartServiceError, yt as WorkGraph, z as CloudControlProvider, zt as synthesisStatusMessage } from "./deploy-engine-BkXNtI7L.js";
3
3
  import { a as getLiveRenderer, c as PATTERN_B_RESOURCE_TYPES, d as generateResourceNameWithFallback, h as withStackName, i as runStackBuffered, m as withSkipPrefix, n as getLogger, o as PATTERN_B_NAME_OPTIONS, s as PATTERN_B_NAME_PROPERTIES, u as generateResourceName } from "./logger-zRrlbaQt.js";
4
- import { t as ASGProvider } from "./asg-provider-pvAc9MXZ.js";
4
+ import { t as ASGProvider } from "./asg-provider-CxK7d3pH.js";
5
5
  import { AsyncLocalStorage } from "node:async_hooks";
6
6
  import { createHash, randomBytes, randomUUID } from "node:crypto";
7
7
  import { CopyObjectCommand, CreateBucketCommand, DeleteBucketAnalyticsConfigurationCommand, DeleteBucketCommand, DeleteBucketCorsCommand, DeleteBucketEncryptionCommand, DeleteBucketIntelligentTieringConfigurationCommand, DeleteBucketInventoryConfigurationCommand, DeleteBucketLifecycleCommand, DeleteBucketMetricsConfigurationCommand, DeleteBucketOwnershipControlsCommand, DeleteBucketPolicyCommand, DeleteBucketReplicationCommand, DeleteBucketTaggingCommand, DeleteBucketWebsiteCommand, DeleteObjectsCommand, GetBucketAccelerateConfigurationCommand, GetBucketCorsCommand, GetBucketEncryptionCommand, GetBucketLifecycleConfigurationCommand, GetBucketLocationCommand, GetBucketLoggingCommand, GetBucketNotificationConfigurationCommand, GetBucketOwnershipControlsCommand, GetBucketPolicyCommand, GetBucketReplicationCommand, GetBucketTaggingCommand, GetBucketVersioningCommand, GetBucketWebsiteCommand, GetObjectCommand, GetObjectLockConfigurationCommand, GetPublicAccessBlockCommand, HeadBucketCommand, ListBucketAnalyticsConfigurationsCommand, ListBucketIntelligentTieringConfigurationsCommand, ListBucketInventoryConfigurationsCommand, ListBucketMetricsConfigurationsCommand, ListObjectVersionsCommand, ListObjectsV2Command, NoSuchBucket, PutBucketAccelerateConfigurationCommand, PutBucketAnalyticsConfigurationCommand, PutBucketCorsCommand, PutBucketEncryptionCommand, PutBucketIntelligentTieringConfigurationCommand, PutBucketInventoryConfigurationCommand, PutBucketLifecycleConfigurationCommand, PutBucketLoggingCommand, PutBucketMetricsConfigurationCommand, PutBucketNotificationConfigurationCommand, PutBucketOwnershipControlsCommand, PutBucketPolicyCommand, PutBucketReplicationCommand, PutBucketTaggingCommand, PutBucketVersioningCommand, PutBucketWebsiteCommand, PutObjectCommand, PutObjectLockConfigurationCommand, PutPublicAccessBlockCommand, S3Client, S3ServiceException } from "@aws-sdk/client-s3";
@@ -12455,16 +12455,57 @@ function formatShape(segments) {
12455
12455
  * ## The `skipping` variant
12456
12456
  *
12457
12457
  * On the DELETE path the established policy is warn-and-continue, so a
12458
- * malformed record leaves the AWS resource ALIVE while `cdkd destroy` reports
12459
- * success. That policy is not changed here but the warning now says what the
12460
- * skip costs, because an orphan the user is never told about is one they cannot
12461
- * go and delete.
12458
+ * malformed record leaves the AWS resource ALIVE. That policy is unchanged —
12459
+ * the warning says what the skip costs, because an orphan the user is never
12460
+ * told about is one they cannot go and delete.
12461
+ *
12462
+ * Since issue [#1752](https://github.com/go-to-k/cdkd/issues/1752) the ACCOUNTING
12463
+ * around it is no longer a lie: an arm emitting this message must also return
12464
+ * `{ outcome: 'skipped', reason: COMPOSITE_ID_SKIP_REASON }` so `cdkd destroy`
12465
+ * prints a `⚠ … skipped` line instead of `✓ … deleted`, reports
12466
+ * `N deleted, 1 skipped`, KEEPS the state record, and exits non-zero. The
12467
+ * wording here changed accordingly — it used to promise unconditionally that
12468
+ * cdkd "will report this delete as successful", which is no longer true on the
12469
+ * destroy path. It is STILL true for the deploy engine's replacement / rollback
12470
+ * deletes, which discard the return value (issue
12471
+ * [#1762](https://github.com/go-to-k/cdkd/issues/1762)), so the message names
12472
+ * both callers rather than describing only the fixed one — a remedy of
12473
+ * "drop the record with 'cdkd state orphan'" is impossible on a path that has
12474
+ * already dropped it.
12462
12475
  */
12463
12476
  function compositeIdFormatMessage(format, logicalId, physicalId, options) {
12464
12477
  const accepted = format.alsoAccepts ? `"${formatShape(format.segments)}" or ${format.alsoAccepts}` : `"${formatShape(format.segments)}"`;
12465
12478
  const head = `Invalid physicalId format for ${format.label} ${logicalId}: expected ${accepted}, got "${physicalId}"`;
12466
12479
  if (!options?.skipping) return head;
12467
- return `${head}. Skipping the delete — the AWS resource is LEFT IN PLACE and cdkd will report this delete as successful, so delete it by hand (or repair the id in state.json and re-run) if it still exists. NOTE this arm is reached from cdkd destroy AND from the deploy engine's replacement / rollback deletes.`;
12480
+ return `${head}. Skipping the delete — no AWS call is issued, so the resource is LEFT IN PLACE. On 'cdkd destroy' / 'cdkd state destroy' the state record is KEPT and the run exits non-zero, so repair the id in state.json and re-run (or delete the resource by hand and drop the record with 'cdkd state orphan'). NOTE this arm is ALSO reached from cdkd deploy the plain DELETE of a resource removed from the template (the most common one), plus the replacement and rollback deletes — all of which still DROP the record and report success (https://github.com/go-to-k/cdkd/issues/1762) — there, delete it by hand.`;
12481
+ }
12482
+ /**
12483
+ * The short `ResourceDeleteResult.reason` every malformed-composite-id DELETE
12484
+ * skip reports (issue [#1752](https://github.com/go-to-k/cdkd/issues/1752)).
12485
+ *
12486
+ * Deliberately short and IDENTICAL across the five arms: it is rendered inline
12487
+ * on the per-resource status line (`⚠ MyTable (AWS::Glue::Table) skipped
12488
+ * (malformed physicalId in state — no delete issued)`), where the full
12489
+ * remediation sentence built by {@link compositeIdFormatMessage} would not fit.
12490
+ * The full text is still emitted, as the `logger.warn` right beside it.
12491
+ *
12492
+ * A shared constant rather than five literals so the five arms cannot drift and
12493
+ * a grep for the wording finds all of them.
12494
+ */
12495
+ const COMPOSITE_ID_SKIP_REASON = "malformed physicalId in state — no delete issued";
12496
+ /**
12497
+ * The `ResourceDeleteResult` a malformed-composite-id DELETE arm returns.
12498
+ *
12499
+ * Pairs with the `{ skipping: true }` warning: emit the warning, return this.
12500
+ * Providers call it as `return compositeIdSkipResult();` so the arm reads as
12501
+ * the deliberate skip it is rather than as the bare `return;` that made issue
12502
+ * #1752's mis-accounting possible.
12503
+ */
12504
+ function compositeIdSkipResult() {
12505
+ return {
12506
+ outcome: "skipped",
12507
+ reason: COMPOSITE_ID_SKIP_REASON
12508
+ };
12468
12509
  }
12469
12510
 
12470
12511
  //#endregion
@@ -21890,7 +21931,7 @@ var EC2Provider = class {
21890
21931
  const parts = physicalId.split("|");
21891
21932
  if (parts.length < 3) {
21892
21933
  this.logger.warn(compositeIdFormatMessage(EC2_NETWORK_ACL_ENTRY_ID_FORMAT, logicalId, physicalId, { skipping: true }));
21893
- return;
21934
+ return compositeIdSkipResult();
21894
21935
  }
21895
21936
  const networkAclId = parts[0];
21896
21937
  const ruleNumber = parseInt(parts[1], 10);
@@ -39028,7 +39069,7 @@ var AppSyncProvider = class {
39028
39069
  const [apiId, name] = physicalId.split("|");
39029
39070
  if (!apiId || !name) {
39030
39071
  this.logger.warn(compositeIdFormatMessage(APPSYNC_DATASOURCE_ID_FORMAT, logicalId, physicalId, { skipping: true }));
39031
- return;
39072
+ return compositeIdSkipResult();
39032
39073
  }
39033
39074
  try {
39034
39075
  await this.getClient().send(new DeleteDataSourceCommand({
@@ -39091,7 +39132,7 @@ var AppSyncProvider = class {
39091
39132
  const parts = physicalId.split("|");
39092
39133
  if (parts.length < 3) {
39093
39134
  this.logger.warn(compositeIdFormatMessage(APPSYNC_RESOLVER_ID_FORMAT, logicalId, physicalId, { skipping: true }));
39094
- return;
39135
+ return compositeIdSkipResult();
39095
39136
  }
39096
39137
  const [apiId, typeName, fieldName] = parts;
39097
39138
  try {
@@ -39147,7 +39188,7 @@ var AppSyncProvider = class {
39147
39188
  const [apiId, apiKeyId] = physicalId.split("|");
39148
39189
  if (!apiId || !apiKeyId) {
39149
39190
  this.logger.warn(compositeIdFormatMessage(APPSYNC_APIKEY_ID_FORMAT, logicalId, physicalId, { skipping: true }));
39150
- return;
39191
+ return compositeIdSkipResult();
39151
39192
  }
39152
39193
  try {
39153
39194
  await this.getClient().send(new DeleteApiKeyCommand({
@@ -39873,7 +39914,7 @@ var GlueProvider = class {
39873
39914
  const [databaseName, tableName] = physicalId.split("|");
39874
39915
  if (!databaseName || !tableName) {
39875
39916
  this.logger.warn(compositeIdFormatMessage(GLUE_TABLE_ID_FORMAT, logicalId, physicalId, { skipping: true }));
39876
- return;
39917
+ return compositeIdSkipResult();
39877
39918
  }
39878
39919
  const target = deleteCatalogId(properties);
39879
39920
  if (target.declaredButUnusable) this.logger.debug(`Glue Table ${logicalId}: CatalogId is not a usable value (likely an unresolved intrinsic), so DeleteTable targets ${describeCatalog(target.catalogId)}.`);
@@ -52076,6 +52117,12 @@ var ECRProvider = class {
52076
52117
  *
52077
52118
  * Exported for unit-test coverage of `countProtectedResources`.
52078
52119
  */
52120
+ /**
52121
+ * The nested-stack resource type. A skipped row of this type means the CHILD
52122
+ * stack was not destroyed, and the record a user must repair lives in the
52123
+ * child's own state file (`<parent>~<logicalId>`) — see `skippedStateTargets`.
52124
+ */
52125
+ const NESTED_STACK_TYPE = "AWS::CloudFormation::Stack";
52079
52126
  const PROTECTION_PROPERTY_BY_TYPE = {
52080
52127
  "AWS::Logs::LogGroup": "DeletionProtectionEnabled",
52081
52128
  "AWS::RDS::DBInstance": "DeletionProtection",
@@ -52179,6 +52226,7 @@ async function runDestroyForStack(stackName, state, ctx) {
52179
52226
  skippedEmpty: false,
52180
52227
  deletedCount: 0,
52181
52228
  retainedCount: 0,
52229
+ skippedCount: 0,
52182
52230
  errorCount: 0,
52183
52231
  interrupted: false
52184
52232
  };
@@ -52285,6 +52333,7 @@ async function runDestroyForStack(stackName, state, ctx) {
52285
52333
  }
52286
52334
  }
52287
52335
  const remainingResources = { ...state.resources };
52336
+ const skippedStateTargets = /* @__PURE__ */ new Set();
52288
52337
  const buildDestroySnapshot = () => {
52289
52338
  const { imports: _imports, outputReads: _outputReads, ...rest } = state;
52290
52339
  return {
@@ -52412,15 +52461,16 @@ async function runDestroyForStack(stackName, state, ctx) {
52412
52461
  const globalTimeoutMs = ctx.resourceTimeoutMs ?? 18e5;
52413
52462
  const slowTypeMinTimeoutMs = slowCcOperationTimeoutMs(resource.resourceType, "DELETE");
52414
52463
  const timeoutMs = ctx.resourceTimeoutByType?.[resource.resourceType] ?? Math.max(providerMinTimeoutMs, slowTypeMinTimeoutMs, globalTimeoutMs);
52464
+ let deleteResult;
52415
52465
  await withResourceDeadline(async () => {
52416
52466
  const maxAttempts = provider.disableOuterRetry ? 0 : 3;
52417
52467
  let lastDeleteError;
52418
52468
  for (let attempt = 0; attempt <= maxAttempts; attempt++) try {
52419
- await provider.delete(logicalId, resource.physicalId, resource.resourceType, resource.properties, {
52469
+ deleteResult = await provider.delete(logicalId, resource.physicalId, resource.resourceType, resource.properties, {
52420
52470
  ...state.region !== void 0 && { expectedRegion: state.region },
52421
52471
  ...ctx.removeProtection === true && { removeProtection: true },
52422
52472
  ...finalSnapshotIdentifier !== void 0 && { finalSnapshotIdentifier }
52423
- });
52473
+ }) ?? void 0;
52424
52474
  lastDeleteError = null;
52425
52475
  break;
52426
52476
  } catch (retryError) {
@@ -52445,6 +52495,23 @@ async function runDestroyForStack(stackName, state, ctx) {
52445
52495
  onTimeout: (elapsedMs) => new ResourceTimeoutError(logicalId, resource.resourceType, stackRegion, elapsedMs, "DELETE", timeoutMs)
52446
52496
  });
52447
52497
  renderer.removeTask(logicalId);
52498
+ if (deleteResult?.outcome === "skipped") {
52499
+ logger.info(` ${formatResourceLine("skipped", logicalId, resource.resourceType, `skipped (${deleteResult.reason})`)}`);
52500
+ result.skippedCount++;
52501
+ skippedStateTargets.add(resource.resourceType === NESTED_STACK_TYPE ? `${stackName}~${logicalId}` : stackName);
52502
+ ctx.eventRecorder?.record({
52503
+ eventType: "RESOURCE_SKIPPED",
52504
+ stackName,
52505
+ operation: "DELETE",
52506
+ logicalId,
52507
+ resourceType: resource.resourceType,
52508
+ ...resource.provisionedBy && { provisionedBy: resource.provisionedBy },
52509
+ ...resource.physicalId && { physicalId: resource.physicalId },
52510
+ reason: deleteResult.reason,
52511
+ durationMs: Date.now() - resourceStartedAt
52512
+ });
52513
+ return;
52514
+ }
52448
52515
  logger.info(` ${formatResourceLine("deleted", logicalId, resource.resourceType)}`);
52449
52516
  result.deletedCount++;
52450
52517
  ctx.eventRecorder?.record({
@@ -52513,7 +52580,7 @@ async function runDestroyForStack(stackName, state, ctx) {
52513
52580
  }
52514
52581
  result.interrupted = draining;
52515
52582
  await saveChain;
52516
- const preserveState = result.errorCount > 0 || result.interrupted;
52583
+ const preserveState = result.errorCount > 0 || result.interrupted || result.skippedCount > 0;
52517
52584
  if (!preserveState) {
52518
52585
  await ctx.stateBackend.deleteState(stackName, regionForState);
52519
52586
  logger.debug("State deleted");
@@ -52525,12 +52592,19 @@ async function runDestroyForStack(stackName, state, ctx) {
52525
52592
  logger.warn(`Failed to persist remaining state after partial destroy: ${error instanceof Error ? error.message : String(error)}. The state file may still list already-deleted resources; a re-run resolves them idempotently.`);
52526
52593
  }
52527
52594
  if (result.interrupted) logger.warn(`Destroy interrupted — ${Object.keys(remainingResources).length} resource(s) not deleted. State preserved.`);
52528
- else logger.warn(`${result.errorCount} resource(s) failed to delete. State preserved.`);
52595
+ else if (result.errorCount > 0) logger.warn(`${result.errorCount} resource(s) failed to delete. State preserved.`);
52596
+ else logger.warn(`${result.skippedCount} resource(s) skipped — cdkd could not address them, so no delete was issued and they may still exist in AWS. State preserved (the records are kept so the resources stay traceable).`);
52529
52597
  }
52530
52598
  const retainedSuffix = result.retainedCount > 0 ? `, ${result.retainedCount} retained` : "";
52599
+ const skippedSuffix = result.skippedCount > 0 ? `, ${yellow(result.skippedCount)} skipped` : "";
52531
52600
  if (!preserveState) logger.info(`\n${green("✓")} ${bold(`Stack ${stackName} destroyed`)} (${green(result.deletedCount)} deleted${retainedSuffix}, ${result.errorCount} errors)`);
52532
- else if (result.interrupted && result.errorCount === 0) logger.warn(`\n${yellow("⚠")} ${bold(`Stack ${stackName} destroy interrupted`)} (${green(result.deletedCount)} deleted${retainedSuffix}, ${result.errorCount} errors). State preserved — re-run 'cdkd destroy' / 'cdkd state destroy' to finish.`);
52533
- else logger.warn(`\n${yellow("⚠")} ${bold(`Stack ${stackName} partially destroyed`)} (${green(result.deletedCount)} deleted${retainedSuffix}, ${red(result.errorCount)} errors). State preserved — re-run 'cdkd destroy' / 'cdkd state destroy' to clean up. If the same resource keeps failing, 'cdkd state orphan ${stackName}' is the last resort: it removes the state record without deleting AWS resources.`);
52601
+ else if (result.interrupted && result.errorCount === 0) logger.warn(`\n${yellow("⚠")} ${bold(`Stack ${stackName} destroy interrupted`)} (${green(result.deletedCount)} deleted${retainedSuffix}${skippedSuffix}, ${result.errorCount} errors). State preserved — re-run 'cdkd destroy' / 'cdkd state destroy' to finish.`);
52602
+ else if (result.errorCount === 0) {
52603
+ const targets = [...skippedStateTargets];
52604
+ const showHint = targets.map((t) => `'cdkd state show ${t}'`).join(" / ");
52605
+ const orphanHint = targets.map((t) => `'cdkd state orphan ${t}'`).join(" / ");
52606
+ logger.warn(`\n${yellow("⚠")} ${bold(`Stack ${stackName} partially destroyed`)} (${green(result.deletedCount)} deleted${retainedSuffix}${skippedSuffix}, ${result.errorCount} errors). cdkd could not address the skipped resource(s), so they may still exist in AWS. Fix the physicalId in state.json (${showHint}) and re-run, or delete them by hand and drop the records with ${orphanHint}.`);
52607
+ } else logger.warn(`\n${yellow("⚠")} ${bold(`Stack ${stackName} partially destroyed`)} (${green(result.deletedCount)} deleted${retainedSuffix}${skippedSuffix}, ${red(result.errorCount)} errors). State preserved — re-run 'cdkd destroy' / 'cdkd state destroy' to clean up. If the same resource keeps failing, 'cdkd state orphan ${stackName}' is the last resort: it removes the state record without deleting AWS resources.`);
52534
52608
  } finally {
52535
52609
  process.removeListener("SIGINT", sigintHandler);
52536
52610
  renderer.stop();
@@ -52749,7 +52823,7 @@ var NestedStackProvider = class {
52749
52823
  }
52750
52824
  const resourceCount = Object.keys(childStateData.state.resources).length;
52751
52825
  this.logger.info(`Destroying nested stack ${childStackName} (logicalId=${logicalId}, ${resourceCount} resource(s))`);
52752
- await withNestedStackContext({
52826
+ const childResult = await withNestedStackContext({
52753
52827
  ...ctx,
52754
52828
  parentStackName: childStackName,
52755
52829
  parentRegion: childRegion,
@@ -52771,6 +52845,15 @@ var NestedStackProvider = class {
52771
52845
  ...ctx.destroyOptions?.resourceWarnAfterByType && { resourceWarnAfterByType: ctx.destroyOptions.resourceWarnAfterByType },
52772
52846
  ...ctx.destroyOptions?.resourceTimeoutByType && { resourceTimeoutByType: ctx.destroyOptions.resourceTimeoutByType }
52773
52847
  }));
52848
+ if (childResult.skippedCount > 0 || childResult.interrupted) {
52849
+ const causes = [];
52850
+ if (childResult.skippedCount > 0) causes.push(`skipped ${childResult.skippedCount} resource(s)`);
52851
+ if (childResult.interrupted) causes.push("was interrupted");
52852
+ return {
52853
+ outcome: "skipped",
52854
+ reason: `nested stack ${childStackName} ${causes.join(" and ")}`
52855
+ };
52856
+ }
52774
52857
  }
52775
52858
  async getAttribute(_physicalId, _resourceType, attributeName) {
52776
52859
  throw new Error(`AWS::CloudFormation::Stack: attribute '${attributeName}' is not in the recorded Outputs map. Only 'Outputs.<Key>' references to declared Output names on the child template are supported.`);
@@ -57689,6 +57772,7 @@ async function destroyCommand(stackArgs, options) {
57689
57772
  } else throw new Error("Could not determine which stacks belong to this app. Specify stack names explicitly, use --all, or ensure --app / cdk.json is configured.");
57690
57773
  const stackPatterns = stackArgs.length > 0 ? stackArgs : options.stack ? [options.stack] : [];
57691
57774
  let totalErrors = 0;
57775
+ let totalSkipped = 0;
57692
57776
  let interrupted = false;
57693
57777
  let stackNames;
57694
57778
  if (options.all) stackNames = candidateStacks.map((s) => s.stackName);
@@ -57813,8 +57897,9 @@ async function destroyCommand(stackArgs, options) {
57813
57897
  eventRecorder
57814
57898
  }));
57815
57899
  totalErrors += result.errorCount;
57900
+ totalSkipped += result.skippedCount;
57816
57901
  if (result.interrupted) interrupted = true;
57817
- destroyRunResult = result.errorCount > 0 ? "FAILED" : "SUCCEEDED";
57902
+ destroyRunResult = result.errorCount > 0 || result.skippedCount > 0 ? "FAILED" : "SUCCEEDED";
57818
57903
  eventRecorder.record({
57819
57904
  eventType: "RUN_FINISHED",
57820
57905
  stackName,
@@ -57823,7 +57908,8 @@ async function destroyCommand(stackArgs, options) {
57823
57908
  created: 0,
57824
57909
  updated: 0,
57825
57910
  deleted: result.deletedCount,
57826
- ...result.errorCount > 0 && { failed: result.errorCount }
57911
+ ...result.errorCount > 0 && { failed: result.errorCount },
57912
+ ...result.skippedCount > 0 && { skipped: result.skippedCount }
57827
57913
  }
57828
57914
  });
57829
57915
  } catch (destroyError) {
@@ -57842,6 +57928,7 @@ async function destroyCommand(stackArgs, options) {
57842
57928
  }
57843
57929
  if (totalErrors > 0) throw new PartialFailureError(`Destroy completed with ${totalErrors} resource error(s). State preserved — inspect 'cdkd state show <stack>' and re-run 'cdkd destroy' to retry. If the same resource keeps failing, 'cdkd state orphan <stack>' removes the state record without deleting AWS resources.`);
57844
57930
  if (interrupted) throw new PartialFailureError(`Destroy interrupted by Ctrl-C. State preserved — re-run 'cdkd destroy' to finish.`);
57931
+ if (totalSkipped > 0) throw new PartialFailureError(`Destroy skipped ${totalSkipped} entr${totalSkipped === 1 ? "y" : "ies"} cdkd could not address, so the underlying resources may still exist in AWS. A skipped nested stack counts as ONE entry and may cover several of its own resources — the per-stack summaries above give the exact breakdown. State preserved (the records are kept). Repair the physicalId in state.json and re-run 'cdkd destroy', or delete the resources by hand and drop the records with 'cdkd state orphan <stack>'.`);
57845
57932
  } finally {
57846
57933
  unforwardSigterm();
57847
57934
  awsClients.destroy();
@@ -58251,9 +58338,17 @@ function stackTreeToJson(roots) {
58251
58338
  * PR B2 — until that lands, the orchestrator hard-errors at submission
58252
58339
  * time with a clear pointer.
58253
58340
  *
58254
- * The list is intentionally narrow. Other resource types CFn may not yet
58255
- * support for import are surfaced as errors by the CreateChangeSet call
58256
- * itself; we do not try to maintain a closed allowlist here.
58341
+ * The list is intentionally narrow, and stays hand-written because each member
58342
+ * is a cdkd / CDK construct rather than something the registry describes.
58343
+ *
58344
+ * It is NOT the only refusal, though — the "defer to the CreateChangeSet
58345
+ * error" note this comment used to end on was revisited in issue
58346
+ * [#1659](https://github.com/go-to-k/cdkd/issues/1659). Types AWS refuses for
58347
+ * IMPORT because their registry schema declares no `read` handler are now
58348
+ * caught by {@link buildImportPlan}'s pre-flight instead, which needs no
58349
+ * hand-maintained list (the signal is derived from the schema cdkd already
58350
+ * fetches for the identifier) and names every offender in one pass — AWS's own
58351
+ * error names only some of them.
58257
58352
  */
58258
58353
  const NEVER_IMPORTABLE_TYPES = /* @__PURE__ */ new Set(["AWS::CDK::Metadata"]);
58259
58354
  /**
@@ -58426,6 +58521,16 @@ const COMPOSITE_ID_SPLITTERS = {
58426
58521
  propertiesOverlay: { ApiId: apiId }
58427
58522
  };
58428
58523
  },
58524
+ "AWS::S3Tables::Namespace": (physicalId) => {
58525
+ const parts = physicalId.split("|");
58526
+ if (parts.length !== 2) throw new Error(`expected 2 parts ('<tableBucketARN>|<namespaceName>'), got ${parts.length}: '${physicalId}'`);
58527
+ const [tableBucketARN, namespace] = parts;
58528
+ if (!tableBucketARN || !namespace) throw new Error(`empty part in '<tableBucketARN>|<namespaceName>': '${physicalId}'`);
58529
+ return { resourceIdentifier: {
58530
+ TableBucketARN: tableBucketARN,
58531
+ Namespace: namespace
58532
+ } };
58533
+ },
58429
58534
  "AWS::Lambda::Permission": (physicalId, properties) => {
58430
58535
  const functionName = readStringProperty(properties, "FunctionName", "AWS::Lambda::Permission");
58431
58536
  return {
@@ -58511,6 +58616,144 @@ function readStringProperty(properties, key, resourceType) {
58511
58616
  return v;
58512
58617
  }
58513
58618
  /**
58619
+ * Types whose cdkd physicalId is a COMPOSITE (`|`-joined) string while
58620
+ * CloudFormation's `primaryIdentifier` is a SINGLE field — so the physicalId is
58621
+ * NOT the identifier value, and it is not a SPLIT of the composite either
58622
+ * (issue [#1659](https://github.com/go-to-k/cdkd/issues/1659)).
58623
+ *
58624
+ * ## Why this table exists next to `COMPOSITE_ID_SPLITTERS`
58625
+ *
58626
+ * `resolveResourceIdentifier` used to conflate two independent questions: the
58627
+ * ARITY of the CFn identifier, and the FORMAT of cdkd's physical id. The
58628
+ * single-field branch assumed "the physicalId IS the identifier value", which
58629
+ * holds for every type whose id is a scalar and silently produces a wrong value
58630
+ * for a composite-id type whose CFn identifier happens to be single-field —
58631
+ * `{ TableARN: 'arn:aws:s3tables:...|analytics|events' }` for an
58632
+ * `AWS::S3Tables::Table`. `COMPOSITE_ID_SPLITTERS` cannot fix that: it is only
58633
+ * consulted on the multi-field branch, so registering a type there would add an
58634
+ * entry no code path reaches.
58635
+ *
58636
+ * This table is consulted BEFORE the arity branch, so the two concerns are
58637
+ * separated: a registered type never takes the "physicalId is the identifier"
58638
+ * assumption regardless of what the schema's field count says.
58639
+ *
58640
+ * ## Why a RESOLVE and not a split
58641
+ *
58642
+ * The correct identifier for each member is a different value entirely, not a
58643
+ * segment of the composite — an ARN AWS minted, or an `sgr-` rule id. So each
58644
+ * entry reads the value cdkd recorded in state (`attributes`), and refuses when
58645
+ * it is absent rather than guessing.
58646
+ *
58647
+ * ## Live measurement (us-east-1, 2026-08-13)
58648
+ *
58649
+ * Every field below is BOTH the type's single-field `primaryIdentifier` AND
58650
+ * `readOnlyProperties`, which is why each resolution returns an EMPTY
58651
+ * `propertiesOverlay`: writing a read-only property into the synth template's
58652
+ * `Properties` block is rejected by CFn at changeset-create.
58653
+ *
58654
+ * | Type | `primaryIdentifier` | recorded as |
58655
+ * |---|---|---|
58656
+ * | `AWS::AppSync::DataSource` | `DataSourceArn` | `attributes.DataSourceArn` |
58657
+ * | `AWS::AppSync::Resolver` | `ResolverArn` | `attributes.ResolverArn` |
58658
+ * | `AWS::S3Tables::Table` | `TableARN` | `attributes.TableARN` |
58659
+ * | `AWS::EC2::SecurityGroupIngress` | `Id` | NOTHING (issue #1761) |
58660
+ *
58661
+ * All four declare a `read` handler, so CFn genuinely does accept them for
58662
+ * IMPORT — unlike `AWS::Glue::Table`, the type issue #1659's title names, which
58663
+ * CFn rejects outright (see {@link typeIsImportUnsupported}).
58664
+ */
58665
+ const COMPOSITE_PHYSICAL_ID_IDENTIFIERS = {
58666
+ "AWS::AppSync::DataSource": recordedArnIdentifier({
58667
+ resourceType: "AWS::AppSync::DataSource",
58668
+ field: "DataSourceArn",
58669
+ physicalIdShape: "<apiId>|<name>",
58670
+ arnServiceSegment: "appsync"
58671
+ }),
58672
+ "AWS::AppSync::Resolver": recordedArnIdentifier({
58673
+ resourceType: "AWS::AppSync::Resolver",
58674
+ field: "ResolverArn",
58675
+ physicalIdShape: "<apiId>|<typeName>|<fieldName>",
58676
+ arnServiceSegment: "appsync"
58677
+ }),
58678
+ "AWS::S3Tables::Table": recordedArnIdentifier({
58679
+ resourceType: "AWS::S3Tables::Table",
58680
+ field: "TableARN",
58681
+ physicalIdShape: "<tableBucketARN>|<namespace>|<name>",
58682
+ arnServiceSegment: "s3tables"
58683
+ }),
58684
+ "AWS::EC2::SecurityGroupIngress": {
58685
+ field: "Id",
58686
+ resolve: ({ logicalId }) => {
58687
+ throw new Error(`cdkd's physical id for AWS::EC2::SecurityGroupIngress is the composite '<groupId>|<ipProtocol>|<fromPort>|<toPort>', but CloudFormation IMPORT identifies the rule by the 'sgr-...' security-group rule id AWS assigns, which cdkd does not record in state (tracked in https://github.com/go-to-k/cdkd/issues/1761). Remove '${logicalId}' from the stack before exporting (the rule stays in AWS and can be re-declared in CloudFormation afterwards), or destroy it first and let CloudFormation create it fresh.`);
58688
+ }
58689
+ }
58690
+ };
58691
+ /**
58692
+ * Build the {@link CompositePhysicalIdIdentifier} for a type whose CFn
58693
+ * identifier is an ARN cdkd records in `attributes` under the SAME name as the
58694
+ * `primaryIdentifier` field.
58695
+ *
58696
+ * Two accepted sources, mirroring the splitter family's "both shapes exist in
58697
+ * state in the wild" contract:
58698
+ *
58699
+ * 1. the recorded attribute, which is what a fresh deploy and a cdkd `import`
58700
+ * both write; and
58701
+ * 2. a physicalId that is ALREADY the bare ARN. Two live producers:
58702
+ * `S3TablesProvider.importTable` records it for a Cloud-Control-routed
58703
+ * table (CC's identifier for the type IS the ARN), and an AppSync child
58704
+ * adopted through `cdkd import --migrate-from-cloudformation` records
58705
+ * CloudFormation's `PhysicalResourceId` verbatim, which is likewise the
58706
+ * ARN.
58707
+ *
58708
+ * **Both sources are validated by the SAME predicate.** An earlier cut checked
58709
+ * the ARN's shape only on source 2 — the rare one — while source 1, which
58710
+ * fires on essentially every export, accepted any non-blank string verbatim.
58711
+ * That is the wrong way round: a hand-edited state row, a row written by an
58712
+ * older binary that recorded the composite under the attribute name, or an ARN
58713
+ * for a different service sitting under `attributes.<field>` would each have
58714
+ * been shipped as the IMPORT identifier and silently adopted the WRONG
58715
+ * resource, which is precisely what this table exists to prevent.
58716
+ *
58717
+ * Anything failing both sources is refused. That covers the degraded record
58718
+ * `docs/state-management.md` describes — written by a cdkd older than the fix
58719
+ * that started recording the ARN, or by an import that could not reach STS —
58720
+ * and the remedy is the one that doc already gives.
58721
+ */
58722
+ function recordedArnIdentifier(options) {
58723
+ const { resourceType, field, physicalIdShape, arnServiceSegment } = options;
58724
+ /**
58725
+ * Is this value usable AS the type's CFn identifier?
58726
+ *
58727
+ * - `arn:` prefix and the type's own SERVICE segment: an ARN naming another
58728
+ * service is a different value entirely. Partition-agnostic by
58729
+ * construction, so `arn:aws-cn:s3tables:...` matches.
58730
+ * - no `|`: cdkd's composite for `AWS::S3Tables::Table` is built FROM the
58731
+ * bucket ARN, so it satisfies both checks above — the separator test is
58732
+ * what distinguishes the composite from the identifier, on BOTH sources.
58733
+ */
58734
+ const isUsableIdentifier = (value) => {
58735
+ const trimmed = value.trim();
58736
+ return trimmed.startsWith("arn:") && trimmed.includes(`:${arnServiceSegment}:`) && !trimmed.includes("|");
58737
+ };
58738
+ return {
58739
+ field,
58740
+ resolve: ({ logicalId, physicalId, attributes }) => {
58741
+ const recorded = attributes[field];
58742
+ if (typeof recorded === "string" && isUsableIdentifier(recorded)) return recorded.trim();
58743
+ if (isUsableIdentifier(physicalId)) return physicalId.trim();
58744
+ const recordedNote = typeof recorded === "string" && recorded.trim() ? `attributes.${field} is recorded as '${recorded.trim()}', which is not a ${arnServiceSegment} ARN` : `attributes.${field} is missing or empty`;
58745
+ throw new Error(`cdkd's physical id for ${resourceType} is the composite '${physicalIdShape}', but CloudFormation IMPORT identifies the resource by its ${field}, which cdkd state does not record usably for '${logicalId}' (${recordedNote}). Re-deploy the stack once so cdkd records ${field}, then re-run cdkd export.`);
58746
+ }
58747
+ };
58748
+ }
58749
+ /**
58750
+ * Every type registered in {@link COMPOSITE_PHYSICAL_ID_IDENTIFIERS}. Exported
58751
+ * so a test can assert the table's SIZE, not just membership — a new entry
58752
+ * needs a measurement row in the table's doc comment, and a size-blind test
58753
+ * would let one land silently.
58754
+ */
58755
+ const COMPOSITE_PHYSICAL_ID_IDENTIFIER_TYPES = Object.keys(COMPOSITE_PHYSICAL_ID_IDENTIFIERS);
58756
+ /**
58514
58757
  * Resource types AWS does NOT support in IMPORT changesets but DOES
58515
58758
  * support normal CREATE for. cdkd handles these via a pre-delete + phase-2
58516
58759
  * CREATE dance (see {@link RecreateBeforePhase2Entry}). When the user
@@ -58664,17 +58907,20 @@ async function exportCommand(stackArg, options) {
58664
58907
  }
58665
58908
  }
58666
58909
  }
58910
+ const { phase1Imports, phase2Creates, recreateBeforePhase2, nestedStackRows, blocked } = await buildImportPlan(state, template, awsClients.cloudFormation, resolvedStackName, {
58911
+ recreateImportUnsupported: options.recreateImportUnsupported,
58912
+ skipImportSupportPreflight: options.skipImportSupportPreflight
58913
+ });
58914
+ if (blocked.length > 0) {
58915
+ logger.error("The following resources block migration:");
58916
+ for (const b of blocked) logger.error(` - ${b.logicalId} (${b.resourceType}): ${b.reason}`);
58917
+ throw new Error(`${blocked.length} resource(s) block migration. Either destroy them first (cdkd destroy / cdkd state destroy cherry-picked), or remove them from the CDK app and re-synthesize.`);
58918
+ }
58667
58919
  const owner = `${process.env["USER"] || "unknown"}@${process.env["HOSTNAME"] || "host"}:${process.pid}`;
58668
58920
  if (!options.dryRun) {
58669
58921
  if (!await lockManager.acquireLock(resolvedStackName, targetRegion, owner, "export")) throw new Error(`Could not acquire lock for stack '${resolvedStackName}' (${targetRegion}) — another cdkd process holds it. Wait for it to finish, or run 'cdkd force-unlock ${resolvedStackName}' if you are certain no other process is active.`);
58670
58922
  }
58671
58923
  try {
58672
- const { phase1Imports, phase2Creates, recreateBeforePhase2, nestedStackRows, blocked } = await buildImportPlan(state, template, awsClients.cloudFormation, resolvedStackName, { recreateImportUnsupported: options.recreateImportUnsupported });
58673
- if (blocked.length > 0) {
58674
- logger.error("The following resources block migration:");
58675
- for (const b of blocked) logger.error(` - ${b.logicalId} (${b.resourceType}): ${b.reason}`);
58676
- throw new Error(`${blocked.length} resource(s) block migration. Either destroy them first (cdkd destroy / cdkd state destroy cherry-picked), or remove them from the CDK app and re-synthesize.`);
58677
- }
58678
58924
  if (nestedStackRows.length > 0) {
58679
58925
  const nestedStackTree = await buildCdkdStateStackTree(resolvedStackName, targetRegion, stateBackend, state);
58680
58926
  reportDriftBaselineGaps(state, logger);
@@ -58717,7 +58963,8 @@ async function exportCommand(stackArg, options) {
58717
58963
  dryRun: options.dryRun,
58718
58964
  yes: options.yes,
58719
58965
  includeNonImportable: options.includeNonImportable,
58720
- recreateImportUnsupported: options.recreateImportUnsupported
58966
+ recreateImportUnsupported: options.recreateImportUnsupported,
58967
+ skipImportSupportPreflight: options.skipImportSupportPreflight
58721
58968
  }
58722
58969
  })).outcome === "success") printNextSteps({
58723
58970
  cfnStackName: options.cfnStackName ?? cdkd2cfnStackName(resolvedStackName),
@@ -59256,7 +59503,16 @@ async function buildImportPlan(state, template, cfnClient, parentStackName, opti
59256
59503
  }
59257
59504
  let resolved;
59258
59505
  try {
59259
- resolved = await resolveResourceIdentifier(resourceType, stateEntry.physicalId, stateEntry.properties ?? {}, cfnClient, identifierCache);
59506
+ const schemaInfo = await cachedTypeSchemaInfo(resourceType, cfnClient, identifierCache);
59507
+ if (!options.skipImportSupportPreflight && typeIsImportUnsupported(schemaInfo)) {
59508
+ blocked.push({
59509
+ logicalId,
59510
+ resourceType,
59511
+ reason: `AWS CloudFormation does not support ${resourceType} in IMPORT changesets: its CloudFormation registry schema declares no 'read' handler (and reports the type as NON_PROVISIONABLE), and IMPORT needs that handler to look the resource up by identifier. CreateChangeSet would reject the whole export with "ResourceTypes [${resourceType}] are not supported for Import". Remove the resource from the stack before exporting (it stays in AWS and can be re-declared in CloudFormation afterwards), or destroy it first and let CloudFormation create it fresh. If AWS has since added IMPORT support for this type, please open a cdkd issue.`
59512
+ });
59513
+ continue;
59514
+ }
59515
+ resolved = resolveResourceIdentifier(resourceType, logicalId, stateEntry.physicalId, stateEntry.properties ?? {}, stateEntry.attributes ?? {}, schemaInfo);
59260
59516
  } catch (err) {
59261
59517
  blocked.push({
59262
59518
  logicalId,
@@ -59286,6 +59542,11 @@ async function buildImportPlan(state, template, cfnClient, parentStackName, opti
59286
59542
  * `ResourcesToImport[].ResourceIdentifier` for the given resource type
59287
59543
  * and cdkd state's physical ID.
59288
59544
  *
59545
+ * Types whose cdkd physicalId is COMPOSITE while the CFn identifier is a
59546
+ * single field (`COMPOSITE_PHYSICAL_ID_IDENTIFIERS`): resolved from cdkd
59547
+ * state's recorded attributes, BEFORE the field-count branch below — the
59548
+ * physicalId is neither the identifier nor a source to split (issue #1659).
59549
+ *
59289
59550
  * Single-key types: the map has a single entry keyed by the schema's
59290
59551
  * primaryIdentifier field name (e.g. `{ BucketName: 'my-bucket' }`).
59291
59552
  *
@@ -59295,16 +59556,27 @@ async function buildImportPlan(state, template, cfnClient, parentStackName, opti
59295
59556
  * into one entry per field. Composite types without a registered
59296
59557
  * splitter surface a clear error pointing at where to add one.
59297
59558
  *
59298
- * Prefers `DescribeType` (the authoritative AWS-internal registry) and
59299
- * falls back to a hardcoded single-key table when DescribeType fails
59300
- * (insufficient permissions, throttling, obscure type without a registry
59301
- * entry).
59302
- */
59303
- async function resolveResourceIdentifier(resourceType, physicalId, properties, cfnClient, cache) {
59304
- let entry = cache.get(resourceType);
59305
- if (entry === void 0) {
59306
- entry = await fetchPrimaryIdentifier(resourceType, cfnClient);
59307
- cache.set(resourceType, entry);
59559
+ * `entry` is the caller's already-resolved registry-schema info — see
59560
+ * {@link cachedTypeSchemaInfo}, which prefers `DescribeType` (the authoritative
59561
+ * AWS-internal registry) and falls back to a hardcoded single-key table when
59562
+ * DescribeType fails (insufficient permissions, throttling, obscure type
59563
+ * without a registry entry). Taking it as a parameter rather than fetching is
59564
+ * what keeps `buildImportPlan` to ONE DescribeType per type across both the
59565
+ * IMPORT pre-flight and this resolution.
59566
+ */
59567
+ function resolveResourceIdentifier(resourceType, logicalId, physicalId, properties, attributes, entry) {
59568
+ const compositeIdentifier = COMPOSITE_PHYSICAL_ID_IDENTIFIERS[resourceType];
59569
+ if (compositeIdentifier) {
59570
+ if (entry.fields.length !== 1 || entry.fields[0] !== compositeIdentifier.field) throw new Error(`${resourceType} is registered in COMPOSITE_PHYSICAL_ID_IDENTIFIERS as identified by '${compositeIdentifier.field}', but CloudFormation now reports primaryIdentifier [${entry.fields.join(", ")}]. The registry schema changed under cdkd — update the entry in src/cli/commands/export.ts (or move the type to COMPOSITE_ID_SPLITTERS if its identifier is now composite too)`);
59571
+ const value = compositeIdentifier.resolve({
59572
+ logicalId,
59573
+ physicalId,
59574
+ attributes
59575
+ });
59576
+ return {
59577
+ resourceIdentifier: { [compositeIdentifier.field]: value },
59578
+ propertiesOverlay: {}
59579
+ };
59308
59580
  }
59309
59581
  if (entry.fields.length === 1) return { resourceIdentifier: { [entry.fields[0]]: physicalId } };
59310
59582
  const splitter = COMPOSITE_ID_SPLITTERS[resourceType];
@@ -59319,6 +59591,53 @@ async function resolveResourceIdentifier(resourceType, physicalId, properties, c
59319
59591
  return result;
59320
59592
  }
59321
59593
  /**
59594
+ * Cache-consulting wrapper around {@link fetchPrimaryIdentifier}. Shared by
59595
+ * `resolveResourceIdentifier` and `buildImportPlan`'s pre-flight so one
59596
+ * DescribeType per type answers both questions.
59597
+ */
59598
+ async function cachedTypeSchemaInfo(resourceType, cfnClient, cache) {
59599
+ const cached = cache.get(resourceType);
59600
+ if (cached !== void 0) return cached;
59601
+ const entry = await fetchPrimaryIdentifier(resourceType, cfnClient);
59602
+ cache.set(resourceType, entry);
59603
+ return entry;
59604
+ }
59605
+ /**
59606
+ * True when the registry schema says CloudFormation will refuse the type in an
59607
+ * IMPORT changeset (`ValidationError: ResourceTypes [<T>] are not supported
59608
+ * for Import`) — IMPORT looks the resource up by identifier through the type's
59609
+ * `read` handler, so a type without one cannot be adopted.
59610
+ *
59611
+ * Live-measured (us-east-1, 2026-08-13) against `CreateChangeSet
59612
+ * --change-set-type IMPORT`: `AWS::Glue::Table` / `AWS::Route53::RecordSet` /
59613
+ * `AWS::AppSync::ApiKey` / `AWS::EC2::NetworkAclEntry` /
59614
+ * `AWS::Route53::RecordSetGroup` / `AWS::SQS::QueuePolicy` /
59615
+ * `AWS::SNS::TopicPolicy` all lack a `read` handler, are all
59616
+ * `NON_PROVISIONABLE`, and were all rejected; `AWS::S3::Bucket`
59617
+ * (`FULLY_MUTABLE`, read handler present) passed the type check. Both shapes
59618
+ * of "no read handler" occur and both count: NO `handlers` block at all (the
59619
+ * legacy types) and a `handlers` block whose keys are only create / update /
59620
+ * delete (`AWS::EC2::NetworkAclEntry`).
59621
+ *
59622
+ * **Why the verdict needs TWO agreeing fields.** Reading "the `handlers` key is
59623
+ * missing" as a refusal on its own makes any partial or unusual response block
59624
+ * an export that works today — the one regression this pre-flight could cause,
59625
+ * and strictly worse than the status quo of letting AWS answer. So the refusal
59626
+ * also requires `ProvisioningType: NON_PROVISIONABLE`; a response satisfying
59627
+ * only one of the two resolves to `unknown` and is left to CreateChangeSet.
59628
+ *
59629
+ * The two were perfectly CORRELATED across the 21 types sampled on 2026-08-13
59630
+ * (every no-read-handler type was NON_PROVISIONABLE and vice versa), so on
59631
+ * today's registry the conjunction's real work is requiring a COMPLETE
59632
+ * DescribeType response before refusing. The `read` handler stays the primary
59633
+ * signal because it is the mechanistic reason CFn accepts a type for IMPORT —
59634
+ * refusing on `ProvisioningType` alone would reject a NON_PROVISIONABLE type
59635
+ * that does declare one, should AWS ever publish that combination.
59636
+ */
59637
+ function typeIsImportUnsupported(entry) {
59638
+ return entry.importSupport === "unsupported";
59639
+ }
59640
+ /**
59322
59641
  * Fetch the primary identifier field names for a resource type, with a
59323
59642
  * hardcoded single-key fallback when DescribeType is unavailable.
59324
59643
  */
@@ -59326,15 +59645,26 @@ async function fetchPrimaryIdentifier(resourceType, cfnClient) {
59326
59645
  try {
59327
59646
  const resp = await describeTypeWithThrottleRetry(resourceType, cfnClient);
59328
59647
  if (resp.Schema) {
59329
- const primary = JSON.parse(resp.Schema).primaryIdentifier;
59330
- if (Array.isArray(primary) && primary.length > 0 && primary.every((p) => typeof p === "string")) return { fields: primary.map((p) => p.replace(/^\/properties\//, "")) };
59648
+ const parsed = JSON.parse(resp.Schema);
59649
+ const primary = parsed.primaryIdentifier;
59650
+ if (Array.isArray(primary) && primary.length > 0 && primary.every((p) => typeof p === "string")) {
59651
+ const fields = primary.map((p) => p.replace(/^\/properties\//, ""));
59652
+ const handlers = parsed.handlers;
59653
+ return {
59654
+ fields,
59655
+ importSupport: typeof handlers === "object" && handlers !== null && Object.prototype.hasOwnProperty.call(handlers, "read") ? "importable" : resp.ProvisioningType === "NON_PROVISIONABLE" ? "unsupported" : "unknown"
59656
+ };
59657
+ }
59331
59658
  }
59332
59659
  } catch (err) {
59333
59660
  const msg = err instanceof Error ? err.message : String(err);
59334
59661
  getLogger().debug(`DescribeType failed for ${resourceType}: ${msg} — using fallback`);
59335
59662
  }
59336
59663
  const fallback = PRIMARY_IDENTIFIER_FALLBACK[resourceType];
59337
- if (fallback) return { fields: [fallback] };
59664
+ if (fallback) return {
59665
+ fields: [fallback],
59666
+ importSupport: "unknown"
59667
+ };
59338
59668
  throw new Error(`primary identifier unknown (DescribeType returned no usable schema and no fallback is registered). Add ${resourceType} to PRIMARY_IDENTIFIER_FALLBACK in export.ts, or open an issue.`);
59339
59669
  }
59340
59670
  /**
@@ -60260,7 +60590,10 @@ async function runPerStackImportLoop(args) {
60260
60590
  const perStackPlans = [];
60261
60591
  for (const node of leafFirst) {
60262
60592
  const meta = nodesByCdkdName.get(node.stackName);
60263
- const plan = await buildImportPlan(meta.state, meta.template, deps.cfnClient, meta.cdkdStackName, { recreateImportUnsupported: options.recreateImportUnsupported });
60593
+ const plan = await buildImportPlan(meta.state, meta.template, deps.cfnClient, meta.cdkdStackName, {
60594
+ recreateImportUnsupported: options.recreateImportUnsupported,
60595
+ skipImportSupportPreflight: options.skipImportSupportPreflight === true
60596
+ });
60264
60597
  if (plan.blocked.length > 0) {
60265
60598
  const lines = plan.blocked.map((b) => ` - ${b.logicalId} (${b.resourceType}): ${b.reason}`);
60266
60599
  throw new Error(`Stack '${meta.cdkdStackName}' has ${plan.blocked.length} resource(s) that block migration:\n${lines.join("\n")}`);
@@ -60676,7 +61009,7 @@ async function confirmPrompt$3(prompt) {
60676
61009
  }
60677
61010
  }
60678
61011
  function createExportCommand() {
60679
- const cmd = new Command("export").description("Hand a cdkd-managed stack over to CloudFormation via CFn IMPORT (changeset). AWS resources are unchanged; cdkd state for the stack is deleted on success. Mirror of `cdkd import` (AWS → cdkd) in the reverse direction (cdkd → CFn). Accepts JSON and YAML templates (YAML via a CFn-aware codec that preserves !Ref / !GetAtt / !Sub shorthand). Aborts if any resource is not CFn-importable.").argument("[stack]", "Stack name to export (auto-detected for single-stack apps)").option("--cfn-stack-name <name>", "Name of the destination CloudFormation stack for the root stack. Defaults to the cdkd stack name (or the cdkd2cfnStackName mapping when the name contains '~'). For per-nested-child overrides, use --cfn-child-stack-name.").option("--cfn-child-stack-name <pair...>", "Per-nested-child CFn stack-name override. Repeatable. Format: '<cdkdName>=<cfnName>' (e.g. --cfn-child-stack-name 'MyApp~Database=my-app-db'). The cdkd stack name for a nested child is '<parent>~<childLogicalId>' (v6 state-key form). Without an override the CFn name is derived by replacing '~' with '-'. Only consulted when the exported stack tree contains nested children; ignored for flat stacks.").option("--template <path>", "Path to a pre-rendered CloudFormation template (JSON or YAML — format auto-detected). Skips synth.").option("--stack-region <region>", "Region of the cdkd state record to operate on. Required when the same stack name has state in multiple regions.").option("--dry-run", "Print the import plan without creating a changeset.", false).option("--accept-transient-context", "Allow CLI -c key=value overrides at export time even though they are not persisted to cdk.json / cdk.context.json (default: refuse). When set, the user is responsible for passing the same -c flags to every future cdk deploy.", false).option("--include-non-importable", "Run a 2-phase migration when the stack contains non-importable resources (Custom::*). Phase 1 imports the importable resources; phase 2 CFn-CREATEs the non-importable ones, which re-invokes each Custom Resource's onCreate handler. Make sure onCreate is idempotent before enabling.", false).option("--parameter <key=value...>", "CFn template Parameter override, repeatable. Required when the synthesized template has Parameters without Default values; otherwise overrides the template's default value. Format: --parameter Key=Value.").option("--strict-cross-stack", "Refuse to export when sibling cdkd stacks in the same CDK app reference the exporting stack via Fn::GetStackOutput. Without the flag, cdkd warns and proceeds — the consumers keep resolving via the CloudFormation fallback (weak reference) unless they deploy with --no-cfn-fallback.", false).option("--no-recreate-import-unsupported", "Block instead of auto-handling resource types AWS does NOT support in IMPORT changesets (currently only AWS::ApiGatewayV2::Stage, emitted by CDK HttpApi). Default behavior: cdkd skips these from phase 1, deletes the AWS-side resource between phases, and lets CFn re-CREATE in phase 2 (brief unavailability window). With this flag, the export aborts with a clear error instead.").action(withErrorHandling(exportCommand));
61012
+ const cmd = new Command("export").description("Hand a cdkd-managed stack over to CloudFormation via CFn IMPORT (changeset). AWS resources are unchanged; cdkd state for the stack is deleted on success. Mirror of `cdkd import` (AWS → cdkd) in the reverse direction (cdkd → CFn). Accepts JSON and YAML templates (YAML via a CFn-aware codec that preserves !Ref / !GetAtt / !Sub shorthand). Aborts if any resource is not CFn-importable.").argument("[stack]", "Stack name to export (auto-detected for single-stack apps)").option("--cfn-stack-name <name>", "Name of the destination CloudFormation stack for the root stack. Defaults to the cdkd stack name (or the cdkd2cfnStackName mapping when the name contains '~'). For per-nested-child overrides, use --cfn-child-stack-name.").option("--cfn-child-stack-name <pair...>", "Per-nested-child CFn stack-name override. Repeatable. Format: '<cdkdName>=<cfnName>' (e.g. --cfn-child-stack-name 'MyApp~Database=my-app-db'). The cdkd stack name for a nested child is '<parent>~<childLogicalId>' (v6 state-key form). Without an override the CFn name is derived by replacing '~' with '-'. Only consulted when the exported stack tree contains nested children; ignored for flat stacks.").option("--template <path>", "Path to a pre-rendered CloudFormation template (JSON or YAML — format auto-detected). Skips synth.").option("--stack-region <region>", "Region of the cdkd state record to operate on. Required when the same stack name has state in multiple regions.").option("--dry-run", "Print the import plan without creating a changeset.", false).option("--accept-transient-context", "Allow CLI -c key=value overrides at export time even though they are not persisted to cdk.json / cdk.context.json (default: refuse). When set, the user is responsible for passing the same -c flags to every future cdk deploy.", false).option("--include-non-importable", "Run a 2-phase migration when the stack contains non-importable resources (Custom::*). Phase 1 imports the importable resources; phase 2 CFn-CREATEs the non-importable ones, which re-invokes each Custom Resource's onCreate handler. Make sure onCreate is idempotent before enabling.", false).option("--parameter <key=value...>", "CFn template Parameter override, repeatable. Required when the synthesized template has Parameters without Default values; otherwise overrides the template's default value. Format: --parameter Key=Value.").option("--strict-cross-stack", "Refuse to export when sibling cdkd stacks in the same CDK app reference the exporting stack via Fn::GetStackOutput. Without the flag, cdkd warns and proceeds — the consumers keep resolving via the CloudFormation fallback (weak reference) unless they deploy with --no-cfn-fallback.", false).option("--skip-import-support-preflight", "Skip the pre-flight that refuses resource types whose CloudFormation registry schema says CFn cannot IMPORT them (no read handler AND NON_PROVISIONABLE), and let CreateChangeSet answer instead. The pre-flight is a registry HEURISTIC, not AWS's supported-for-import list, so this is the escape hatch for a type AWS has since made importable. Expect the changeset to be rejected if the heuristic was right.", false).option("--no-recreate-import-unsupported", "Block instead of auto-handling resource types AWS does NOT support in IMPORT changesets (currently only AWS::ApiGatewayV2::Stage, emitted by CDK HttpApi). Default behavior: cdkd skips these from phase 1, deletes the AWS-side resource between phases, and lets CFn re-CREATE in phase 2 (brief unavailability window). With this flag, the export aborts with a clear error instead.").action(withErrorHandling(exportCommand));
60680
61013
  [
60681
61014
  ...commonOptions,
60682
61015
  ...appOptions,
@@ -61356,6 +61689,7 @@ async function stateDestroyCommand(stackArgs, options) {
61356
61689
  }
61357
61690
  logger.info(`Found ${stackNames.length} stack(s) to destroy: ${stackNames.join(", ")}`);
61358
61691
  let totalErrors = 0;
61692
+ let totalSkipped = 0;
61359
61693
  let interrupted = false;
61360
61694
  for (const stackName of stackNames) {
61361
61695
  const refs = stateRefs.filter((r) => r.stackName === stackName);
@@ -61416,6 +61750,7 @@ async function stateDestroyCommand(stackArgs, options) {
61416
61750
  ...options.resourceTimeout?.perTypeMs && { resourceTimeoutByType: options.resourceTimeout.perTypeMs }
61417
61751
  }));
61418
61752
  totalErrors += result.errorCount;
61753
+ totalSkipped += result.skippedCount;
61419
61754
  if (result.interrupted) interrupted = true;
61420
61755
  if (interrupted) break;
61421
61756
  }
@@ -61423,6 +61758,7 @@ async function stateDestroyCommand(stackArgs, options) {
61423
61758
  }
61424
61759
  if (totalErrors > 0) throw new PartialFailureError(`Destroy completed with ${totalErrors} resource error(s). State preserved — inspect 'cdkd state show <stack>' and re-run 'cdkd state destroy' to retry. If the same resource keeps failing, 'cdkd state orphan <stack>' removes the state record without deleting AWS resources.`);
61425
61760
  if (interrupted) throw new PartialFailureError(`Destroy interrupted by Ctrl-C. State preserved — re-run 'cdkd state destroy' to finish.`);
61761
+ if (totalSkipped > 0) throw new PartialFailureError(`Destroy skipped ${totalSkipped} entr${totalSkipped === 1 ? "y" : "ies"} cdkd could not address, so the underlying resources may still exist in AWS. A skipped nested stack counts as ONE entry and may cover several of its own resources — the per-stack summaries above give the exact breakdown. State preserved (the records are kept). Repair the physicalId in state.json and re-run 'cdkd state destroy', or delete the resources by hand and drop the records with 'cdkd state orphan <stack>'.`);
61426
61762
  } finally {
61427
61763
  unforwardSigterm();
61428
61764
  setup.dispose();
@@ -62492,6 +62828,11 @@ function printRunList(stackName, region, runs) {
62492
62828
  logger.info(gray(`\nUse 'cdkd events ${stackName} --run <runId>' to read one run's events.`));
62493
62829
  }
62494
62830
  /** Human-readable single-run event stream (in recorded order). */
62831
+ /**
62832
+ * Render one run's event stream. Exported for unit-test coverage — the
62833
+ * `counts.skipped` / `reason` rendering is the whole value of those fields
62834
+ * (issue #1752), so it needs a direct fence rather than only `--json` coverage.
62835
+ */
62495
62836
  function printRunEvents(stackName, region, runId, events) {
62496
62837
  const logger = getLogger();
62497
62838
  logger.info(`${bold("Events for run")} ${cyan(runId)} ${gray(`(${stackName}, ${region})`)}`);
@@ -62509,8 +62850,9 @@ function printRunEvents(stackName, region, runId, events) {
62509
62850
  if (e.cdkdVersion) parts.push(gray(`cdkd ${e.cdkdVersion}`));
62510
62851
  if (e.result) parts.push(e.result === "SUCCEEDED" ? green(e.result) : red(e.result));
62511
62852
  if (typeof e.durationMs === "number") parts.push(gray(`${e.durationMs}ms`));
62512
- if (e.counts) parts.push(gray(`+${e.counts.created}/~${e.counts.updated}/-${e.counts.deleted}` + (e.counts.failed ? ` !${e.counts.failed}` : "")));
62853
+ if (e.counts) parts.push(gray(`+${e.counts.created}/~${e.counts.updated}/-${e.counts.deleted}` + (e.counts.failed ? ` !${e.counts.failed}` : "") + (e.counts.skipped ? ` ⚠${e.counts.skipped}` : "")));
62513
62854
  logger.info(` ${parts.join(" ")}`);
62855
+ if (e.reason) logger.info(` ${yellow(e.reason)}`);
62514
62856
  if (e.error) {
62515
62857
  const code = e.error.awsErrorCode ? ` (${e.error.awsErrorCode})` : "";
62516
62858
  const reqId = e.error.requestId ? gray(` requestId=${e.error.requestId}`) : "";
@@ -62518,10 +62860,17 @@ function printRunEvents(stackName, region, runId, events) {
62518
62860
  }
62519
62861
  }
62520
62862
  }
62521
- /** Color the event-type token by its lifecycle phase. */
62863
+ /**
62864
+ * Color the event-type token by its lifecycle phase.
62865
+ *
62866
+ * Exported for unit-test coverage (matching `destroy-runner.ts`'s
62867
+ * `PROTECTION_PROPERTY_BY_TYPE` convention) — it is pure, so testing it
62868
+ * directly beats driving it through the renderer's log output.
62869
+ */
62522
62870
  function colorizeEventType(eventType) {
62523
62871
  if (eventType.endsWith("FAILED")) return red(eventType);
62524
62872
  if (eventType.endsWith("SUCCEEDED") || eventType === "RUN_FINISHED") return green(eventType);
62873
+ if (eventType === "RESOURCE_SKIPPED") return yellow(eventType);
62525
62874
  if (eventType.startsWith("ROLLBACK")) return yellow(eventType);
62526
62875
  return cyan(eventType);
62527
62876
  }
@@ -74505,7 +74854,7 @@ function createMigrateCommand() {
74505
74854
  */
74506
74855
  function buildProgram() {
74507
74856
  const program = new Command();
74508
- program.name("cdkd").description("CDK Direct - Deploy AWS CDK apps directly via SDK/Cloud Control API").version("0.281.22");
74857
+ program.name("cdkd").description("CDK Direct - Deploy AWS CDK apps directly via SDK/Cloud Control API").version("0.281.24");
74509
74858
  program.hook("preAction", (_thisCommand, actionCommand) => {
74510
74859
  const { profile } = actionCommand.optsWithGlobals();
74511
74860
  if (profile !== void 0) process.env["AWS_PROFILE"] = profile;