sf-plugin-cms 0.3.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/README.md +334 -28
  2. package/lib/commands/cms/export/workspace.d.ts +6 -1
  3. package/lib/commands/cms/export/workspace.js +80 -3
  4. package/lib/commands/cms/export/workspace.js.map +1 -1
  5. package/lib/commands/cms/import/workspace.d.ts +14 -4
  6. package/lib/commands/cms/import/workspace.js +199 -24
  7. package/lib/commands/cms/import/workspace.js.map +1 -1
  8. package/lib/commands/cms/info.js +21 -2
  9. package/lib/commands/cms/info.js.map +1 -1
  10. package/lib/contracts/info.d.ts +7 -4
  11. package/lib/contracts/info.js +17 -8
  12. package/lib/contracts/info.js.map +1 -1
  13. package/lib/contracts/shared.d.ts +2 -1
  14. package/lib/contracts/shared.js +11 -3
  15. package/lib/contracts/shared.js.map +1 -1
  16. package/lib/contracts/workspace-export.d.ts +23 -3
  17. package/lib/contracts/workspace-export.js +118 -5
  18. package/lib/contracts/workspace-export.js.map +1 -1
  19. package/lib/contracts/workspace-import.d.ts +64 -0
  20. package/lib/contracts/workspace-import.js +119 -0
  21. package/lib/contracts/workspace-import.js.map +1 -1
  22. package/lib/index.d.ts +3 -1
  23. package/lib/index.js +2 -0
  24. package/lib/index.js.map +1 -1
  25. package/lib/services/bulk-export-workspaces.js +10 -4
  26. package/lib/services/bulk-export-workspaces.js.map +1 -1
  27. package/lib/services/editable-raw-html-export.d.ts +26 -0
  28. package/lib/services/editable-raw-html-export.js +100 -0
  29. package/lib/services/editable-raw-html-export.js.map +1 -0
  30. package/lib/services/editable-raw-html-import.d.ts +25 -0
  31. package/lib/services/editable-raw-html-import.js +107 -0
  32. package/lib/services/editable-raw-html-import.js.map +1 -0
  33. package/lib/services/editable-raw-html.d.ts +34 -0
  34. package/lib/services/editable-raw-html.js +83 -0
  35. package/lib/services/editable-raw-html.js.map +1 -0
  36. package/lib/services/email-fragment.d.ts +15 -0
  37. package/lib/services/email-fragment.js +199 -0
  38. package/lib/services/email-fragment.js.map +1 -0
  39. package/lib/services/export-references.js +3 -1
  40. package/lib/services/export-references.js.map +1 -1
  41. package/lib/services/export-workspace.d.ts +16 -1
  42. package/lib/services/export-workspace.js +222 -15
  43. package/lib/services/export-workspace.js.map +1 -1
  44. package/lib/services/image-import.d.ts +91 -0
  45. package/lib/services/image-import.js +579 -0
  46. package/lib/services/image-import.js.map +1 -0
  47. package/lib/services/import-identities.d.ts +33 -0
  48. package/lib/services/import-identities.js +287 -0
  49. package/lib/services/import-identities.js.map +1 -0
  50. package/lib/services/import-workspace.d.ts +50 -1
  51. package/lib/services/import-workspace.js +445 -21
  52. package/lib/services/import-workspace.js.map +1 -1
  53. package/lib/services/landing-page-template.d.ts +52 -0
  54. package/lib/services/landing-page-template.js +421 -0
  55. package/lib/services/landing-page-template.js.map +1 -0
  56. package/lib/services/landing-page.d.ts +43 -0
  57. package/lib/services/landing-page.js +117 -0
  58. package/lib/services/landing-page.js.map +1 -0
  59. package/lib/services/resolve-workspace.d.ts +6 -1
  60. package/lib/services/resolve-workspace.js +51 -4
  61. package/lib/services/resolve-workspace.js.map +1 -1
  62. package/lib/services/variant-identity.d.ts +13 -0
  63. package/lib/services/variant-identity.js +34 -0
  64. package/lib/services/variant-identity.js.map +1 -0
  65. package/lib/services/web-fragment.d.ts +41 -0
  66. package/lib/services/web-fragment.js +245 -0
  67. package/lib/services/web-fragment.js.map +1 -0
  68. package/lib/transport/experimental-media.d.ts +30 -0
  69. package/lib/transport/experimental-media.js +224 -0
  70. package/lib/transport/experimental-media.js.map +1 -0
  71. package/lib/transport/json-request.d.ts +9 -1
  72. package/lib/transport/json-request.js +30 -3
  73. package/lib/transport/json-request.js.map +1 -1
  74. package/lib/transport/multipart-image-create.d.ts +28 -0
  75. package/lib/transport/multipart-image-create.js +189 -0
  76. package/lib/transport/multipart-image-create.js.map +1 -0
  77. package/messages/cms.export.workspace.md +12 -2
  78. package/messages/cms.import.workspace.md +29 -3
  79. package/package.json +1 -1
