From ba8fefa882db1fe01534406bbfb991911e2d9e73 Mon Sep 17 00:00:00 2001 From: Jeremy Zahner Date: Wed, 22 Apr 2026 12:11:16 +0200 Subject: [PATCH 1/2] Improves deployment logic for handling alternate dependency protocols. Signed-off-by: Jeremy Zahner --- .changeset/resolve-workspace-protocols.md | 14 + packages/cli/src/commands/deploy.ts | 91 ++++- .../src/utils/compiler/compilePlatformApp.ts | 9 +- packages/cli/src/utils/getPackageVersion.ts | 2 +- packages/cli/src/utils/logger.ts | 4 + packages/cli/tests/commands/deploy.spec.ts | 335 +++++++++++++++++- 6 files changed, 429 insertions(+), 26 deletions(-) create mode 100644 .changeset/resolve-workspace-protocols.md diff --git a/.changeset/resolve-workspace-protocols.md b/.changeset/resolve-workspace-protocols.md new file mode 100644 index 000000000..593a2558b --- /dev/null +++ b/.changeset/resolve-workspace-protocols.md @@ -0,0 +1,14 @@ +--- +"@frontify/frontify-cli": patch +--- + +fix(cli): resolve workspace protocol specifiers during deployment + +The deploy command now resolves `catalog:`, `workspace:`, `link:`, `file:`, and other protocol specifiers to their actual installed versions before uploading to the Frontify Marketplace API. Previously, these raw specifiers were sent as-is, causing deployment failures in pnpm workspace setups. + +Key changes: + +- Dependencies are resolved via `node_modules` lookup during `collectFiles`. Unresolvable protocol specifiers are omitted from the payload with a warning. +- The `package.json` included in `source_files` is sanitized with resolved versions before upload. +- The platform app compiler now guards against protocol specifiers being injected into compiled JavaScript output. +- A warning is emitted when potentially sensitive files (`.env`, `.npmrc`, `.netrc`) are detected in the source upload. diff --git a/packages/cli/src/commands/deploy.ts b/packages/cli/src/commands/deploy.ts index 6db5c70ea..278e7b7b3 100644 --- a/packages/cli/src/commands/deploy.ts +++ b/packages/cli/src/commands/deploy.ts @@ -7,6 +7,7 @@ import open from 'open'; import pc from 'picocolors'; import { type HttpClientError } from '../errors/HttpClientError'; +import { getInstalledPackageVersion } from '../utils/getPackageVersion'; import { type CompilerOptions, Configuration, @@ -61,6 +62,55 @@ const SOURCE_FILE_BLOCK_LIST = [ '**/*.graphql', ]; +const SENSITIVE_FILE_PATTERNS = ['.env', '.npmrc', '.netrc']; + +const PROTOCOL_PREFIXES = ['catalog:', 'workspace:', 'link:', 'file:', 'portal:']; + +const isProtocolSpecifier = (specifier: string): boolean => { + return PROTOCOL_PREFIXES.some((prefix) => specifier.startsWith(prefix)); +}; + +export const resolveDependencyVersions = ( + dependencies: Record, + projectPath: string, +): Record => { + const resolved: Record = {}; + + for (const [name, specifier] of Object.entries(dependencies)) { + const installedVersion = getInstalledPackageVersion(projectPath, name); + + if (installedVersion && !isProtocolSpecifier(installedVersion)) { + resolved[name] = installedVersion; + continue; + } + + if (isProtocolSpecifier(specifier)) { + Logger.warn( + `Could not resolve version for "${name}" (specifier: "${specifier}"). ` + + 'The package may not be installed. Omitting from deployment dependencies.', + ); + continue; + } + + resolved[name] = specifier; + } + + return resolved; +}; + +export const warnAboutSensitiveFiles = (sourceFiles: Record): void => { + const sensitiveFiles = Object.keys(sourceFiles).filter((filePath) => + SENSITIVE_FILE_PATTERNS.some((pattern) => filePath.split('/').some((segment) => segment.startsWith(pattern))), + ); + + if (sensitiveFiles.length > 0) { + Logger.warn( + `Potentially sensitive files detected in source files:\n${sensitiveFiles.map((f) => ` - ${f}`).join('\n')}\n` + + 'Consider adding these to your .gitignore to prevent them from being uploaded.', + ); + } +}; + export const resolveCredentials = (token?: string, instance?: string) => { const instanceUrl = instance || Configuration.get('instanceUrl'); const accessToken = token || Configuration.get('tokens.access_token'); @@ -108,17 +158,42 @@ export const collectFiles = async (projectPath: string, distPath: string) => { return fastGlob.convertPathToPattern(`${projectPath}/${path}`); }); - const packageJsonContent = reactiveJson<{ dependencies?: Record }>( - join(projectPath, 'package.json'), + const packageJsonContent = reactiveJson<{ + dependencies?: Record; + devDependencies?: Record; + peerDependencies?: Record; + }>(join(projectPath, 'package.json')); + + const resolvedDependencies = resolveDependencyVersions(packageJsonContent?.dependencies || {}, projectPath); + + const sourceFiles = await makeFilesDict(fastGlob.convertPathToPattern(projectPath), sourceFilesToIgnore); + + // Sanitize the package.json in source_files to contain resolved versions + // instead of protocol specifiers that are invalid outside the workspace. + // Note: packageJsonContent is a reactiveJson Proxy. We only READ from it here + // via spread and property access. Do not mutate it directly — the Proxy's set + // trap would write changes back to disk. + if (sourceFiles['/package.json'] && packageJsonContent) { + const sanitized = { + ...packageJsonContent, + dependencies: resolvedDependencies, + devDependencies: resolveDependencyVersions(packageJsonContent.devDependencies || {}, projectPath), + peerDependencies: resolveDependencyVersions(packageJsonContent.peerDependencies || {}, projectPath), + }; + sourceFiles['/package.json'] = Buffer.from(JSON.stringify(sanitized, null, '\t')).toString('base64'); + } + + warnAboutSensitiveFiles(sourceFiles); + + const buildFiles = await makeFilesDict( + fastGlob.convertPathToPattern(`${projectPath}/${distPath}`), + buildFilesToIgnore, ); return { - build_files: await makeFilesDict( - fastGlob.convertPathToPattern(`${projectPath}/${distPath}`), - buildFilesToIgnore, - ), - source_files: await makeFilesDict(fastGlob.convertPathToPattern(projectPath), sourceFilesToIgnore), - dependencies: packageJsonContent?.dependencies || {}, + build_files: buildFiles, + source_files: sourceFiles, + dependencies: resolvedDependencies, }; }; diff --git a/packages/cli/src/utils/compiler/compilePlatformApp.ts b/packages/cli/src/utils/compiler/compilePlatformApp.ts index 23cd8f704..375bae440 100644 --- a/packages/cli/src/utils/compiler/compilePlatformApp.ts +++ b/packages/cli/src/utils/compiler/compilePlatformApp.ts @@ -8,8 +8,15 @@ import { getAppBridgeVersion } from '../getPackageVersion'; import { type CompilerOptions } from './compilerOptions'; +const PROTOCOL_PREFIXES = ['catalog:', 'workspace:', 'link:', 'file:', 'portal:']; + +const isValidVersion = (version: string | undefined): version is string => { + return version !== undefined && !PROTOCOL_PREFIXES.some((prefix) => version.startsWith(prefix)); +}; + export const compilePlatformApp = async ({ outputName, entryFile, projectPath = '' }: CompilerOptions) => { const appBridgeVersion = getAppBridgeVersion(projectPath); + const safeAppBridgeVersion = isValidVersion(appBridgeVersion) ? appBridgeVersion : undefined; const settings = await build({ plugins: [ @@ -45,7 +52,7 @@ export const compilePlatformApp = async ({ outputName, entryFile, projectPath = footer: ` window.${outputName} = ${outputName}; window.${outputName}.dependencies = window.${outputName}.packages || {}; - ${appBridgeVersion ? `window.${outputName}.dependencies['@frontify/app-bridge-app'] = '${appBridgeVersion}';` : ''} + ${safeAppBridgeVersion ? `window.${outputName}.dependencies['@frontify/app-bridge-app'] = '${safeAppBridgeVersion}';` : ''} `, }, }, diff --git a/packages/cli/src/utils/getPackageVersion.ts b/packages/cli/src/utils/getPackageVersion.ts index 123262e4b..69597f96f 100644 --- a/packages/cli/src/utils/getPackageVersion.ts +++ b/packages/cli/src/utils/getPackageVersion.ts @@ -4,7 +4,7 @@ import { readFileSync } from 'node:fs'; import { findPackageJSON } from 'node:module'; import { join } from 'node:path'; -const getInstalledPackageVersion = (rootPath: string, packageName: string): string | undefined => { +export const getInstalledPackageVersion = (rootPath: string, packageName: string): string | undefined => { try { const pkgJsonPath = findPackageJSON(packageName, join(rootPath, 'package.json')); diff --git a/packages/cli/src/utils/logger.ts b/packages/cli/src/utils/logger.ts index e3d678338..7272845f2 100644 --- a/packages/cli/src/utils/logger.ts +++ b/packages/cli/src/utils/logger.ts @@ -21,6 +21,10 @@ export class Logger { console.error(pc.red(`[${getCurrentTime()}] ${messages.join(' ')}`)); } + static warn(...messages: string[]): void { + console.warn(pc.yellow(`[${getCurrentTime()}] ${messages.join(' ')}`)); + } + static spacer(width = 1): string { return ' '.repeat(width); } diff --git a/packages/cli/tests/commands/deploy.spec.ts b/packages/cli/tests/commands/deploy.spec.ts index 5b0d6a469..10a45bb98 100644 --- a/packages/cli/tests/commands/deploy.spec.ts +++ b/packages/cli/tests/commands/deploy.spec.ts @@ -4,34 +4,58 @@ import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, test, vi, type MockInstance } from 'vitest'; import { Configuration } from '../../src/utils/configuration'; +import { Logger } from '../../src/utils/logger'; +import type * as GetPackageVersionModule from '../../src/utils/getPackageVersion'; import type * as UtilsModule from '../../src/utils/index'; const promiseExecMock = vi.fn().mockResolvedValue(''); +const getInstalledPackageVersionMock = vi.fn(); vi.mock('../../src/utils/index', async (importOriginal) => { const original = await importOriginal(); return { ...original, promiseExec: promiseExecMock }; }); -const { collectFiles, handleDeployError, resolveCredentials, verifyCode } = await import('../../src/commands/deploy'); +vi.mock('../../src/utils/getPackageVersion', async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + getInstalledPackageVersion: getInstalledPackageVersionMock, + }; +}); + +const { + collectFiles, + handleDeployError, + resolveCredentials, + resolveDependencyVersions, + verifyCode, + warnAboutSensitiveFiles, +} = await import('../../src/commands/deploy'); describe('Deploy command helpers', () => { describe('resolveCredentials', () => { let oldTokens: unknown; let oldInstanceUrl: unknown; + let exitSpy: MockInstance; + let consoleErrorSpy: MockInstance; beforeEach(() => { oldTokens = Configuration.get('tokens') || {}; oldInstanceUrl = Configuration.get('instanceUrl') || ''; + exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); }); afterEach(() => { Configuration.set('tokens', oldTokens); Configuration.set('instanceUrl', oldInstanceUrl); + exitSpy.mockRestore(); + consoleErrorSpy.mockRestore(); }); test('should return token and instance when passed directly', () => { @@ -68,26 +92,16 @@ describe('Deploy command helpers', () => { Configuration.delete('tokens'); Configuration.set('instanceUrl', 'some.frontify.com'); - const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); - console.error = vi.fn(); - resolveCredentials(); expect(exitSpy).toHaveBeenCalledWith(-1); - - exitSpy.mockRestore(); }); test('should exit when no instance URL is available', () => { Configuration.set('tokens', { access_token: 'some-token' }); Configuration.delete('instanceUrl'); - const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); - console.error = vi.fn(); - resolveCredentials(); expect(exitSpy).toHaveBeenCalledWith(-1); - - exitSpy.mockRestore(); }); }); @@ -113,6 +127,215 @@ describe('Deploy command helpers', () => { }); }); + describe('resolveDependencyVersions', () => { + let warnSpy: MockInstance; + + beforeEach(() => { + getInstalledPackageVersionMock.mockReset(); + warnSpy = vi.spyOn(Logger, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + warnSpy.mockRestore(); + }); + + test('should resolve catalog: specifier to installed version', () => { + getInstalledPackageVersionMock.mockReturnValue('18.2.0'); + + const result = resolveDependencyVersions({ react: 'catalog:' }, '/project'); + + expect(result).toEqual({ react: '18.2.0' }); + expect(getInstalledPackageVersionMock).toHaveBeenCalledWith('/project', 'react'); + }); + + test('should resolve catalog:name specifier to installed version', () => { + getInstalledPackageVersionMock.mockReturnValue('19.1.0'); + + const result = resolveDependencyVersions({ react: 'catalog:react19' }, '/project'); + + expect(result).toEqual({ react: '19.1.0' }); + }); + + test('should resolve workspace:* specifier to installed version', () => { + getInstalledPackageVersionMock.mockReturnValue('1.0.0'); + + const result = resolveDependencyVersions({ '@acme/shared': 'workspace:*' }, '/project'); + + expect(result).toEqual({ '@acme/shared': '1.0.0' }); + }); + + test('should resolve workspace:^ specifier to installed version', () => { + getInstalledPackageVersionMock.mockReturnValue('2.3.4'); + + const result = resolveDependencyVersions({ '@acme/utils': 'workspace:^' }, '/project'); + + expect(result).toEqual({ '@acme/utils': '2.3.4' }); + }); + + test('should omit catalog: specifier when package is not installed', () => { + getInstalledPackageVersionMock.mockReturnValue(undefined); + + const result = resolveDependencyVersions({ react: 'catalog:' }, '/project'); + + expect(result).toEqual({}); + }); + + test('should omit workspace: specifier when package is not installed', () => { + getInstalledPackageVersionMock.mockReturnValue(undefined); + + const result = resolveDependencyVersions({ '@acme/shared': 'workspace:*' }, '/project'); + + expect(result).toEqual({}); + }); + + test('should warn when omitting unresolvable protocol specifier', () => { + getInstalledPackageVersionMock.mockReturnValue(undefined); + + resolveDependencyVersions({ react: 'catalog:' }, '/project'); + + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Could not resolve version for "react"')); + }); + + test('should use raw specifier for normal semver ranges when not installed', () => { + getInstalledPackageVersionMock.mockReturnValue(undefined); + + const result = resolveDependencyVersions({ react: '^18.0.0' }, '/project'); + + expect(result).toEqual({ react: '^18.0.0' }); + }); + + test('should prefer installed version over raw specifier', () => { + getInstalledPackageVersionMock.mockReturnValue('18.2.0'); + + const result = resolveDependencyVersions({ react: '^18.0.0' }, '/project'); + + expect(result).toEqual({ react: '18.2.0' }); + }); + + test('should handle mixed dependencies correctly', () => { + getInstalledPackageVersionMock.mockImplementation((_root: string, name: string) => { + if (name === 'react') { + return '18.2.0'; + } + if (name === '@acme/shared') { + return '1.5.0'; + } + return undefined; + }); + + const result = resolveDependencyVersions( + { + react: 'catalog:', + '@acme/shared': 'workspace:*', + lodash: '^4.17.21', + '@acme/missing': 'workspace:^', + }, + '/project', + ); + + expect(result).toEqual({ + react: '18.2.0', + '@acme/shared': '1.5.0', + lodash: '^4.17.21', + }); + }); + + test('should handle link: specifier like workspace:', () => { + getInstalledPackageVersionMock.mockReturnValue(undefined); + + const result = resolveDependencyVersions({ mylib: 'link:../mylib' }, '/project'); + + expect(result).toEqual({}); + }); + + test('should handle file: specifier like workspace:', () => { + getInstalledPackageVersionMock.mockReturnValue(undefined); + + const result = resolveDependencyVersions({ mylib: 'file:../mylib' }, '/project'); + + expect(result).toEqual({}); + }); + + test('should handle empty dependencies', () => { + const result = resolveDependencyVersions({}, '/project'); + + expect(result).toEqual({}); + }); + + test('should omit unknown protocol specifiers (portal:)', () => { + getInstalledPackageVersionMock.mockReturnValue(undefined); + + const result = resolveDependencyVersions({ mylib: 'portal:../mylib' }, '/project'); + + expect(result).toEqual({}); + }); + + test('should discard installed version when it is itself a protocol specifier', () => { + getInstalledPackageVersionMock.mockReturnValue('catalog:'); + + const result = resolveDependencyVersions({ react: 'catalog:' }, '/project'); + + expect(result).toEqual({}); + }); + }); + + describe('warnAboutSensitiveFiles', () => { + let warnSpy: MockInstance; + + beforeEach(() => { + warnSpy = vi.spyOn(Logger, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + warnSpy.mockRestore(); + }); + + test('should warn about .env files', () => { + warnAboutSensitiveFiles({ + '/src/index.ts': 'base64', + '/.env': 'base64', + }); + + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('.env')); + }); + + test('should warn about .npmrc files', () => { + warnAboutSensitiveFiles({ + '/src/index.ts': 'base64', + '/.npmrc': 'base64', + }); + + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('.npmrc')); + }); + + test('should warn about .env.local files', () => { + warnAboutSensitiveFiles({ + '/src/index.ts': 'base64', + '/.env.local': 'base64', + }); + + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('.env.local')); + }); + + test('should warn about .netrc files', () => { + warnAboutSensitiveFiles({ + '/src/index.ts': 'base64', + '/.netrc': 'base64', + }); + + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('.netrc')); + }); + + test('should not warn when no sensitive files detected', () => { + warnAboutSensitiveFiles({ + '/src/index.ts': 'base64', + '/package.json': 'base64', + }); + + expect(warnSpy).not.toHaveBeenCalled(); + }); + }); + describe('collectFiles', () => { let tempDir: string; @@ -127,6 +350,8 @@ describe('Deploy command helpers', () => { writeFileSync(join(tempDir, 'dist', 'index.js'), 'console.log("built")'); writeFileSync(join(tempDir, 'dist', 'index.js.map'), '{"mappings":""}'); writeFileSync(join(tempDir, 'src', 'index.ts'), 'console.log("source")'); + + getInstalledPackageVersionMock.mockReset(); }); afterEach(() => { @@ -134,16 +359,40 @@ describe('Deploy command helpers', () => { }); test('should collect build files excluding source maps', async () => { + getInstalledPackageVersionMock.mockReturnValue('18.2.0'); + const result = await collectFiles(tempDir, 'dist'); expect(result.build_files).toHaveProperty('/index.js'); expect(result.build_files).not.toHaveProperty('/index.js.map'); }); - test('should collect dependencies from package.json', async () => { + test('should resolve dependencies to installed versions', async () => { + getInstalledPackageVersionMock.mockReturnValue('18.2.0'); + + const result = await collectFiles(tempDir, 'dist'); + + expect(result.dependencies).toEqual({ react: '18.2.0' }); + }); + + test('should resolve catalog: dependencies in package.json', async () => { + writeFileSync( + join(tempDir, 'package.json'), + JSON.stringify({ dependencies: { react: 'catalog:', lodash: '^4.17.21' } }), + ); + getInstalledPackageVersionMock.mockImplementation((_root: string, name: string) => { + if (name === 'react') { + return '18.2.0'; + } + if (name === 'lodash') { + return '4.17.21'; + } + return undefined; + }); + const result = await collectFiles(tempDir, 'dist'); - expect(result.dependencies).toEqual({ react: '18.0.0' }); + expect(result.dependencies).toEqual({ react: '18.2.0', lodash: '4.17.21' }); }); test('should return empty dependencies when package.json has none', async () => { @@ -163,18 +412,72 @@ describe('Deploy command helpers', () => { const sourceFilePaths = Object.keys(result.source_files); expect(sourceFilePaths.some((p) => p.includes('node_modules'))).toBe(false); }); + + test('should sanitize package.json in source_files with resolved versions', async () => { + writeFileSync( + join(tempDir, 'package.json'), + JSON.stringify({ name: 'my-app', dependencies: { react: 'catalog:' } }), + ); + getInstalledPackageVersionMock.mockReturnValue('18.2.0'); + + const result = await collectFiles(tempDir, 'dist'); + + const pkgJsonBase64: string = result.source_files['/package.json'] ?? ''; + const decoded = JSON.parse(Buffer.from(pkgJsonBase64, 'base64').toString('utf8')) as { + name: string; + dependencies: Record; + }; + expect(decoded.dependencies).toEqual({ react: '18.2.0' }); + expect(decoded.name).toEqual('my-app'); + }); + + test('should sanitize devDependencies and peerDependencies in source_files', async () => { + writeFileSync( + join(tempDir, 'package.json'), + JSON.stringify({ + name: 'my-app', + dependencies: { react: '^18.0.0' }, + devDependencies: { typescript: 'catalog:' }, + peerDependencies: { '@frontify/app-bridge': 'workspace:*' }, + }), + ); + getInstalledPackageVersionMock.mockImplementation((_root: string, name: string) => { + if (name === 'react') { + return '18.2.0'; + } + if (name === 'typescript') { + return '5.4.5'; + } + if (name === '@frontify/app-bridge') { + return '3.0.0'; + } + return undefined; + }); + + const result = await collectFiles(tempDir, 'dist'); + + const pkgJsonBase64: string = result.source_files['/package.json'] ?? ''; + const decoded = JSON.parse(Buffer.from(pkgJsonBase64, 'base64').toString('utf8')) as { + devDependencies: Record; + peerDependencies: Record; + }; + expect(decoded.devDependencies).toEqual({ typescript: '5.4.5' }); + expect(decoded.peerDependencies).toEqual({ '@frontify/app-bridge': '3.0.0' }); + }); }); describe('handleDeployError', () => { - let exitSpy: ReturnType; + let exitSpy: MockInstance; + let consoleErrorSpy: MockInstance; beforeEach(() => { exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); - console.error = vi.fn(); + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); }); afterEach(() => { exitSpy.mockRestore(); + consoleErrorSpy.mockRestore(); }); test('should log string errors and exit', () => { From 774f8da96f713b6aff8501d94c53eba2e9f98b42 Mon Sep 17 00:00:00 2001 From: Jeremy Zahner Date: Fri, 24 Apr 2026 15:59:33 +0200 Subject: [PATCH 2/2] Makes coupling for makeFilesDict more strict and "dries" protocol const. --- packages/cli/src/commands/deploy.ts | 23 +++++++++++-------- .../src/utils/compiler/compilePlatformApp.ts | 5 ++-- packages/cli/src/utils/index.ts | 1 + packages/cli/src/utils/packageProtocols.ts | 13 +++++++++++ 4 files changed, 29 insertions(+), 13 deletions(-) create mode 100644 packages/cli/src/utils/packageProtocols.ts diff --git a/packages/cli/src/commands/deploy.ts b/packages/cli/src/commands/deploy.ts index 278e7b7b3..9c6ca3ccd 100644 --- a/packages/cli/src/commands/deploy.ts +++ b/packages/cli/src/commands/deploy.ts @@ -19,6 +19,7 @@ import { readFileAsBase64, readFileLinesAsArray, } from '../utils/index'; +import { isPackageProtocolSpecifier } from '../utils/packageProtocols'; import { platformAppManifestSchemaV1, verifyManifest } from '../utils/verifyManifest'; type Options = { @@ -64,12 +65,6 @@ const SOURCE_FILE_BLOCK_LIST = [ const SENSITIVE_FILE_PATTERNS = ['.env', '.npmrc', '.netrc']; -const PROTOCOL_PREFIXES = ['catalog:', 'workspace:', 'link:', 'file:', 'portal:']; - -const isProtocolSpecifier = (specifier: string): boolean => { - return PROTOCOL_PREFIXES.some((prefix) => specifier.startsWith(prefix)); -}; - export const resolveDependencyVersions = ( dependencies: Record, projectPath: string, @@ -79,12 +74,12 @@ export const resolveDependencyVersions = ( for (const [name, specifier] of Object.entries(dependencies)) { const installedVersion = getInstalledPackageVersion(projectPath, name); - if (installedVersion && !isProtocolSpecifier(installedVersion)) { + if (installedVersion && !isPackageProtocolSpecifier(installedVersion)) { resolved[name] = installedVersion; continue; } - if (isProtocolSpecifier(specifier)) { + if (isPackageProtocolSpecifier(specifier)) { Logger.warn( `Could not resolve version for "${name}" (specifier: "${specifier}"). ` + 'The package may not be installed. Omitting from deployment dependencies.', @@ -173,14 +168,22 @@ export const collectFiles = async (projectPath: string, distPath: string) => { // Note: packageJsonContent is a reactiveJson Proxy. We only READ from it here // via spread and property access. Do not mutate it directly — the Proxy's set // trap would write changes back to disk. - if (sourceFiles['/package.json'] && packageJsonContent) { + const pkgJsonKey = '/package.json'; + if (packageJsonContent) { + if (!(pkgJsonKey in sourceFiles)) { + Logger.error( + `Expected "${pkgJsonKey}" in sourceFiles but key was not found. ` + + 'This likely indicates a change in makeFilesDict key format.', + ); + process.exit(-1); + } const sanitized = { ...packageJsonContent, dependencies: resolvedDependencies, devDependencies: resolveDependencyVersions(packageJsonContent.devDependencies || {}, projectPath), peerDependencies: resolveDependencyVersions(packageJsonContent.peerDependencies || {}, projectPath), }; - sourceFiles['/package.json'] = Buffer.from(JSON.stringify(sanitized, null, '\t')).toString('base64'); + sourceFiles[pkgJsonKey] = Buffer.from(JSON.stringify(sanitized, null, '\t')).toString('base64'); } warnAboutSensitiveFiles(sourceFiles); diff --git a/packages/cli/src/utils/compiler/compilePlatformApp.ts b/packages/cli/src/utils/compiler/compilePlatformApp.ts index 375bae440..80dfeb38b 100644 --- a/packages/cli/src/utils/compiler/compilePlatformApp.ts +++ b/packages/cli/src/utils/compiler/compilePlatformApp.ts @@ -5,13 +5,12 @@ import { build } from 'vite'; import { viteExternalsPlugin } from 'vite-plugin-externals'; import { getAppBridgeVersion } from '../getPackageVersion'; +import { isPackageProtocolSpecifier } from '../packageProtocols'; import { type CompilerOptions } from './compilerOptions'; -const PROTOCOL_PREFIXES = ['catalog:', 'workspace:', 'link:', 'file:', 'portal:']; - const isValidVersion = (version: string | undefined): version is string => { - return version !== undefined && !PROTOCOL_PREFIXES.some((prefix) => version.startsWith(prefix)); + return version !== undefined && !isPackageProtocolSpecifier(version); }; export const compilePlatformApp = async ({ outputName, entryFile, projectPath = '' }: CompilerOptions) => { diff --git a/packages/cli/src/utils/index.ts b/packages/cli/src/utils/index.ts index 31fc8f62a..2343be569 100644 --- a/packages/cli/src/utils/index.ts +++ b/packages/cli/src/utils/index.ts @@ -11,6 +11,7 @@ export * from './logger'; export * from './logo'; export * from './npm'; export * from './promiseExec'; +export * from './packageProtocols'; export * from './reactiveJson'; export * from './url'; export * from './user'; diff --git a/packages/cli/src/utils/packageProtocols.ts b/packages/cli/src/utils/packageProtocols.ts new file mode 100644 index 000000000..b7b0c1d07 --- /dev/null +++ b/packages/cli/src/utils/packageProtocols.ts @@ -0,0 +1,13 @@ +/* (c) Copyright Frontify Ltd., all rights reserved. */ + +/** + * Package manager protocol prefixes used in dependency specifiers + * (e.g. `workspace:*`, `catalog:react`, `link:../foo`). + * + * These are not valid semver ranges and must be resolved before deployment. + */ +export const PACKAGE_PROTOCOL_PREFIXES = ['catalog:', 'workspace:', 'link:', 'file:', 'portal:'] as const; + +export const isPackageProtocolSpecifier = (specifier: string): boolean => { + return PACKAGE_PROTOCOL_PREFIXES.some((prefix) => specifier.startsWith(prefix)); +};