Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changeset/resolve-workspace-protocols.md
Original file line number Diff line number Diff line change
@@ -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.
94 changes: 86 additions & 8 deletions packages/cli/src/commands/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -18,6 +19,7 @@ import {
readFileAsBase64,
readFileLinesAsArray,
} from '../utils/index';
import { isPackageProtocolSpecifier } from '../utils/packageProtocols';
import { platformAppManifestSchemaV1, verifyManifest } from '../utils/verifyManifest';

type Options = {
Expand Down Expand Up @@ -61,6 +63,49 @@ const SOURCE_FILE_BLOCK_LIST = [
'**/*.graphql',
];

const SENSITIVE_FILE_PATTERNS = ['.env', '.npmrc', '.netrc'];

export const resolveDependencyVersions = (
dependencies: Record<string, string>,
projectPath: string,
): Record<string, string> => {
const resolved: Record<string, string> = {};

for (const [name, specifier] of Object.entries(dependencies)) {
const installedVersion = getInstalledPackageVersion(projectPath, name);

if (installedVersion && !isPackageProtocolSpecifier(installedVersion)) {
resolved[name] = installedVersion;
continue;
}

if (isPackageProtocolSpecifier(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<string, string>): 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');
Expand Down Expand Up @@ -108,17 +153,50 @@ export const collectFiles = async (projectPath: string, distPath: string) => {
return fastGlob.convertPathToPattern(`${projectPath}/${path}`);
});

const packageJsonContent = reactiveJson<{ dependencies?: Record<string, string> }>(
join(projectPath, 'package.json'),
const packageJsonContent = reactiveJson<{
dependencies?: Record<string, string>;
devDependencies?: Record<string, string>;
peerDependencies?: Record<string, string>;
}>(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.
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[pkgJsonKey] = 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,
};
};

Expand Down
8 changes: 7 additions & 1 deletion packages/cli/src/utils/compiler/compilePlatformApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,17 @@ import { build } from 'vite';
import { viteExternalsPlugin } from 'vite-plugin-externals';

import { getAppBridgeVersion } from '../getPackageVersion';
import { isPackageProtocolSpecifier } from '../packageProtocols';

import { type CompilerOptions } from './compilerOptions';

const isValidVersion = (version: string | undefined): version is string => {
return version !== undefined && !isPackageProtocolSpecifier(version);
};

export const compilePlatformApp = async ({ outputName, entryFile, projectPath = '' }: CompilerOptions) => {
const appBridgeVersion = getAppBridgeVersion(projectPath);
const safeAppBridgeVersion = isValidVersion(appBridgeVersion) ? appBridgeVersion : undefined;

const settings = await build({
plugins: [
Expand Down Expand Up @@ -45,7 +51,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}';` : ''}
`,
},
},
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/utils/getPackageVersion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'));

Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
4 changes: 4 additions & 0 deletions packages/cli/src/utils/logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ export class Logger {
console.error(pc.red(`[${getCurrentTime()}] ${messages.join(' ')}`));
}

static warn(...messages: string[]): void {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This one should only be logged behind a --verbose flag

Feel free to create a new PR to add this mode 👍

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ragi96 Looking at how i used it right now (for example warning about potentially sensitive files being leaked) I'm not sure I would see it as being a "verbose" case. I would treat this as a "warning" much rather than as a "debug" message of sorts which should absolutely be placed behind a flag like this (like the verbose logs i removed about the source file list ie.). Do you want me to remove this entirely for now?

console.warn(pc.yellow(`[${getCurrentTime()}] ${messages.join(' ')}`));
}

static spacer(width = 1): string {
return ' '.repeat(width);
}
Expand Down
13 changes: 13 additions & 0 deletions packages/cli/src/utils/packageProtocols.ts
Original file line number Diff line number Diff line change
@@ -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));
};
Loading
Loading