import chalk from "chalk" ; import { spawnSync } from "child_process" ; import L from "lodash" ; import path from "upath" ; import { CommonArgs } from ".." ; import { ChecksumBundle , CodeComponentMeta , ComponentBundle , ProjectIdAndToken , ProjectMetaBundle , StyleConfigResponse , } from "../api" ; import { logger } from "../deps" ; import { getChecksums } from "../utils/checksum" ; import { ComponentUpdateSummary , fixAllImportStatements , formatAsLocal , maybeConvertTsxToJsx , mkFixImportContext , replaceImports , } from "../utils/code-utils" ; import { CONFIG_FILE_NAME , createProjectConfig , getOrAddProjectConfig , getOrAddProjectLock , LOADER_CONFIG_FILE_NAME , PlasmicContext , PlasmicLoaderConfig , updateConfig , } from "../utils/config-utils" ; import { HandledError } from "../utils/error" ; import { assertAllPathsInRootDir , defaultResourcePath , existsBuffered , readFileText , renameFile , stripExtension , withBufferedFs , writeFileContent , writeFileText , } from "../utils/file-utils" ; import { generateMetadata , getContext , Metadata } from "../utils/get-context" ; import { printFirstSyncInfo } from "../utils/help" ; import { ensure , tuple } from "../utils/lang-utils" ; import { findInstalledVersion , getCliVersion , installCommand , installUpgrade , isCliGloballyInstalled , } from "../utils/npm-utils" ; import { checkVersionResolution } from "../utils/resolve-utils" ; import * as semver from "../utils/semver" ; import { confirmWithUser } from "../utils/user-utils" ; import { ComponentPendingMerge , syncProjectComponents , } from "./sync-components" ; import { syncGlobalVariants } from "./sync-global-variants" ; import { syncProjectIconAssets } from "./sync-icons" ; import { syncProjectImageAssets } from "./sync-images" ; import { upsertStyleTokens } from "./sync-styles" ; export interface SyncArgs extends CommonArgs { projects : readonly string [ ] ; forceOverwrite : boolean ; newComponentScheme ? : "blackbox" | "direct" ; appendJsxOnMissingBase ? : boolean ; yes ? : boolean ; force ? : boolean ; nonRecursive ? : boolean ; skipUpgradeCheck ? : boolean ; ignorePostSync ? : boolean ; quiet ? : boolean ; metadata ? : string ; allFiles ? : boolean ; loaderConfig ? : string ; } async function ensureRequiredPackages ( context , baseDir , yes ? ) { const requireds = await context . api . requiredPackages ( ) ; const confirmInstall = async ( pkg , requiredVersion , opts ) => { let success = false ; const command = installCommand ( pkg , baseDir , opts ) ; const upgrade = await confirmWithUser ( `A more recent version of ${ pkg } >=${ requiredVersion } is required. Would you like to upgrade via "${ command }"?` , yes ) ; if ( upgrade ) { success = installUpgrade ( pkg , baseDir , opts ) ; } else { success = false ; } if ( ! success ) { throw new HandledError ( `Upgrading ${ pkg } is required to continue.` ) ; } } ; const cliVersion = getCliVersion ( ) ; if ( ! cliVersion || semver . gt ( requireds [ "@plasmicapp/cli" ] , cliVersion ) ) { const isGlobal = isCliGloballyInstalled ( context . rootDir ) ; await confirmInstall ( "@plasmicapp/cli" , requireds [ "@plasmicapp/cli" ] , { global : isGlobal , dev : true , } ) ; logger . info ( chalk . bold ( "@plasmicapp/cli has been upgraded; please try again!" ) ) ; throw new HandledError ( ) ; } const reactWebVersion = findInstalledVersion ( context , baseDir , "@plasmicapp/react-web" ) ; if ( ! reactWebVersion || semver . gt ( requireds [ "@plasmicapp/react-web" ] , reactWebVersion ) ) { await confirmInstall ( "@plasmicapp/react-web" , requireds [ "@plasmicapp/react-web" ] , { global : false , dev : false } ) ; } if ( context . config . code . reactRuntime === "automatic" ) { const runtimeVersion = findInstalledVersion ( context , baseDir , "@plasmicapp/react-web-runtime" ) ; if ( ! runtimeVersion || semver . gt ( requireds [ "@plasmicapp/react-web-runtime" ] , runtimeVersion ) ) { await confirmInstall ( "@plasmicapp/react-web-runtime" , requireds [ "@plasmicapp/react-web-runtime" ] , { global : false , dev : false } ) ; } } } function getLoaderConfigPath ( opts ) { return opts . loaderConfig || LOADER_CONFIG_FILE_NAME ; } function maybeReadLoaderConfig ( opts ) { const path = getLoaderConfigPath ( opts ) ; if ( ! existsBuffered ( path ) ) { return { } ; } return JSON . parse ( readFileText ( path ! ) ) ; } function writeLoaderConfig ( opts , config ) { const loaderConfigPath = getLoaderConfigPath ( opts ) ; writeFileText ( loaderConfigPath , formatAsLocal ( JSON . stringify ( config ) , loaderConfigPath , opts . baseDir ) ) ; } param opts export async function sync ( opts , metadataDefaults ? ) { if ( ! opts . baseDir ) opts . baseDir = process . cwd ( ) ; const baseDir = opts . baseDir ; let context = await getContext ( opts , { enableSkipAuth : true } ) ; const isFirstRun = context . config . projects . length === 0 ; if ( ! opts . skipUpgradeCheck ) { await ensureRequiredPackages ( context , opts . baseDir , opts . yes ) ; } fixFileExtension ( context ) ; assertAllPathsInRootDir ( context ) ; const loaderConfig = process . env . PLASMIC_LOADER ? maybeReadLoaderConfig ( opts ) : { } ; const projectIdToToken = new Map ( [ ... context . config . projects , ... ( loaderConfig ?. projects ?? [ ] ) ] . filter ( ( p ) => p . projectApiToken ) . map ( ( p ) => tuple ( p . projectId , p . projectApiToken ) ) ) ; const projectConfigMap = L . keyBy ( context . config . projects , ( p ) => p . projectId ) ; const projectWithVersion = opts . projects . map ( ( p ) => { const [ projectIdToken , versionRange ] = p . split ( "@" ) ; const [ projectId , projectApiToken ] = projectIdToken . split ( ":" ) ; return { projectId , versionRange : versionRange || projectConfigMap [ projectId ] ?. version || "latest" , componentIdOrNames : undefined , projectApiToken : projectApiToken || projectIdToToken . get ( projectId ) , indirect : false , } ; } ) ; const projectSyncParams = projectWithVersion . length ? projectWithVersion : context . config . projects . map ( ( p ) => ( { projectId : p . projectId , versionRange : p . version , componentIdOrNames : undefined , projectApiToken : p . projectApiToken , indirect : ! ! p . indirect , } ) ) ; if ( projectSyncParams . length === 0 ) { throw new HandledError ( "Don't know which projects to sync. Please specify via --projects" ) ; } if ( projectSyncParams . some ( ( p ) => ! p . projectApiToken ) ) { try { context = await getContext ( opts ) ; } catch ( e ) { if ( ( e as any ) . message . includes ( "Unable to authenticate Plasmic" ) ) { const configFileName = process . env . PLASMIC_LOADER ? LOADER_CONFIG_FILE_NAME : CONFIG_FILE_NAME ; throw new HandledError ( `Unable to authenticate Plasmic. Please run 'plasmic auth' or check the projectApiTokens in your ${ configFileName }, and try again.` ) ; } } } context . api . attachProjectIdsAndTokens ( projectSyncParams . flatMap ( ( p ) => p . projectApiToken ? [ { projectId : p . projectId , projectApiToken : p . projectApiToken } ] : [ ] ) ) ; const versionResolution = await context . api . resolveSync ( projectSyncParams , true ) ; const projectsToSync = await checkVersionResolution ( versionResolution , context , opts ) ; if ( projectsToSync . length <= 0 ) { logger . info ( "Your projects are up-to-date with respect to your specified version ranges. Nothing to sync." ) ; return ; } const summary = new Map < string , ComponentUpdateSummary > ( ) ; const pendingMerge = new Array < ComponentPendingMerge > ( ) ; const projectIdsAndTokens = [ ... versionResolution . projects , ... versionResolution . dependencies , ] . map ( ( p ) => L . pick ( p , "projectId" , "projectApiToken" ) ) ; context . api . attachProjectIdsAndTokens ( projectIdsAndTokens ) ; const externalNpmPackages = new Set < string > ( ) ; const externalCssImports = new Set < string > ( ) ; await withBufferedFs ( async ( ) => { for ( const projectMeta of projectsToSync ) { await syncProject ( context , opts , projectIdsAndTokens , projectMeta . projectId , projectMeta . componentIds , projectMeta . version , projectMeta . dependencies , summary , pendingMerge , projectMeta . indirect , externalNpmPackages , externalCssImports , metadataDefaults ) ; } context . config . projects . forEach ( ( p ) => p . components . forEach ( ( c ) => { if ( c . type === "managed" && ! c . scheme ) { c . scheme = context . config . code . scheme ; } } ) ) ; await syncStyleConfig ( context , await context . api . genStyleConfig ( context . config . style ) ) ; if ( projectWithVersion . length ) { const versionMap = { } ; projectWithVersion . forEach ( ( p ) => ( versionMap [ p . projectId ] = p . versionRange ) ) ; const indirectMap = { } ; projectsToSync . forEach ( ( p ) => ( indirectMap [ p . projectId ] = p . indirect ) ) ; context . config . projects . forEach ( ( p ) => { p . version = versionMap [ p . projectId ] || p . version ; if ( p . projectId in indirectMap && p . indirect ) { p . indirect = indirectMap [ p . projectId ] ; } } ) ; } const fixImportContext = mkFixImportContext ( context . config ) ; for ( const m of pendingMerge ) { const resolvedEditedFile = replaceImports ( context , m . editedSkeletonFile , m . skeletonModulePath , fixImportContext , true , baseDir ) ; const resolvedNewFile = replaceImports ( context , m . newSkeletonFile , m . skeletonModulePath , fixImportContext , true , baseDir ) ; await m . merge ( resolvedNewFile , resolvedEditedFile ) ; } await fixAllImportStatements ( context , opts . baseDir , summary ) ; if ( process . env . PLASMIC_LOADER ) { const rootProjectIds = new Set ( projectSyncParams . map ( ( p ) => p . projectId ) ) ; const freshIdsAndTokens = projectIdsAndTokens . filter ( ( p ) => rootProjectIds . has ( p . projectId ) ) . map ( ( p ) => L . pick ( p , "projectId" , "projectApiToken" ) ) ; const config = { ... loaderConfig , projects : L . sortBy ( L . uniqBy ( [ ... freshIdsAndTokens , ... ( loaderConfig ?. projects ?? [ ] ) ] , ( p ) => p . projectId ) , ( p ) => p . projectId ) , } ; writeLoaderConfig ( opts , config ) ; } await updateConfig ( context , context . config , baseDir ) ; } ) ; await checkExternalPkgs ( context , baseDir , opts , Array . from ( externalNpmPackages . keys ( ) ) ) ; if ( ! opts . quiet && externalCssImports . size > 0 ) { logger . info ( `This project uses external packages and styles. Make sure to import the following global CSS: ` + Array . from ( externalCssImports . keys ( ) ) . map ( ( stmt ) => `"${ stmt }"` ) . join ( ", " ) ) ; } if ( ! opts . ignorePostSync ) { for ( const cmd of context . config . postSyncCommands || [ ] ) { spawnSync ( cmd , { shell : true , stdio : "inherit" } ) ; } } if ( isFirstRun ) { if ( ! process . env . QUIET ) { printFirstSyncInfo ( context ) ; } } } async function checkExternalPkgs ( context , baseDir , opts , pkgs ) { const missingPkgs = pkgs . filter ( ( pkg ) => { const installedPkg = findInstalledVersion ( context , baseDir , pkg ) ; return ! installedPkg ; } ) ; if ( missingPkgs . length > 0 ) { const upgrade = await confirmWithUser ( `The following packages aren't installed but are required by some projects, would you like to install them? ${ missingPkgs . join ( ", " ) }` , opts . yes ) ; if ( upgrade ) { installUpgrade ( missingPkgs . join ( " " ) , baseDir ) ; } } } function maybeRenamePathExt ( context , path , ext ) { if ( ! path ) { return path ; } const correctPath = `${ stripExtension ( path , true ) }${ ext }` ; if ( path !== correctPath ) { renameFile ( context , path , correctPath ) ; } return correctPath ; } function fixFileExtension ( context ) { const cssExt = context . config . style . scheme === "css-modules" ? ".module.css" : ".css" ; context . config . style . defaultStyleCssFilePath = maybeRenamePathExt ( context , context . config . style . defaultStyleCssFilePath , cssExt ) ; context . config . projects . forEach ( ( project ) => { project . cssFilePath = maybeRenamePathExt ( context , project . cssFilePath , cssExt ) ; project . components . forEach ( ( component ) => { component . cssFilePath = maybeRenamePathExt ( context , component . cssFilePath , cssExt ) ; } ) ; } ) ; } async function syncProject ( context , opts , projectIdsAndTokens , projectId , componentIds , projectVersion , dependencies , summary , pendingMerge , indirect , externalNpmPackages , externalCssImports , metadataDefaults ? ) { const newComponentScheme = opts . newComponentScheme || context . config . code . scheme ; const existingProject = context . config . projects . find ( ( p ) => p . projectId === projectId ) ; const existingCompScheme = ( existingProject ?. components || [ ] ) . map ( ( c ) => [ c . id , c . scheme ] ) ; const projectApiToken = ensure ( projectIdsAndTokens . find ( ( p ) => p . projectId === projectId ) ?. projectApiToken , `Could not find the API token for project ${ projectId } in list: ${ JSON . stringify ( projectIdsAndTokens ) }` ) ; const existingChecksums = getChecksums ( context , opts , projectId , componentIds ) ; const projectBundle = await context . api . projectComponents ( projectId , { platform : context . config . platform , newCompScheme : newComponentScheme , existingCompScheme , componentIdOrNames : componentIds , version : projectVersion , imageOpts : context . config . images , stylesOpts : context . config . style , checksums : existingChecksums , codeOpts : context . config . code , metadata : generateMetadata ( { ... metadataDefaults , platform : context . config . platform , } , opts . metadata ) , indirect , } ) ; if ( context . config . code . lang === "js" ) { projectBundle . components . forEach ( ( c ) => { [ c . renderModuleFileName , c . renderModule ] = maybeConvertTsxToJsx ( c . renderModuleFileName , c . renderModule , opts . baseDir ) ; [ c . skeletonModuleFileName , c . skeletonModule ] = maybeConvertTsxToJsx ( c . skeletonModuleFileName , c . skeletonModule , opts . baseDir ) ; } ) ; projectBundle . iconAssets . forEach ( ( icon ) => { [ icon . fileName , icon . module ] = maybeConvertTsxToJsx ( icon . fileName , icon . module , opts . baseDir ) ; } ) ; projectBundle . globalVariants . forEach ( ( gv ) => { [ gv . contextFileName , gv . contextModule ] = maybeConvertTsxToJsx ( gv . contextFileName , gv . contextModule , opts . baseDir ) ; } ) ; ( projectBundle . projectConfig . jsBundleThemes || [ ] ) . forEach ( ( theme ) => { [ theme . themeFileName , theme . themeModule ] = maybeConvertTsxToJsx ( theme . themeFileName , theme . themeModule , opts . baseDir ) ; } ) ; } await syncGlobalVariants ( context , projectBundle . projectConfig , projectBundle . globalVariants , projectBundle . checksums , opts . baseDir ) ; await syncProjectConfig ( context , projectBundle . projectConfig , projectApiToken , projectVersion , dependencies , projectBundle . components , opts . forceOverwrite , ! ! opts . appendJsxOnMissingBase , summary , pendingMerge , projectBundle . checksums , opts . baseDir , indirect ) ; syncCodeComponentsMeta ( context , projectId , projectBundle . codeComponentMetas ) ; await upsertStyleTokens ( context , projectBundle . usedTokens ) ; await syncProjectIconAssets ( context , projectId , projectVersion , projectBundle . iconAssets , projectBundle . checksums , opts . baseDir ) ; await syncProjectImageAssets ( context , projectId , projectVersion , projectBundle . imageAssets , projectBundle . checksums ) ; ( projectBundle . usedNpmPackages || [ ] ) . forEach ( ( pkg ) => externalNpmPackages . add ( pkg ) ) ; ( projectBundle . externalCssImports || [ ] ) . forEach ( ( css ) => externalCssImports . add ( css ) ) ; } async function syncStyleConfig ( context , response ) { const expectedPath = context . config . style . defaultStyleCssFilePath || path . join ( context . config . defaultPlasmicDir , response . defaultStyleCssFileName ) ; context . config . style . defaultStyleCssFilePath = expectedPath ; await writeFileContent ( context , expectedPath , response . defaultStyleCssRules , { force : true , } ) ; } async function syncProjectConfig ( context , projectBundle , projectApiToken , version , dependencies , componentBundles , forceOverwrite , appendJsxOnMissingBase , summary , pendingMerge , checksums , baseDir , indirect ) { const defaultCssFilePath = defaultResourcePath ( context , projectBundle . projectName , projectBundle . cssFileName ) ; const isNew = ! context . config . projects . find ( ( p ) => p . projectId === projectBundle . projectId ) ; const projectConfig = getOrAddProjectConfig ( context , projectBundle . projectId , createProjectConfig ( { projectId : projectBundle . projectId , projectApiToken , projectName : projectBundle . projectName , version , cssFilePath : defaultCssFilePath , indirect , } ) ) ; projectConfig . projectName = projectBundle . projectName ; if ( ! projectConfig . cssFilePath ) { projectConfig . cssFilePath = defaultCssFilePath ; } projectConfig . projectApiToken = projectApiToken ; const projectLock = getOrAddProjectLock ( context , projectConfig . projectId ) ; projectLock . version = version ; projectLock . dependencies = dependencies ; projectLock . lang = context . config . code . lang ; if ( projectBundle . cssRules ) { const formattedCssRules = formatAsLocal ( projectBundle . cssRules , projectConfig . cssFilePath , baseDir ) ; await writeFileContent ( context , projectConfig . cssFilePath , formattedCssRules , { force : ! isNew , } ) ; } projectLock . fileLocks = projectLock . fileLocks . filter ( ( fileLock ) => fileLock . type !== "projectCss" ) ; projectLock . fileLocks . push ( { assetId : projectConfig . projectId , type : "projectCss" , checksum : checksums . projectCssChecksum , } ) ; if ( projectConfig . jsBundleThemes && projectConfig . jsBundleThemes . length === 0 ) { delete projectConfig . jsBundleThemes ; } await syncProjectComponents ( context , projectConfig , version , componentBundles , forceOverwrite , appendJsxOnMissingBase , summary , pendingMerge , projectLock , checksums , baseDir ) ; } function syncCodeComponentsMeta ( context , projectId , codeComponentBundles ) { const projectConfig = getOrAddProjectConfig ( context , projectId ) ; projectConfig . codeComponents = codeComponentBundles . map ( ( meta ) => ( { id : meta . id , name : meta . name , componentImportPath : meta . importPath , } ) ) ; }