@@ -3,8 +3,17 @@ import { lstat, mkdir, open, readFile, readdir, realpath } from 'node:fs/promise
3
3
  import path from 'node:path';
4
4
  import { cleanupOwnedPath, replaceFileByAtomicRename, } from './atomic-publish.js';
5
5
  import { CmsRequestError } from '../transport/json-request.js';
6
- import { assertWorkspaceExportManifest, } from '../contracts/workspace-export.js';
6
+ import { assertWorkspaceExportManifest, UnsupportedWorkspacePackageVersionError, } from '../contracts/workspace-export.js';
7
7
  import { getContent } from './read.js';
8
+ import { variantIdentity } from './variant-identity.js';
9
+ import { inventoryExportReferences } from './export-references.js';
10
+ import { planImportIdentities, planNativeCopies, } from './import-identities.js';
11
+ import { planEmailFragmentCopies } from './email-fragment.js';
12
+ import { planWebFragmentCopies, validateDataGraphPrerequisites, } from './web-fragment.js';
13
+ import { planLandingPageTemplateCopies, validateLandingPageTemplatePrerequisites, } from './landing-page-template.js';
14
+ import { planLandingPageCopies, validateLandingPagePrerequisites } from './landing-page.js';
15
+ import { loadEditableRawHtml } from './editable-raw-html-import.js';
16
+ import { isDeepStrictEqual } from 'node:util';
8
17
  import { createContent, createVariant, } from './write.js';
9
18
  export class WorkspaceImportOwnershipUncertainError extends Error {
10
19
  operation;
@@ -93,7 +102,13 @@ async function assertRegularFile(file, label) {
93
102
  throw new TypeError(`${label} must be a regular file and not a symlink or reparse point`);
94
103
  }
95
104
  }
