From 77ce8608838e0aa9f95e33d383fc371dc724ce12 Mon Sep 17 00:00:00 2001 From: sc-naveenhedallaarachchi Date: Tue, 6 Oct 2026 10:11:38 +0530 Subject: [PATCH 1/2] Include required Sitecore peer dependencies in metadata.json --- .changeset/yarn-pages-router-peer-metadata.md | 5 + .../core/src/tools/metadata/metadata.test.ts | 139 +++++++- packages/core/src/tools/metadata/metadata.ts | 317 +++++++++++++++++- 3 files changed, 443 insertions(+), 18 deletions(-) create mode 100644 .changeset/yarn-pages-router-peer-metadata.md diff --git a/.changeset/yarn-pages-router-peer-metadata.md b/.changeset/yarn-pages-router-peer-metadata.md new file mode 100644 index 0000000000..87c1a6d249 --- /dev/null +++ b/.changeset/yarn-pages-router-peer-metadata.md @@ -0,0 +1,5 @@ +--- +'@sitecore-content-sdk/core': patch +--- + +Include required Sitecore peer dependencies in `.sitecore/metadata.json` when the package manager does not install them. Metadata records the same version npm would install, without adding those peers as direct dependencies. diff --git a/packages/core/src/tools/metadata/metadata.test.ts b/packages/core/src/tools/metadata/metadata.test.ts index 848d9fb8ee..43f6fca3c2 100644 --- a/packages/core/src/tools/metadata/metadata.test.ts +++ b/packages/core/src/tools/metadata/metadata.test.ts @@ -4,6 +4,9 @@ import { expect } from 'chai'; import { getMetadata } from './metadata'; import sinon, { SinonStub } from 'sinon'; import childProcess from 'child_process'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; import metadataNextjs from './test-data/metadata-nextjs.json'; import npmQueryResultNext from './test-data/npm-query-nextjs.json'; import npmQueryResultNoSc from './test-data/npm-query-no-sc.json'; @@ -131,15 +134,17 @@ describe('metadata', () => { it('should return tracked packages with exact versions from result of yarn info', () => { runWithPackageManager(USER_AGENTS.yarn); - execSyncStub.withArgs(YARN_INFO).returns( - [ - '{"value":"@sitecore-content-sdk/nextjs@npm:22.2.0-canary.69","children":{}}', - '"@sitecore-content-sdk/core@npm:22.2.0-canary.69"', - '{"value":"@sitecore-content-sdk/react@virtual:1a2b3c#npm:22.2.0-canary.69"}', - '➤ YN0000: a diagnostic that is not json', - '', - ].join('\n') - ); + execSyncStub + .withArgs(YARN_INFO) + .returns( + [ + '{"value":"@sitecore-content-sdk/nextjs@npm:22.2.0-canary.69","children":{}}', + '"@sitecore-content-sdk/core@npm:22.2.0-canary.69"', + '{"value":"@sitecore-content-sdk/react@virtual:1a2b3c#npm:22.2.0-canary.69"}', + '➤ YN0000: a diagnostic that is not json', + '', + ].join('\n') + ); const metadata = getMetadata(); @@ -153,14 +158,122 @@ describe('metadata', () => { }); }); - it('should return tracked packages with exact versions from result of yarn classic list', () => { + const writeManifest = (root: string, name: string, manifest: unknown) => { + const manifestDirectory = path.join(root, 'node_modules', name); + + fs.mkdirSync(manifestDirectory, { recursive: true }); + fs.writeFileSync(path.join(manifestDirectory, 'package.json'), JSON.stringify(manifest)); + }; + + const withProject = (manifests: { name: string; manifest: unknown }[], run: () => void) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'metadata-peers-')); + const cwd = process.cwd(); + + manifests.forEach(({ name, manifest }) => writeManifest(root, name, manifest)); + process.chdir(root); + + try { + run(); + } finally { + process.chdir(cwd); + fs.rmSync(root, { recursive: true, force: true }); + } + }; + + const nextjsPeerManifest = { + peerDependencies: { + '@sitecore-content-sdk/personalize': '^2.1.0', + react: '^19.2.1', + typescript: '^5.4.0', + }, + peerDependenciesMeta: { + typescript: { optional: true }, + }, + }; + + it('should include a tracked peer dependency Yarn did not install', () => { + runWithPackageManager(USER_AGENTS.yarnClassic); + execSyncStub.withArgs(YARN_CLASSIC_LIST).returns( + '{"type":"tree","data":{"type":"list","trees":[{"name":"@sitecore-content-sdk/nextjs@2.5.0-canary.1","children":[]}]}}' + ); + const execFileSyncStub = sinon.stub(childProcess, 'execFileSync').returns( + JSON.stringify(['2.0.0', '2.1.0', '2.1.1-canary.1', '3.0.0']) + ); + + withProject([{ name: '@sitecore-content-sdk/nextjs', manifest: nextjsPeerManifest }], () => { + const metadata = getMetadata(); + + expect(execFileSyncStub.calledOnce).to.be.true; + expect(execFileSyncStub.firstCall.args[2].env.METADATA_PACKAGE_NAME).to.equal( + '@sitecore-content-sdk/personalize' + ); + expect(metadata).to.deep.equal({ + packages: { + '@sitecore-content-sdk/nextjs': '2.5.0-canary.1', + '@sitecore-content-sdk/personalize': '2.1.0', + }, + }); + }); + }); + + it('should use the installed version of a tracked peer dependency', () => { runWithPackageManager(USER_AGENTS.yarnClassic); execSyncStub.withArgs(YARN_CLASSIC_LIST).returns( + '{"type":"tree","data":{"type":"list","trees":[{"name":"@sitecore-content-sdk/nextjs@2.5.0-canary.1","children":[]}]}}' + ); + const execFileSyncStub = sinon.stub(childProcess, 'execFileSync'); + + withProject( [ - '{"type":"progressStart","data":{"id":0,"total":5}}', - '{"type":"tree","data":{"type":"list","trees":[{"name":"@sitecore-content-sdk/core@22.2.0-canary.69","children":[],"depth":0},{"name":"@sitecore/byoc@0.2.15","children":[],"depth":0}]}}', - ].join('\n') + { name: '@sitecore-content-sdk/nextjs', manifest: nextjsPeerManifest }, + { + name: '@sitecore-content-sdk/personalize', + manifest: { version: '2.1.0' }, + }, + ], + () => { + const metadata = getMetadata(); + + expect(execFileSyncStub.called).to.be.false; + expect(metadata.packages['@sitecore-content-sdk/personalize']).to.equal('2.1.0'); + } ); + }); + + it('should keep metadata when a missing peer dependency cannot be resolved', () => { + runWithPackageManager(USER_AGENTS.yarn); + execSyncStub + .withArgs(YARN_INFO) + .returns('"@sitecore-content-sdk/nextjs@npm:2.5.0-canary.1"'); + sinon.stub(childProcess, 'execFileSync').throws(new Error('offline')); + logStub = sinon.stub(console, 'warn'); + + withProject([{ name: '@sitecore-content-sdk/nextjs', manifest: nextjsPeerManifest }], () => { + const metadata = getMetadata(); + + expect( + logStub.calledOnceWith( + "Unable to resolve peer dependency '@sitecore-content-sdk/personalize@^2.1.0' for metadata" + ) + ).to.be.true; + expect(metadata).to.deep.equal({ + packages: { + '@sitecore-content-sdk/nextjs': '2.5.0-canary.1', + }, + }); + }); + }); + + it('should return tracked packages with exact versions from result of yarn classic list', () => { + runWithPackageManager(USER_AGENTS.yarnClassic); + execSyncStub + .withArgs(YARN_CLASSIC_LIST) + .returns( + [ + '{"type":"progressStart","data":{"id":0,"total":5}}', + '{"type":"tree","data":{"type":"list","trees":[{"name":"@sitecore-content-sdk/core@22.2.0-canary.69","children":[],"depth":0},{"name":"@sitecore/byoc@0.2.15","children":[],"depth":0}]}}', + ].join('\n') + ); const metadata = getMetadata(); diff --git a/packages/core/src/tools/metadata/metadata.ts b/packages/core/src/tools/metadata/metadata.ts index 9cf4ccd5de..ab3e6cef0a 100644 --- a/packages/core/src/tools/metadata/metadata.ts +++ b/packages/core/src/tools/metadata/metadata.ts @@ -1,4 +1,6 @@ -import { execSync } from 'child_process'; +import { execFileSync, execSync } from 'child_process'; +import fs from 'fs'; +import path from 'path'; type Package = { name: string; @@ -35,7 +37,9 @@ const PACKAGE_ENTRY = /(@[^\s/:\\]+\/[^\s/:\\]+)@([^\s]+)$/; const MAX_OUTPUT_BUFFER = 10 * 1024 * 1024; /** - * Get application metadata + * Get application metadata. Non-optional tracked peer dependencies are included as well. + * npm installs those peers on its own; Yarn does not, so a peer such as + * `@sitecore-content-sdk/personalize` would otherwise be missing from metadata. * @param {boolean} allowWorkspaces - Whether to allow workspaces in the metadata generation. * @returns {Metadata} The generated metadata. */ @@ -58,6 +62,7 @@ export function getMetadata(allowWorkspaces: boolean = false): Metadata { } metadata.packages = getPackagesFromQueryResult(queryResult); + addMissingTrackedPeers(metadata.packages); return metadata; } @@ -71,7 +76,7 @@ function getPackagesFromQueryResult(scPackages: Package[]): Record = {}; scPackages.forEach((scPackage) => { - if (trackedScopes.some((trackedScope) => scPackage.name.startsWith(trackedScope))) { + if (isTrackedPackage(scPackage.name)) { packages[scPackage.name] = scPackage.version; } }); @@ -79,6 +84,309 @@ function getPackagesFromQueryResult(scPackages: Package[]): Record; + peerDependenciesMeta?: Record; +}; + +type Semver = { + major: number; + minor: number; + patch: number; + prerelease: boolean; +}; + +// Prints the published versions of METADATA_PACKAGE_NAME. The name stays in the environment so a +// caret range never has to be escaped for cmd.exe. +const PUBLISHED_VERSIONS_SCRIPT = ` +const name = process.env.METADATA_PACKAGE_NAME || ''; +const registry = (process.env.npm_config_registry || 'https://registry.npmjs.org').replace(/\\/$/, ''); +fetch(registry + '/' + encodeURIComponent(name)) + .then((response) => (response.ok ? response.json() : {})) + .then((doc) => { + const versions = doc && doc.versions ? Object.keys(doc.versions) : []; + process.stdout.write(JSON.stringify(versions)); + }) + .catch(() => process.stdout.write('[]')); +`; + +/** + * Add tracked peer dependencies that the package manager did not list. + * An installed copy wins. Otherwise the highest published version of the peer range is used, + * which is the version npm would have installed. + * @param {Record} packages tracked packages already discovered + */ +function addMissingTrackedPeers(packages: Record): void { + const seen = new Set(); + const pending = Object.keys(packages); + + while (pending.length > 0) { + const name = pending.pop(); + + if (!name || seen.has(name)) { + continue; + } + + seen.add(name); + + const manifest = readInstalledManifest(name); + + if (!manifest) { + continue; + } + + getRequiredTrackedPeers(manifest).forEach((peer) => { + if (packages[peer.name]) { + return; + } + + const version = + readInstalledManifest(peer.name)?.version || resolvePublishedVersion(peer.name, peer.range); + + if (!version) { + console.warn( + `Unable to resolve peer dependency '${peer.name}@${peer.range}' for metadata` + ); + return; + } + + packages[peer.name] = version; + pending.push(peer.name); + }); + } +} + +/** + * Non-optional peer dependencies that belong to a tracked scope + * @param {PackageManifest} manifest package manifest + * @returns {{ name: string; range: string }[]} peers metadata should record + */ +function getRequiredTrackedPeers(manifest: PackageManifest): { name: string; range: string }[] { + const peers = manifest.peerDependencies ?? {}; + const meta = manifest.peerDependenciesMeta ?? {}; + + return Object.entries(peers).flatMap(([name, range]) => { + const optional = meta[name]?.optional === true; + + return isTrackedPackage(name) && !optional && range ? [{ name, range }] : []; + }); +} + +/** + * Read an installed package manifest from the project node_modules. + * Resolution stays at the project root so a workspace checkout of the SDK is not picked up + * while tests run from a package directory. + * @param {string} name package name + * @returns {PackageManifest | undefined} the manifest, when it is installed + */ +function readInstalledManifest(name: string): PackageManifest | undefined { + const manifestPath = path.join(process.cwd(), 'node_modules', name, 'package.json'); + + try { + if (!fs.existsSync(manifestPath)) { + return undefined; + } + + const parsed: unknown = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + + return isPackageManifest(parsed) ? parsed : undefined; + } catch { + return undefined; + } +} + +/** + * Highest published version that satisfies a caret or exact peer range + * @param {string} name package name + * @param {string} range peer dependency range + * @returns {string | undefined} the resolved version + */ +function resolvePublishedVersion(name: string, range: string): string | undefined { + if (!/^\^?\d+\.\d+\.\d+$/.test(range.trim())) { + return undefined; + } + + return maxSatisfying(fetchPublishedVersions(name), range); +} + +/** + * Published versions of a package from the npm registry + * @param {string} name package name + * @returns {string[]} versions, empty when the registry could not be read + */ +function fetchPublishedVersions(name: string): string[] { + try { + const output = execFileSync(process.execPath, ['-e', PUBLISHED_VERSIONS_SCRIPT], { + encoding: 'utf8', + env: { ...process.env, METADATA_PACKAGE_NAME: name }, + maxBuffer: MAX_OUTPUT_BUFFER, + timeout: 20000, + stdio: ['ignore', 'pipe', 'pipe'], + }); + const parsed: unknown = JSON.parse(output); + + return Array.isArray(parsed) ? parsed.filter((version) => typeof version === 'string') : []; + } catch { + return []; + } +} + +/** + * @param {string[]} versions published versions + * @param {string} range caret or exact version range + * @returns {string | undefined} the highest matching version + */ +function maxSatisfying(versions: string[], range: string): string | undefined { + return versions.reduce((best, version) => { + if (!satisfiesRange(version, range)) { + return best; + } + + return !best || compareVersions(version, best) > 0 ? version : best; + }, undefined); +} + +/** + * Caret and exact ranges cover the peer ranges the SDK packages declare. + * Prereleases are excluded, so `^2.1.0` stays on releases. + * @param {string} version published version + * @param {string} range peer range + * @returns {boolean} whether the version satisfies the range + */ +function satisfiesRange(version: string, range: string): boolean { + const parsedVersion = parseVersion(version); + const trimmed = range.trim(); + const caret = /^\^(\d+)\.(\d+)\.(\d+)$/.exec(trimmed); + const exact = /^(\d+)\.(\d+)\.(\d+)$/.exec(trimmed); + + if (!parsedVersion || parsedVersion.prerelease) { + return false; + } + + if (exact) { + return compareVersions(version, `${exact[1]}.${exact[2]}.${exact[3]}`) === 0; + } + + if (!caret) { + return false; + } + + const lowerBound = `${caret[1]}.${caret[2]}.${caret[3]}`; + const major = Number(caret[1]); + const minor = Number(caret[2]); + + if (compareVersions(version, lowerBound) < 0) { + return false; + } + + if (major > 0) { + return parsedVersion.major === major; + } + + if (minor > 0) { + return parsedVersion.major === 0 && parsedVersion.minor === minor; + } + + return ( + parsedVersion.major === 0 && + parsedVersion.minor === 0 && + parsedVersion.patch === Number(caret[3]) + ); +} + +/** + * @param {string} version version string + * @returns {Semver | undefined} parsed version + */ +function parseVersion(version: string): Semver | undefined { + const match = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+.*)?$/.exec(version); + + if (!match) { + return undefined; + } + + return { + major: Number(match[1]), + minor: Number(match[2]), + patch: Number(match[3]), + prerelease: Boolean(match[4]), + }; +} + +/** + * @param {string} left version + * @param {string} right version + * @returns {number} negative when left is older + */ +function compareVersions(left: string, right: string): number { + const a = parseVersion(left); + const b = parseVersion(right); + + if (!a || !b) { + return 0; + } + + if (a.major !== b.major) { + return a.major - b.major; + } + + if (a.minor !== b.minor) { + return a.minor - b.minor; + } + + return a.patch - b.patch; +} + +/** + * @param {string} name package name + * @returns {boolean} whether the package belongs to a tracked scope + */ +function isTrackedPackage(name: string): boolean { + return trackedScopes.some((trackedScope) => name.startsWith(trackedScope)); +} + +/** + * @param {unknown} value parsed package.json + * @returns {value is PackageManifest} whether the value can be read as a manifest + */ +function isPackageManifest(value: unknown): value is PackageManifest { + if (!isRecord(value)) { + return false; + } + + const peers = value.peerDependencies; + const meta = value.peerDependenciesMeta; + + return ( + (value.version === undefined || typeof value.version === 'string') && + (peers === undefined || isStringRecord(peers)) && + (meta === undefined || isPeerMeta(meta)) + ); +} + +/** + * @param {unknown} value value to check + * @returns {value is Record} whether every property is a string + */ +function isStringRecord(value: unknown): value is Record { + return isRecord(value) && Object.values(value).every((entry) => typeof entry === 'string'); +} + +/** + * @param {unknown} value peerDependenciesMeta + * @returns {value is Record} whether the meta is usable + */ +function isPeerMeta(value: unknown): value is Record { + return ( + isRecord(value) && + Object.values(value).every( + (entry) => + isRecord(entry) && (entry.optional === undefined || typeof entry.optional === 'boolean') + ) + ); +} + /** * Build the listing command and parser for each supported package manager. * Yarn classic is a dedicated entry because its command differs from yarn berry. @@ -173,8 +481,7 @@ function parseNpmQuery(output: string): Package[] { return Array.isArray(parsed) ? parsed.filter( - (scPackage) => - typeof scPackage?.name === 'string' && typeof scPackage?.version === 'string' + (scPackage) => typeof scPackage?.name === 'string' && typeof scPackage?.version === 'string' ) : []; } From 1923d52a3e6c7480e685ecc118c2ec6a9ca422a6 Mon Sep 17 00:00:00 2001 From: sc-naveenhedallaarachchi Date: Wed, 7 Oct 2026 11:17:24 +0530 Subject: [PATCH 2/2] Add @sitecore-content-sdk/personalize to Next.js Pages Router dependencies --- .changeset/yarn-pages-router-peer-metadata.md | 4 +- .../core/src/tools/metadata/metadata.test.ts | 139 +------- packages/core/src/tools/metadata/metadata.ts | 317 +----------------- .../src/templates/nextjs/package.json | 1 + 4 files changed, 21 insertions(+), 440 deletions(-) diff --git a/.changeset/yarn-pages-router-peer-metadata.md b/.changeset/yarn-pages-router-peer-metadata.md index 87c1a6d249..56a7245b76 100644 --- a/.changeset/yarn-pages-router-peer-metadata.md +++ b/.changeset/yarn-pages-router-peer-metadata.md @@ -1,5 +1,5 @@ --- -'@sitecore-content-sdk/core': patch +'create-content-sdk-app': patch --- -Include required Sitecore peer dependencies in `.sitecore/metadata.json` when the package manager does not install them. Metadata records the same version npm would install, without adding those peers as direct dependencies. +Add `@sitecore-content-sdk/personalize` to the Next.js Pages Router template dependencies. Yarn does not install missing peers, so a Pages Router app built with Yarn could not resolve the package and omitted it from `.sitecore/metadata.json`. diff --git a/packages/core/src/tools/metadata/metadata.test.ts b/packages/core/src/tools/metadata/metadata.test.ts index 43f6fca3c2..848d9fb8ee 100644 --- a/packages/core/src/tools/metadata/metadata.test.ts +++ b/packages/core/src/tools/metadata/metadata.test.ts @@ -4,9 +4,6 @@ import { expect } from 'chai'; import { getMetadata } from './metadata'; import sinon, { SinonStub } from 'sinon'; import childProcess from 'child_process'; -import fs from 'fs'; -import os from 'os'; -import path from 'path'; import metadataNextjs from './test-data/metadata-nextjs.json'; import npmQueryResultNext from './test-data/npm-query-nextjs.json'; import npmQueryResultNoSc from './test-data/npm-query-no-sc.json'; @@ -134,17 +131,15 @@ describe('metadata', () => { it('should return tracked packages with exact versions from result of yarn info', () => { runWithPackageManager(USER_AGENTS.yarn); - execSyncStub - .withArgs(YARN_INFO) - .returns( - [ - '{"value":"@sitecore-content-sdk/nextjs@npm:22.2.0-canary.69","children":{}}', - '"@sitecore-content-sdk/core@npm:22.2.0-canary.69"', - '{"value":"@sitecore-content-sdk/react@virtual:1a2b3c#npm:22.2.0-canary.69"}', - '➤ YN0000: a diagnostic that is not json', - '', - ].join('\n') - ); + execSyncStub.withArgs(YARN_INFO).returns( + [ + '{"value":"@sitecore-content-sdk/nextjs@npm:22.2.0-canary.69","children":{}}', + '"@sitecore-content-sdk/core@npm:22.2.0-canary.69"', + '{"value":"@sitecore-content-sdk/react@virtual:1a2b3c#npm:22.2.0-canary.69"}', + '➤ YN0000: a diagnostic that is not json', + '', + ].join('\n') + ); const metadata = getMetadata(); @@ -158,122 +153,14 @@ describe('metadata', () => { }); }); - const writeManifest = (root: string, name: string, manifest: unknown) => { - const manifestDirectory = path.join(root, 'node_modules', name); - - fs.mkdirSync(manifestDirectory, { recursive: true }); - fs.writeFileSync(path.join(manifestDirectory, 'package.json'), JSON.stringify(manifest)); - }; - - const withProject = (manifests: { name: string; manifest: unknown }[], run: () => void) => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), 'metadata-peers-')); - const cwd = process.cwd(); - - manifests.forEach(({ name, manifest }) => writeManifest(root, name, manifest)); - process.chdir(root); - - try { - run(); - } finally { - process.chdir(cwd); - fs.rmSync(root, { recursive: true, force: true }); - } - }; - - const nextjsPeerManifest = { - peerDependencies: { - '@sitecore-content-sdk/personalize': '^2.1.0', - react: '^19.2.1', - typescript: '^5.4.0', - }, - peerDependenciesMeta: { - typescript: { optional: true }, - }, - }; - - it('should include a tracked peer dependency Yarn did not install', () => { - runWithPackageManager(USER_AGENTS.yarnClassic); - execSyncStub.withArgs(YARN_CLASSIC_LIST).returns( - '{"type":"tree","data":{"type":"list","trees":[{"name":"@sitecore-content-sdk/nextjs@2.5.0-canary.1","children":[]}]}}' - ); - const execFileSyncStub = sinon.stub(childProcess, 'execFileSync').returns( - JSON.stringify(['2.0.0', '2.1.0', '2.1.1-canary.1', '3.0.0']) - ); - - withProject([{ name: '@sitecore-content-sdk/nextjs', manifest: nextjsPeerManifest }], () => { - const metadata = getMetadata(); - - expect(execFileSyncStub.calledOnce).to.be.true; - expect(execFileSyncStub.firstCall.args[2].env.METADATA_PACKAGE_NAME).to.equal( - '@sitecore-content-sdk/personalize' - ); - expect(metadata).to.deep.equal({ - packages: { - '@sitecore-content-sdk/nextjs': '2.5.0-canary.1', - '@sitecore-content-sdk/personalize': '2.1.0', - }, - }); - }); - }); - - it('should use the installed version of a tracked peer dependency', () => { + it('should return tracked packages with exact versions from result of yarn classic list', () => { runWithPackageManager(USER_AGENTS.yarnClassic); execSyncStub.withArgs(YARN_CLASSIC_LIST).returns( - '{"type":"tree","data":{"type":"list","trees":[{"name":"@sitecore-content-sdk/nextjs@2.5.0-canary.1","children":[]}]}}' - ); - const execFileSyncStub = sinon.stub(childProcess, 'execFileSync'); - - withProject( [ - { name: '@sitecore-content-sdk/nextjs', manifest: nextjsPeerManifest }, - { - name: '@sitecore-content-sdk/personalize', - manifest: { version: '2.1.0' }, - }, - ], - () => { - const metadata = getMetadata(); - - expect(execFileSyncStub.called).to.be.false; - expect(metadata.packages['@sitecore-content-sdk/personalize']).to.equal('2.1.0'); - } + '{"type":"progressStart","data":{"id":0,"total":5}}', + '{"type":"tree","data":{"type":"list","trees":[{"name":"@sitecore-content-sdk/core@22.2.0-canary.69","children":[],"depth":0},{"name":"@sitecore/byoc@0.2.15","children":[],"depth":0}]}}', + ].join('\n') ); - }); - - it('should keep metadata when a missing peer dependency cannot be resolved', () => { - runWithPackageManager(USER_AGENTS.yarn); - execSyncStub - .withArgs(YARN_INFO) - .returns('"@sitecore-content-sdk/nextjs@npm:2.5.0-canary.1"'); - sinon.stub(childProcess, 'execFileSync').throws(new Error('offline')); - logStub = sinon.stub(console, 'warn'); - - withProject([{ name: '@sitecore-content-sdk/nextjs', manifest: nextjsPeerManifest }], () => { - const metadata = getMetadata(); - - expect( - logStub.calledOnceWith( - "Unable to resolve peer dependency '@sitecore-content-sdk/personalize@^2.1.0' for metadata" - ) - ).to.be.true; - expect(metadata).to.deep.equal({ - packages: { - '@sitecore-content-sdk/nextjs': '2.5.0-canary.1', - }, - }); - }); - }); - - it('should return tracked packages with exact versions from result of yarn classic list', () => { - runWithPackageManager(USER_AGENTS.yarnClassic); - execSyncStub - .withArgs(YARN_CLASSIC_LIST) - .returns( - [ - '{"type":"progressStart","data":{"id":0,"total":5}}', - '{"type":"tree","data":{"type":"list","trees":[{"name":"@sitecore-content-sdk/core@22.2.0-canary.69","children":[],"depth":0},{"name":"@sitecore/byoc@0.2.15","children":[],"depth":0}]}}', - ].join('\n') - ); const metadata = getMetadata(); diff --git a/packages/core/src/tools/metadata/metadata.ts b/packages/core/src/tools/metadata/metadata.ts index ab3e6cef0a..9cf4ccd5de 100644 --- a/packages/core/src/tools/metadata/metadata.ts +++ b/packages/core/src/tools/metadata/metadata.ts @@ -1,6 +1,4 @@ -import { execFileSync, execSync } from 'child_process'; -import fs from 'fs'; -import path from 'path'; +import { execSync } from 'child_process'; type Package = { name: string; @@ -37,9 +35,7 @@ const PACKAGE_ENTRY = /(@[^\s/:\\]+\/[^\s/:\\]+)@([^\s]+)$/; const MAX_OUTPUT_BUFFER = 10 * 1024 * 1024; /** - * Get application metadata. Non-optional tracked peer dependencies are included as well. - * npm installs those peers on its own; Yarn does not, so a peer such as - * `@sitecore-content-sdk/personalize` would otherwise be missing from metadata. + * Get application metadata * @param {boolean} allowWorkspaces - Whether to allow workspaces in the metadata generation. * @returns {Metadata} The generated metadata. */ @@ -62,7 +58,6 @@ export function getMetadata(allowWorkspaces: boolean = false): Metadata { } metadata.packages = getPackagesFromQueryResult(queryResult); - addMissingTrackedPeers(metadata.packages); return metadata; } @@ -76,7 +71,7 @@ function getPackagesFromQueryResult(scPackages: Package[]): Record = {}; scPackages.forEach((scPackage) => { - if (isTrackedPackage(scPackage.name)) { + if (trackedScopes.some((trackedScope) => scPackage.name.startsWith(trackedScope))) { packages[scPackage.name] = scPackage.version; } }); @@ -84,309 +79,6 @@ function getPackagesFromQueryResult(scPackages: Package[]): Record; - peerDependenciesMeta?: Record; -}; - -type Semver = { - major: number; - minor: number; - patch: number; - prerelease: boolean; -}; - -// Prints the published versions of METADATA_PACKAGE_NAME. The name stays in the environment so a -// caret range never has to be escaped for cmd.exe. -const PUBLISHED_VERSIONS_SCRIPT = ` -const name = process.env.METADATA_PACKAGE_NAME || ''; -const registry = (process.env.npm_config_registry || 'https://registry.npmjs.org').replace(/\\/$/, ''); -fetch(registry + '/' + encodeURIComponent(name)) - .then((response) => (response.ok ? response.json() : {})) - .then((doc) => { - const versions = doc && doc.versions ? Object.keys(doc.versions) : []; - process.stdout.write(JSON.stringify(versions)); - }) - .catch(() => process.stdout.write('[]')); -`; - -/** - * Add tracked peer dependencies that the package manager did not list. - * An installed copy wins. Otherwise the highest published version of the peer range is used, - * which is the version npm would have installed. - * @param {Record} packages tracked packages already discovered - */ -function addMissingTrackedPeers(packages: Record): void { - const seen = new Set(); - const pending = Object.keys(packages); - - while (pending.length > 0) { - const name = pending.pop(); - - if (!name || seen.has(name)) { - continue; - } - - seen.add(name); - - const manifest = readInstalledManifest(name); - - if (!manifest) { - continue; - } - - getRequiredTrackedPeers(manifest).forEach((peer) => { - if (packages[peer.name]) { - return; - } - - const version = - readInstalledManifest(peer.name)?.version || resolvePublishedVersion(peer.name, peer.range); - - if (!version) { - console.warn( - `Unable to resolve peer dependency '${peer.name}@${peer.range}' for metadata` - ); - return; - } - - packages[peer.name] = version; - pending.push(peer.name); - }); - } -} - -/** - * Non-optional peer dependencies that belong to a tracked scope - * @param {PackageManifest} manifest package manifest - * @returns {{ name: string; range: string }[]} peers metadata should record - */ -function getRequiredTrackedPeers(manifest: PackageManifest): { name: string; range: string }[] { - const peers = manifest.peerDependencies ?? {}; - const meta = manifest.peerDependenciesMeta ?? {}; - - return Object.entries(peers).flatMap(([name, range]) => { - const optional = meta[name]?.optional === true; - - return isTrackedPackage(name) && !optional && range ? [{ name, range }] : []; - }); -} - -/** - * Read an installed package manifest from the project node_modules. - * Resolution stays at the project root so a workspace checkout of the SDK is not picked up - * while tests run from a package directory. - * @param {string} name package name - * @returns {PackageManifest | undefined} the manifest, when it is installed - */ -function readInstalledManifest(name: string): PackageManifest | undefined { - const manifestPath = path.join(process.cwd(), 'node_modules', name, 'package.json'); - - try { - if (!fs.existsSync(manifestPath)) { - return undefined; - } - - const parsed: unknown = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); - - return isPackageManifest(parsed) ? parsed : undefined; - } catch { - return undefined; - } -} - -/** - * Highest published version that satisfies a caret or exact peer range - * @param {string} name package name - * @param {string} range peer dependency range - * @returns {string | undefined} the resolved version - */ -function resolvePublishedVersion(name: string, range: string): string | undefined { - if (!/^\^?\d+\.\d+\.\d+$/.test(range.trim())) { - return undefined; - } - - return maxSatisfying(fetchPublishedVersions(name), range); -} - -/** - * Published versions of a package from the npm registry - * @param {string} name package name - * @returns {string[]} versions, empty when the registry could not be read - */ -function fetchPublishedVersions(name: string): string[] { - try { - const output = execFileSync(process.execPath, ['-e', PUBLISHED_VERSIONS_SCRIPT], { - encoding: 'utf8', - env: { ...process.env, METADATA_PACKAGE_NAME: name }, - maxBuffer: MAX_OUTPUT_BUFFER, - timeout: 20000, - stdio: ['ignore', 'pipe', 'pipe'], - }); - const parsed: unknown = JSON.parse(output); - - return Array.isArray(parsed) ? parsed.filter((version) => typeof version === 'string') : []; - } catch { - return []; - } -} - -/** - * @param {string[]} versions published versions - * @param {string} range caret or exact version range - * @returns {string | undefined} the highest matching version - */ -function maxSatisfying(versions: string[], range: string): string | undefined { - return versions.reduce((best, version) => { - if (!satisfiesRange(version, range)) { - return best; - } - - return !best || compareVersions(version, best) > 0 ? version : best; - }, undefined); -} - -/** - * Caret and exact ranges cover the peer ranges the SDK packages declare. - * Prereleases are excluded, so `^2.1.0` stays on releases. - * @param {string} version published version - * @param {string} range peer range - * @returns {boolean} whether the version satisfies the range - */ -function satisfiesRange(version: string, range: string): boolean { - const parsedVersion = parseVersion(version); - const trimmed = range.trim(); - const caret = /^\^(\d+)\.(\d+)\.(\d+)$/.exec(trimmed); - const exact = /^(\d+)\.(\d+)\.(\d+)$/.exec(trimmed); - - if (!parsedVersion || parsedVersion.prerelease) { - return false; - } - - if (exact) { - return compareVersions(version, `${exact[1]}.${exact[2]}.${exact[3]}`) === 0; - } - - if (!caret) { - return false; - } - - const lowerBound = `${caret[1]}.${caret[2]}.${caret[3]}`; - const major = Number(caret[1]); - const minor = Number(caret[2]); - - if (compareVersions(version, lowerBound) < 0) { - return false; - } - - if (major > 0) { - return parsedVersion.major === major; - } - - if (minor > 0) { - return parsedVersion.major === 0 && parsedVersion.minor === minor; - } - - return ( - parsedVersion.major === 0 && - parsedVersion.minor === 0 && - parsedVersion.patch === Number(caret[3]) - ); -} - -/** - * @param {string} version version string - * @returns {Semver | undefined} parsed version - */ -function parseVersion(version: string): Semver | undefined { - const match = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+.*)?$/.exec(version); - - if (!match) { - return undefined; - } - - return { - major: Number(match[1]), - minor: Number(match[2]), - patch: Number(match[3]), - prerelease: Boolean(match[4]), - }; -} - -/** - * @param {string} left version - * @param {string} right version - * @returns {number} negative when left is older - */ -function compareVersions(left: string, right: string): number { - const a = parseVersion(left); - const b = parseVersion(right); - - if (!a || !b) { - return 0; - } - - if (a.major !== b.major) { - return a.major - b.major; - } - - if (a.minor !== b.minor) { - return a.minor - b.minor; - } - - return a.patch - b.patch; -} - -/** - * @param {string} name package name - * @returns {boolean} whether the package belongs to a tracked scope - */ -function isTrackedPackage(name: string): boolean { - return trackedScopes.some((trackedScope) => name.startsWith(trackedScope)); -} - -/** - * @param {unknown} value parsed package.json - * @returns {value is PackageManifest} whether the value can be read as a manifest - */ -function isPackageManifest(value: unknown): value is PackageManifest { - if (!isRecord(value)) { - return false; - } - - const peers = value.peerDependencies; - const meta = value.peerDependenciesMeta; - - return ( - (value.version === undefined || typeof value.version === 'string') && - (peers === undefined || isStringRecord(peers)) && - (meta === undefined || isPeerMeta(meta)) - ); -} - -/** - * @param {unknown} value value to check - * @returns {value is Record} whether every property is a string - */ -function isStringRecord(value: unknown): value is Record { - return isRecord(value) && Object.values(value).every((entry) => typeof entry === 'string'); -} - -/** - * @param {unknown} value peerDependenciesMeta - * @returns {value is Record} whether the meta is usable - */ -function isPeerMeta(value: unknown): value is Record { - return ( - isRecord(value) && - Object.values(value).every( - (entry) => - isRecord(entry) && (entry.optional === undefined || typeof entry.optional === 'boolean') - ) - ); -} - /** * Build the listing command and parser for each supported package manager. * Yarn classic is a dedicated entry because its command differs from yarn berry. @@ -481,7 +173,8 @@ function parseNpmQuery(output: string): Package[] { return Array.isArray(parsed) ? parsed.filter( - (scPackage) => typeof scPackage?.name === 'string' && typeof scPackage?.version === 'string' + (scPackage) => + typeof scPackage?.name === 'string' && typeof scPackage?.version === 'string' ) : []; } diff --git a/packages/create-content-sdk-app/src/templates/nextjs/package.json b/packages/create-content-sdk-app/src/templates/nextjs/package.json index 4bb81df427..3c3acb1855 100644 --- a/packages/create-content-sdk-app/src/templates/nextjs/package.json +++ b/packages/create-content-sdk-app/src/templates/nextjs/package.json @@ -17,6 +17,7 @@ "license": "Apache-2.0", "dependencies": { "@sitecore-content-sdk/analytics-core": "<%- versions['@sitecore-content-sdk/analytics-core'] %>", + "@sitecore-content-sdk/personalize": "<%- versions['@sitecore-content-sdk/personalize'] %>", "@sitecore-content-sdk/events": "<%- versions['@sitecore-content-sdk/events'] %>", "@sitecore-content-sdk/nextjs": "<%- versions['@sitecore-content-sdk/nextjs'] %>", "@sitecore-feaas/clientside": "^0.6.0",