96
- async function readStableRegularFile(file, label) {
105
+ /**
106
+ * Read a regular file while detecting observed replacement or modification.
107
+ * @param {string} file - Absolute file path.
108
+ * @param {string} label - Error context.
109
+ * @returns {Promise<Buffer>} Original file bytes.
110
+ */
111
+ export async function readStableRegularFile(file, label) {
97
112
  await assertRegularFile(file, label);
98
113
  const before = await lstat(file);
99
114
  const bytes = await readFile(file);
@@ -103,7 +118,13 @@ async function readStableRegularFile(file, label) {
103
118
  }
104
119
  return bytes;
105
120
  }
106
- async function enumeratePackageFiles(directory, packageRoot) {
121
+ /**
122
+ * Enumerate regular package files without following symlinks.
123
+ * @param {string} directory - Directory to inspect.
124
+ * @param {string} packageRoot - Root for relative paths.
125
+ * @returns {Promise<string[]>} Sorted portable relative filenames.
126
+ */
127
+ export async function enumeratePackageFiles(directory, packageRoot) {
107
128
  const files = [];
108
129
  for (const entry of await readdir(directory, { withFileTypes: true })) {
109
130
  const absolute = path.join(directory, entry.name);
@@ -123,12 +144,24 @@ async function enumeratePackageFiles(directory, packageRoot) {
123
144
  }
124
145
  return files.toSorted();
125
146
  }
126
- function validateItem(value, variantId, workspaceId) {
127
- if (!isRecord(value))
147
+ function validateItem(raw, variantId, workspaceId) {
148
+ if (!isRecord(raw))
128
149
  throw new TypeError(`Item ${variantId} must contain an object`);
129
- assertIdentifier(value.id, `Item ${variantId}.id`);
130
- if (value.id !== variantId)
131
- throw new TypeError(`Item ${variantId} does not match its manifest variant ID`);
150
+ const identity = variantIdentity(raw, variantId);
151
+ assertIdentifier(identity.variantId, `Item ${variantId}.id`);
152
+ // Normalize only the in-memory import view after raw-byte integrity verification.
153
+ const normalized = { ...raw, id: identity.variantId };
154
+ if (isRecord(normalized.contentType)) {
155
+ if (Object.keys(normalized.contentType).some((key) => key !== 'fullyQualifiedName' && key !== 'name')) {
156
+ throw new TypeError(`Item ${variantId}.contentType contains unsupported summary fields`);
157
+ }
158
+ normalized.contentType = normalized.contentType.fullyQualifiedName;
159
+ }
160
+ if (normalized.externalId === null)
161
+ delete normalized.externalId;
162
+ if (normalized.externalSource === null)
163
+ delete normalized.externalSource;
164
+ const value = normalized;
132
165
  for (const key of ['contentKey', 'language', 'contentType', 'title']) {
133
166
  if (!nonemptyString(value[key]))
134
167
  throw new TypeError(`Item ${variantId}.${key} is required`);
@@ -158,7 +191,20 @@ function validateRelationships(items) {
158
191
  const pairIds = new Map();
159
192
  const keyToApiName = new Map();
160
193
  const apiNameToKey = new Map();
194
+ const contentIdToKey = new Map();
195
+ const keyToContentId = new Map();
161
196
  for (const item of items) {
197
+ const { contentId } = variantIdentity(item, item.id);
198
+ if (contentId !== undefined) {
199
+ const priorKey = contentIdToKey.get(contentId);
200
+ const priorId = keyToContentId.get(item.contentKey);
201
+ if ((priorKey !== undefined && priorKey !== item.contentKey) ||
202
+ (priorId !== undefined && priorId !== contentId)) {
203
+ throw new TypeError('Content keys have conflicting parent content identities');
204
+ }
205
+ contentIdToKey.set(contentId, item.contentKey);
206
+ keyToContentId.set(item.contentKey, contentId);
207
+ }
162
208
  const pair = `${item.contentKey}\u0000${item.language}`;
163
209
  if (pairIds.has(pair)) {
164
210
  throw new TypeError(`Duplicate contentKey/language mapping: ${item.contentKey}/${item.language}`);
@@ -191,6 +237,9 @@ export async function loadWorkspaceExport(sourceDirectory, options = {}) {
191
237
  const manifestValue = parseJson(manifestBytes, 'manifest.json');
192
238
  assertWorkspaceExportManifest(manifestValue);
193
239
  const manifest = manifestValue;
240
+ if (options.profile === 'image' && manifest.schemaVersion !== 2) {
241
+ throw new UnsupportedWorkspacePackageVersionError('Image import profile requires workspace package manifest version 2');
242
+ }
194
243
  const packageFiles = await enumeratePackageFiles(canonicalSource, canonicalSource);
195
244
  const expectedFiles = [
196
245
  'manifest.json',
@@ -200,6 +249,19 @@ export async function loadWorkspaceExport(sourceDirectory, options = {}) {
200
249
  throw new TypeError('Workspace package contains missing, substituted, or unlisted regular files');
201
250
  }
202
251
  const manifestItems = new Map(manifest.items.map((item_) => [item_.path, item_]));
252
+ for (const [index, declared] of manifest.items.entries()) {
253
+ const segments = declared.path.split('/');
254
+ const file = path.join(canonicalSource, ...segments);
255
+ const normalized = path.normalize(file);
256
+ if (!normalized.startsWith(`${canonicalSource}${path.sep}`)) {
257
+ throw new TypeError(`manifest.items[${index}].path escapes the source directory`);
258
+ }
259
+ const bytes = await readStableRegularFile(file, `Manifest item ${declared.path}`);
260
+ const actualSha256 = createHash('sha256').update(bytes).digest('hex');
261
+ if (actualSha256 !== declared.sha256) {
262
+ throw new TypeError(`Manifest item ${declared.path} failed SHA-256 verification`);
263
+ }
264
+ }
203
265
  const seenFiles = new Set();
204
266
  const seenIds = new Set();
205
267
  const items = [];
@@ -374,6 +436,81 @@ function variantPayload(item, contentKey) {
374
436
  function isProvenNotFound(error) {
375
437
  return (error instanceof CmsRequestError && error.operationKey === 'content.get' && error.status === 404);
376
438
  }
439
+ function assertTransportableBody(value) {
440
+ if (Array.isArray(value)) {
441
+ for (const child of value)
442
+ assertTransportableBody(child);
443
+ return;
444
+ }
445
+ if (!isRecord(value))
446
+ return;
447
+ if ('ref' in value ||
448
+ value.type === 'file' ||
449
+ (typeof value.type === 'string' && value.type.endsWith('Reference'))) {
450
+ throw new TypeError('Unresolved content reference or media transport; import is blocked');
451
+ }
452
+ for (const child of Object.values(value))
453
+ assertTransportableBody(child);
454
+ }
455
+ function selectedLandingPageTemplateReferences(source, selectedItems) {
456
+ const selectedIds = new Set(selectedItems.map(({ id }) => id));
457
+ return source.manifest.externalReferences.filter((reference) => reference.kind !== 'cms.relationship' || !selectedIds.has(reference.source.sourceId));
458
+ }
459
+ function selectedNativeReferences(source, selectedItems) {
460
+ const selectedIds = new Set(selectedItems.map(({ id }) => id));
461
+ const inventory = inventoryExportReferences(source.manifest.workspaceId,
462
+ // The inventory only reads JSON; its transport type uses mutable array annotations.
463
+ new Map(source.items.map((item) => [item.id, item])));
464
+ const evidenced = new Map(inventory.externalReferences
465
+ .filter(({ kind }) => kind === 'cms.relationship')
466
+ .map((reference) => [reference.referenceId, reference]));
467
+ return source.manifest.externalReferences.filter((reference) => {
468
+ // Only the exporter-proven variant-owned relationship shape can be excluded.
469
+ // Unknown kinds, mismatched identities and contradictory associations fail closed.
470
+ const ownerId = reference.source.sourceId;
471
+ if (selectedIds.has(ownerId) ||
472
+ !isDeepStrictEqual(reference, evidenced.get(reference.referenceId)))
473
+ return true;
474
+ const ownerEntry = source.manifest.entries.find(({ variantId }) => variantId === ownerId);
475
+ return (ownerEntry === undefined ||
476
+ source.manifest.items.some((item) => item.referenceId === reference.referenceId && item.path !== ownerEntry.file));
477
+ });
478
+ }
479
+ function preflightReferences(plan, references = plan.source.manifest.externalReferences, allowValidatedStructuredReferences = false) {
480
+ if (references.some((reference) => reference.required && reference.resolution !== 'included') ||
481
+ plan.source.manifest.dependencies.length > 0) {
482
+ throw new TypeError('Unresolved required package reference; import is blocked');
483
+ }
484
+ const urls = new Set();
485
+ for (const group of plan.groups) {
486
+ if (new Set([group.primary, ...group.variants].map((item) => item.contentType)).size !== 1) {
487
+ throw new TypeError('Parent variants have conflicting content types');
488
+ }
489
+ for (const item of [group.primary, ...group.variants]) {
490
+ if ('references' in item ||
491
+ 'referencesList' in item ||
492
+ item.contentType === 'sfdc_cms__image' ||
493
+ item.contentType === 'sfdc_cms__doc') {
494
+ throw new TypeError('Unresolved content reference or media transport; import is blocked');
495
+ }
496
+ if (!allowValidatedStructuredReferences)
497
+ assertTransportableBody(item.contentBody);
498
+ if (item.externalSource !== undefined)
499
+ assertTransportableBody(item.externalSource);
500
+ const bodyUrl = item.contentBody['sfdc_cms:urlName'];
501
+ if (bodyUrl !== undefined &&
502
+ (typeof bodyUrl !== 'string' || (item.urlName !== undefined && item.urlName !== bodyUrl))) {
503
+ throw new TypeError('Conflicting body and variant URL names');
504
+ }
505
+ const url = item.urlName ?? bodyUrl;
506
+ if (typeof url === 'string') {
507
+ if (urls.has(url))
508
+ throw new TypeError('Target URL names must be unique across variants');
509
+ urls.add(url);
510
+ }
511
+ }
512
+ }
513
+ }
377
514
  async function preflightConflicts(connection, plan, requestOptions) {
378
515
  for (const group of plan.groups) {
379
516
  try {
@@ -387,6 +524,20 @@ async function preflightConflicts(connection, plan, requestOptions) {
387
524
  }
388
525
  }
389
526
  }
527
+ function soqlString(value) {
528
+ return `'${value.replaceAll('\\', '\\\\').replaceAll("'", String.raw `\'`)}'`;
529
+ }
530
+ async function preflightApiNames(connection, plan) {
531
+ if (connection.query === undefined) {
532
+ throw new TypeError('Destination API-name absence cannot be proven; email-fragment apply is blocked');
533
+ }
534
+ for (const apiName of new Set(plan.groups.flatMap(({ primary, variants }) => [primary, ...variants].flatMap((item) => (item.apiName === undefined ? [] : [item.apiName]))))) {
535
+ const result = await connection.query(`SELECT ApiName FROM ManagedContent WHERE ApiName = ${soqlString(apiName)} LIMIT 1`);
536
+ if (result.totalSize !== 0 || result.records.length > 0) {
537
+ throw new Error(`API name ${apiName} already exists`);
538
+ }
539
+ }
540
+ }
390
541
  function compareMappingIdentity(left, right) {
391
542
  return `${left.kind}\u0000${left.referenceId}`.localeCompare(`${right.kind}\u0000${right.referenceId}`);
392
543
  }
@@ -446,6 +597,39 @@ function validateParentResponse(response, expectedContentKey) {
446
597
  primaryVariantId: responseIdentifier(response, ['primaryVariantId', 'managedContentVariantId', 'variantId'], 'primary variant ID'),
447
598
  };
448
599
  }
600
+ function verifyNativeCopy(response, payload, created, language) {
601
+ const type = isRecord(response.contentType)
602
+ ? response.contentType.fullyQualifiedName
603
+ : response.contentType;
604
+ if (response.contentKey !== created.contentKey ||
605
+ responseIdentifier(response, ['managedContentId', 'id'], 'content ID') !== created.contentId ||
606
+ response.apiName !== payload.apiName ||
607
+ response.urlName !== payload.urlName ||
608
+ response.language !== language ||
609
+ response.title !== payload.title ||
610
+ type !== payload.contentType ||
611
+ !isRecord(response.contentSpace) ||
612
+ response.contentSpace.id !== payload.contentSpaceOrFolderId ||
613
+ response.isPublished !== false ||
614
+ !isRecord(response.status) ||
615
+ response.status.status !== 'Draft' ||
616
+ !isRecord(response.contentBody))
617
+ throw new TypeError('Native copy readback identity or draft verification failed');
618
+ for (const [key, value] of Object.entries(payload.contentBody)) {
619
+ // Salesforce entity-encodes raw HTML on retrieval; compare one encoding layer only.
620
+ const encoded = key === 'rawHtml' && typeof value === 'string'
621
+ ? value
622
+ .replaceAll('&', '&amp;')
623
+ .replaceAll('<', '&lt;')
624
+ .replaceAll('>', '&gt;')
625
+ .replaceAll('"', '&quot;')
626
+ .replaceAll("'", '&#39;')
627
+ : value;
628
+ if (!isDeepStrictEqual(response.contentBody[key], value) &&
629
+ !isDeepStrictEqual(response.contentBody[key], encoded))
630
+ throw new TypeError(`Native copy readback body differs: ${key}`);
631
+ }
632
+ }
449
633
  function validateChildResponse(response) {
450
634
  return responseIdentifier(response, ['id', 'variantId', 'managedContentVariantId'], 'variant ID');
451
635
  }
@@ -488,6 +672,33 @@ async function persistOperationResult(reportFile, report, operation, rewrite) {
488
672
  throw new WorkspaceImportOwnershipUncertainError(operation, error);
489
673
  }
490
674
  }
675
+ /**
676
+ * Reconstruct editable HTML before native validation, retaining all original identity inventory.
677
+ * @param {LoadedWorkspaceExport} source - Complete verified original package.
678
+ * @param {unknown} mappings - Native copy selections and fresh identities.
679
+ * @param {string} editableDirectory - Optional verified HTML companion.
680
+ * @returns {Promise<object>} Native proposal and optional all-companion evidence.
681
+ */
682
+ export async function planNativeWorkspaceImport(source, mappings, editableDirectory) {
683
+ if (editableDirectory === undefined)
684
+ return { proposal: planNativeCopies(source, mappings) };
685
+ const editable = await loadEditableRawHtml(source, editableDirectory);
686
+ const editedById = new Map(editable.items.map((item) => [item.id, item]));
687
+ const proposal = planNativeCopies({ ...source, items: source.items.map((item) => editedById.get(item.id) ?? item) }, mappings, editable.literalHtmlVariantIds);
688
+ for (const item of proposal.items) {
689
+ if (!editedById.has(item.id))
690
+ throw new TypeError(`Selected variant ${item.id} is missing from the editable companion`);
691
+ }
692
+ preflightReferences({
693
+ source,
694
+ groups: proposal.items.map((primary) => ({
695
+ contentKey: primary.contentKey,
696
+ primary,
697
+ variants: [],
698
+ })),
699
+ }, selectedNativeReferences(source, proposal.items));
700
+ return { proposal, editableSource: editable.editableSource };
701
+ }
491
702
  export async function executeWorkspaceImport(options) {
492
703
  if (!nonemptyString(options.destinationOrgId)) {
493
704
  throw new TypeError('destinationOrgId must be the nonempty destination org ID');
@@ -496,9 +707,112 @@ export async function executeWorkspaceImport(options) {
496
707
  (await loadWorkspaceExport(options.sourceDirectory, {
497
708
  allowPartial: options.allowPartial,
498
709
  }));
499
- const plan = planWorkspaceImport(source, options.destinationWorkspace, options.workspaceId);
710
+ if (!source.integrity.verified)
711
+ throw new TypeError('Source integrity must be verified first');
712
+ if (source.isPartial && options.allowPartial !== true) {
713
+ throw new TypeError('Workspace export is partial; explicit allowPartial is required');
714
+ }
715
+ validateRelationships(source.items);
716
+ const native = options.nativeCopyMappings !== undefined;
717
+ const emailFragment = options.emailFragmentMappings !== undefined;
718
+ const webFragment = options.webFragmentMappings !== undefined;
719
+ const landingPageTemplate = options.landingPageTemplateMappings !== undefined;
720
+ const landingPage = options.landingPageMappings !== undefined;
721
+ if (Number(native) +
722
+ Number(emailFragment) +
723
+ Number(webFragment) +
724
+ Number(landingPageTemplate) +
725
+ Number(landingPage) +
726
+ Number(options.identityMappings !== undefined) >
727
+ 1)
728
+ throw new TypeError('Choose one identity profile');
729
+ if (options.editableDirectory !== undefined && !native)
730
+ throw new TypeError('editableDirectory requires nativeCopyMappings');
731
+ let proposal;
732
+ let editableSource;
733
+ let landingPageTemplateProposal;
734
+ let landingPageProposal;
735
+ let webFragmentProposal;
736
+ if (native) {
737
+ ({ proposal, editableSource } = await planNativeWorkspaceImport(source, options.nativeCopyMappings, options.editableDirectory));
738
+ }
739
+ else if (emailFragment) {
740
+ proposal = planEmailFragmentCopies(source, options.emailFragmentMappings);
741
+ }
742
+ else if (webFragment) {
743
+ webFragmentProposal = planWebFragmentCopies(source, options.webFragmentMappings);
744
+ proposal = webFragmentProposal;
745
+ }
746
+ else if (landingPageTemplate) {
747
+ landingPageTemplateProposal = planLandingPageTemplateCopies(source, options.landingPageTemplateMappings);
748
+ proposal = landingPageTemplateProposal;
749
+ }
750
+ else if (landingPage) {
751
+ landingPageProposal = planLandingPageCopies(source, options.landingPageMappings);
752
+ proposal = landingPageProposal;
753
+ }
754
+ else if (options.identityMappings !== undefined)
755
+ proposal = planImportIdentities(source, options.identityMappings);
756
+ const selectedIds = new Set(proposal?.items.map((item) => item.id));
757
+ const editableDiagnostics = editableSource === undefined
758
+ ? []
759
+ : [
760
+ {
761
+ code: 'EDITABLE_HTML_INPUT',
762
+ message: `Editable HTML input: original package integrity and references describe only the unchanged baseline, not the edited payload. ${editableSource.entries.filter((entry) => entry.changed && selectedIds.has(entry.variantId)).length} selected HTML modification(s) ${options.dryRun === true ? 'planned' : 'applied'}; ${editableSource.entries.filter((entry) => entry.changed).length} changed entry/entries across all ${editableSource.entries.length} companion entries. No CMS reference rewriting is established by HTML edits.`,
763
+ retryable: false,
764
+ },
765
+ ];
766
+ const selectedSource = native || emailFragment || webFragment || landingPageTemplate || landingPage
767
+ ? { ...source, items: proposal.items }
768
+ : source;
769
+ const sourcePlan = planWorkspaceImport(selectedSource, options.destinationWorkspace, options.workspaceId);
770
+ // Keep original variant IDs and source manifest associations; never substitute the raw package.
771
+ const proposedById = new Map(proposal?.items.map((item) => [item.id, item]));
772
+ const plan = proposal === undefined
773
+ ? sourcePlan
774
+ : {
775
+ ...sourcePlan,
776
+ source,
777
+ groups: sourcePlan.groups.map((group) => ({
778
+ contentKey: proposedById.get(group.primary.id).contentKey,
779
+ primary: proposedById.get(group.primary.id),
780
+ variants: group.variants.map((variant) => proposedById.get(variant.id)),
781
+ })),
782
+ };
783
+ let selectedReferences;
784
+ if (native)
785
+ selectedReferences = selectedNativeReferences(source, proposal.items);
786
+ else if (landingPageTemplate || landingPage) {
787
+ selectedReferences = selectedLandingPageTemplateReferences(source, proposal.items);
788
+ }
789
+ preflightReferences(plan, selectedReferences, landingPageTemplate || landingPage);
500
790
  const requestOptions = options.requestOptions ?? {};
501
- await preflightConflicts(options.connection, plan, requestOptions);
791
+ if (!native)
792
+ await preflightConflicts(options.connection, plan, requestOptions);
793
+ if (emailFragment || webFragment || landingPageTemplate || landingPage)
794
+ await preflightApiNames(options.connection, plan);
795
+ if (webFragment) {
796
+ await validateDataGraphPrerequisites(options.connection, webFragmentProposal.dataGraphs);
797
+ }
798
+ if (landingPageTemplate) {
799
+ await validateLandingPageTemplatePrerequisites(options.connection, plan.destinationWorkspaceId, landingPageTemplateProposal, requestOptions);
800
+ }
801
+ if (landingPage) {
802
+ await validateLandingPagePrerequisites(options.connection, plan.destinationWorkspaceId, landingPageProposal, requestOptions);
803
+ }
804
+ const hasNamedIdentities = plan.groups.some((group) => [group.primary, ...group.variants].some((item) => item.apiName !== undefined ||
805
+ item.urlName !== undefined ||
806
+ item.contentBody['sfdc_cms:urlName'] !== undefined));
807
+ if (!native &&
808
+ !emailFragment &&
809
+ !webFragment &&
810
+ !landingPageTemplate &&
811
+ !landingPage &&
812
+ hasNamedIdentities &&
813
+ options.dryRun !== true) {
814
+ throw new TypeError('Destination API-name/URL-name uniqueness cannot be verified by the supported CMS APIs; import is blocked');
815
+ }
502
816
  const references = source.manifest.externalReferences
503
817
  .filter(({ resolution }) => resolution !== 'included')
504
818
  .map(({ kind, referenceId, resolution }) => ({
@@ -508,7 +822,32 @@ export async function executeWorkspaceImport(options) {
508
822
  }))
509
823
  .toSorted(compareMappingIdentity);
510
824
  if (options.dryRun === true) {
825
+ const diagnostics = [
826
+ ...editableDiagnostics,
827
+ {
828
+ code: 'SERVER_CONFLICT_CHECK_UNVERIFIED',
829
+ message: native
830
+ ? 'Native copy omits contentKey. API-name conflicts can reject; duplicate URLs can create distinct objects. Destination name availability is not prevalidated.'
831
+ : 'Content-key absence was checked, but complete server conflict validation is unverified; this is a read-only proposal.',
832
+ retryable: false,
833
+ },
834
+ {
835
+ code: 'APPLY_READINESS_UNVERIFIED',
836
+ message: 'Dry-run success is not deploy readiness and does not authorize apply.',
837
+ retryable: false,
838
+ },
839
+ ];
840
+ if (hasNamedIdentities && !native) {
841
+ diagnostics.unshift({
842
+ code: 'NAME_AVAILABILITY_UNVERIFIED',
843
+ message: emailFragment || webFragment || landingPageTemplate || landingPage
844
+ ? `Destination API-name absence was proven by exact ManagedContent.ApiName equality lookup;${webFragment || landingPageTemplate || landingPage ? ' every named prerequisite was proven in exact type/workspace scope;' : ''} other server conflict behavior remains unverified.`
845
+ : 'Destination API-name/URL-name availability is unverified; named apply remains blocked until collision evidence is available.',
846
+ retryable: false,
847
+ });
848
+ }
511
849
  return {
850
+ diagnostics,
512
851
  contractResult: importContractResult(source, options.destinationOrgId, plan, [], references),
513
852
  dryRun: true,
514
853
  plan,
@@ -521,6 +860,21 @@ export async function executeWorkspaceImport(options) {
521
860
  await mkdir(reportDirectory);
522
861
  const reportFile = path.join(reportDirectory, 'workspace-import-run.json');
523
862
  const report = {
863
+ ...(editableSource === undefined ? {} : { editableSource }),
864
+ ...(landingPageTemplateProposal === undefined && landingPageProposal === undefined
865
+ ? {}
866
+ : {
867
+ cmsPrerequisites: landingPageTemplateProposal?.cmsPrerequisites ?? landingPageProposal.cmsPrerequisites,
868
+ }),
869
+ ...(webFragmentProposal === undefined &&
870
+ landingPageTemplateProposal === undefined &&
871
+ landingPageProposal === undefined
872
+ ? {}
873
+ : {
874
+ dataGraphPrerequisites: webFragmentProposal?.dataGraphs ??
875
+ landingPageTemplateProposal?.dataGraphs ??
876
+ landingPageProposal.dataGraphs,
877
+ }),
524
878
  createdParents: [],
525
879
  destinationOrgId: options.destinationOrgId,
526
880
  destinationWorkspaceId: plan.destinationWorkspaceId,
@@ -548,11 +902,31 @@ export async function executeWorkspaceImport(options) {
548
902
  ]));
549
903
  const sourceReferenceValues = new Set(sourceReferencesByValue.keys());
550
904
  const unresolvedMappingIds = new Set();
905
+ // Editable native copies have no reference-bearing body or child variants. Bind every
906
+ // exact CREATE payload in the initial durable journal, before any mutation.
907
+ if (editableSource !== undefined) {
908
+ for (const group of plan.groups) {
909
+ const payload = createPayload(group, plan.destinationWorkspaceId);
910
+ delete payload.contentKey;
911
+ delete payload.externalId;
912
+ delete payload.externalSource;
913
+ report.operations.push(pendingOperation(report, 'create-parent', group.contentKey, group.primary.language, payload));
914
+ }
915
+ }
551
916
  await writeExclusive(reportFile, report);
552
917
  try {
553
918
  for (const group of plan.groups) {
919
+ if (landingPageTemplate) {
920
+ await validateLandingPageTemplatePrerequisites(options.connection, plan.destinationWorkspaceId, landingPageTemplateProposal, requestOptions);
921
+ }
922
+ else if (landingPage) {
923
+ await validateLandingPagePrerequisites(options.connection, plan.destinationWorkspaceId, landingPageProposal, requestOptions);
924
+ }
925
+ const finalPrimary = proposal?.items.find((item) => item.id === group.primary.id) ?? group.primary;
926
+ const finalVariants = group.variants.map((variant) => proposal?.items.find((item) => item.id === variant.id) ?? variant);
927
+ const finalGroup = { ...group, primary: finalPrimary, variants: finalVariants };
554
928
  const groupReferenceKeys = new Set();
555
- for (const item_ of [group.primary, ...group.variants]) {
929
+ for (const item_ of [finalGroup.primary, ...finalGroup.variants]) {
556
930
  for (const key of cmsContentBodyReferenceKeys(item_.contentBody, sourceReferenceValues)) {
557
931
  groupReferenceKeys.add(key);
558
932
  }
@@ -569,22 +943,46 @@ export async function executeWorkspaceImport(options) {
569
943
  }
570
944
  }
571
945
  const rewrittenGroup = hasUnknownRequiredMapping
572
- ? group
946
+ ? finalGroup
573
947
  : {
574
- ...group,
575
- primary: rewriteItem(group.primary, replacements),
576
- variants: group.variants.map((variant) => rewriteItem(variant, replacements)),
948
+ ...finalGroup,
949
+ primary: rewriteItem(finalGroup.primary, replacements),
950
+ variants: finalGroup.variants.map((variant) => rewriteItem(variant, replacements)),
951
+ };
952
+ const parentPayload = createPayload(rewrittenGroup, native ? plan.destinationWorkspaceId : plan.rootFolderId);
953
+ if (native) {
954
+ delete parentPayload.contentKey;
955
+ delete parentPayload.externalId;
956
+ delete parentPayload.externalSource;
957
+ }
958
+ if (emailFragment || webFragment || landingPageTemplate || landingPage) {
959
+ const currentPlan = {
960
+ ...plan,
961
+ groups: [rewrittenGroup],
577
962
  };
578
- const parentPayload = createPayload(rewrittenGroup, plan.rootFolderId);
579
- const parentOperation = pendingOperation(report, 'create-parent', group.contentKey, group.primary.language, parentPayload);
580
- report.operations.push(parentOperation);
963
+ await preflightConflicts(options.connection, currentPlan, requestOptions);
964
+ await preflightApiNames(options.connection, currentPlan);
965
+ if (webFragment) {
966
+ await validateDataGraphPrerequisites(options.connection, webFragmentProposal.dataGraphs);
967
+ }
968
+ }
969
+ const parentOperation = editableSource === undefined
970
+ ? pendingOperation(report, 'create-parent', group.contentKey, group.primary.language, parentPayload)
971
+ : report.operations.find((operation) => operation.contentKey === group.contentKey);
972
+ if (editableSource === undefined)
973
+ report.operations.push(parentOperation);
974
+ else if (parentOperation.requestSha256 !==
975
+ operationIdentity(report.runId, 'create-parent', group.contentKey, group.primary.language, parentPayload).requestSha256)
976
+ throw new TypeError('Editable CREATE payload changed after initial journal');
581
977
  await rewrite(reportFile, report);
582
978
  let parentResponse;
583
979
  try {
584
980
  parentResponse = await createContent(options.connection, parentPayload, requestOptions);
585
981
  }
586
982
  catch (error) {
587
- parentOperation.state = 'failed';
983
+ parentOperation.state = native ? 'pending' : 'failed';
984
+ if (native)
985
+ report.state = 'ownership-uncertain';
588
986
  parentOperation.error = error instanceof Error ? error.message : String(error);
589
987
  try {
590
988
  await rewrite(reportFile, report);
@@ -594,14 +992,38 @@ export async function executeWorkspaceImport(options) {
594
992
  }
595
993
  throw error;
596
994
  }
597
- const created = validateParentResponse(parentResponse, group.contentKey);
995
+ // Persist returned identity before any semantic verification or further requests.
996
+ if (native) {
997
+ parentOperation.result = {
998
+ contentKey: typeof parentResponse.contentKey === 'string' ? parentResponse.contentKey : undefined,
999
+ contentId: [parentResponse.managedContentId, parentResponse.id].find(nonemptyString),
1000
+ primaryVariantId: [
1001
+ parentResponse.managedContentVariantId,
1002
+ parentResponse.primaryVariantId,
1003
+ ].find(nonemptyString),
1004
+ };
1005
+ report.state = 'ownership-uncertain';
1006
+ await persistOperationResult(reportFile, report, parentOperation, rewrite);
1007
+ }
1008
+ const created = validateParentResponse(parentResponse, native
1009
+ ? responseIdentifier(parentResponse, ['contentKey'], 'generated key')
1010
+ : group.contentKey);
598
1011
  report.createdParents.push(created);
599
1012
  parentOperation.state = 'succeeded';
600
1013
  parentOperation.result = {
601
1014
  contentId: created.contentId,
1015
+ contentKey: created.contentKey,
602
1016
  primaryVariantId: created.primaryVariantId,
603
1017
  };
604
1018
  await persistOperationResult(reportFile, report, parentOperation, rewrite);
1019
+ if (native) {
1020
+ if (source.items.some((item) => item.contentKey === created.contentKey ||
1021
+ variantIdentity(item, item.id).contentId === created.contentId))
1022
+ throw new TypeError('Native response reused a source identity');
1023
+ const readback = await getContent(options.connection, created.contentKey, {}, requestOptions);
1024
+ verifyNativeCopy(readback, parentPayload, created, group.primary.language);
1025
+ report.state = 'applying';
1026
+ }
605
1027
  const reference = referenceByVariantId.get(group.primary.id);
606
1028
  if (reference !== undefined) {
607
1029
  replacements.set(reference.source.sourceId, created.contentId);
@@ -667,7 +1089,8 @@ export async function executeWorkspaceImport(options) {
667
1089
  await rewrite(reportFile, report);
668
1090
  }
669
1091
  catch (error) {
670
- if (!(error instanceof WorkspaceImportOwnershipUncertainError))
1092
+ if (!(error instanceof WorkspaceImportOwnershipUncertainError) &&
1093
+ report.state !== 'ownership-uncertain')
671
1094
  report.state = 'failed';
672
1095
  try {
673
1096
  await rewrite(reportFile, report);
@@ -688,6 +1111,7 @@ export async function executeWorkspaceImport(options) {
688
1111
  }
689
1112
  }
690
1113
  return {
1114
+ diagnostics: editableDiagnostics,
691
1115
  contractResult: importContractResult(source, options.destinationOrgId, plan, mappings, references),
692
1116
  dryRun: false,
693
1117
  plan,