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 { GLOBAL_SETTINGS } from "../globals"; import { getChecksums } from "../utils/checksum"; import { ComponentUpdateSummary, fixAllImportStatements, formatAsLocal, maybeConvertTsxToJsx, } from "../utils/code-utils"; import { CONFIG_FILE_NAME, CustomFunctionConfig, PlasmicContext, createProjectConfig, getOrAddProjectConfig, getOrAddProjectLock, updateConfig, } from "../utils/config-utils"; import { detectNextJsVersion } from "../utils/envdetect"; import { HandledError } from "../utils/error"; import { assertAllPathsInRootDir, defaultResourcePath, renameFile, stripExtension, withBufferedFs, writeFileContent, } from "../utils/file-utils"; import { Metadata, generateMetadata, getContext } 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 { syncProjectComponents } from "./sync-components"; import { syncDataTokens } from "./sync-data-tokens"; import { syncGlobalContexts } from "./sync-global-contexts"; import { syncGlobalVariants } from "./sync-global-variants"; import { syncProjectIconAssets } from "./sync-icons"; import { syncProjectImageAssets } from "./sync-images"; import { syncProjectModule } from "./sync-project-module"; import { syncSplitsProvider } from "./sync-splits-provider"; import { syncStyleTokensProvider } from "./sync-style-tokens-provider"; import { upsertStyleTokens } from "./sync-styles"; export interface SyncArgs extends CommonArgs { projects: readonly string[]; forceOverwrite: boolean; yes?: boolean; force?: boolean; nonRecursive?: boolean; skipUpgradeCheck?: boolean; ignorePostSync?: boolean; quiet?: boolean; metadata?: string; allFiles?: boolean; skipFormatting?: boolean; skipBuffering?: boolean; } async function ensureRequiredPackages( context: PlasmicContext, baseDir: string, yes?: boolean ) { const requireds = await context.api.requiredPackages(); const confirmInstall = async ( pkg: string, requiredVersion: string, currentVersion: string | undefined, opts: { global: boolean; dev: boolean } ) => { let success = false; const command = installCommand(context.config, pkg, baseDir, opts); const upgrade = await confirmWithUser( currentVersion ? `A more recent version of ${pkg} >=${requiredVersion} is required (you currently have ${currentVersion}). Would you like to upgrade via "${command}"?` : `You need ${pkg} >=${requiredVersion} to proceed. Would you like to install via "${command}"?`, yes ); if (upgrade) { success = installUpgrade(context.config, 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"], cliVersion, { global: isGlobal, dev: true, } ); logger.info( chalk.bold("@plasmicapp/cli has been upgraded; please try again!") ); // Exit so the user can run again with the new cli throw new HandledError(); } const reactWebVersion = findInstalledVersion( context.config, baseDir, "@plasmicapp/react-web" ); if ( !reactWebVersion || semver.gt(requireds["@plasmicapp/react-web"], reactWebVersion) ) { await confirmInstall( "@plasmicapp/react-web", requireds["@plasmicapp/react-web"], reactWebVersion, { global: false, dev: false } ); } if (context.config.code.reactRuntime === "automatic") { // Using automatic runtime requires installing the @plasmicapp/react-web-runtime package const runtimeVersion = findInstalledVersion( context.config, 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"], runtimeVersion, { global: false, dev: false } ); } } } /** * Sync will always try to sync down a set of components that are version-consistent among specified projects. * (we only allow 1 version per projectId). * NOTE: the repo/plasmic.json might include projects with conflicting versions in its dependency tree. * We leave it to the user to sync all projects if they want us to catch/prevent this. * @param opts */ export async function sync( opts: SyncArgs, metadataDefaults?: Metadata ): Promise { // Initially allow for a missing auth. Only require an auth once we need to fetch new or updated API tokens for any // projects. if (!opts.baseDir) { opts.baseDir = process.cwd(); } const baseDir = opts.baseDir; let context = await getContext(opts, { enableSkipAuth: true }); if (opts.skipFormatting) { GLOBAL_SETTINGS.skipFormatting = true; } const isFirstRun = context.config.projects.length === 0; if (!opts.skipUpgradeCheck) { await ensureRequiredPackages(context, opts.baseDir, opts.yes); } fixFileExtension(context); assertAllPathsInRootDir(context); const projectIdToToken = new Map( [...context.config.projects] .filter((p) => p.projectApiToken) .map((p) => tuple(p.projectId, p.projectApiToken)) ); // Resolve what will be synced 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, branchName: projectConfigMap[projectId]?.projectBranchName ?? "main", versionRange: versionRange || projectConfigMap[projectId]?.version || "latest", componentIdOrNames: undefined, // Get all components! projectApiToken: projectApiToken || projectIdToToken.get(projectId), indirect: false, }; }); const projectSyncParams = projectWithVersion.length ? projectWithVersion : context.config.projects .filter((p) => !p.indirect) .map((p) => ({ projectId: p.projectId, branchName: p.projectBranchName ?? "main", versionRange: p.version, componentIdOrNames: undefined, // Get all components! projectApiToken: p.projectApiToken, indirect: !!p.indirect, })); // Short-circuit if nothing to sync if (projectSyncParams.length === 0) { throw new HandledError( "Don't know which projects to sync. Please specify via --projects" ); } // If there are any missing projectApiTokens, reload the context, this time requiring auth, so that we can fetch the // projectApiTokens from the server (as a user that has permission to do so). 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 = CONFIG_FILE_NAME; throw new HandledError( `Unable to authenticate Plasmic. Please run 'plasmic auth' or check the projectApiTokens in your ${configFileName}, and try again.` ); } } } // Pass just the root IDs and tokens that we do have for the resolve call. (In reality it doesn't need any of this // because it only consults the projectApiToken within projectSyncParams; we could just attach [].) context.api.attachProjectIdsAndTokens( projectSyncParams.flatMap((p) => p.projectApiToken ? [{ projectId: p.projectId, projectApiToken: p.projectApiToken }] : [] ) ); const versionResolution = await context.api.resolveSync( projectSyncParams, true // we always want to get dependency data ); // Make sure the resolution is compatible with plasmic.json and plasmic.lock 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(); // The resolveSync call returns the project API tokens for all relevant projects (sources and dependencies). // resolveSync is what does this because it's what is computing all concrete versions to sync, and the dependency // graph can change with any version. Subsequent API calls require the exact API tokens, not to redo this work on each // call. Only resolveSync accepts just the API tokens for the root projects. // // We shouldn't simply use projectsToSync, because this list excludes up-to-date projects, but syncing a dependent // project still requires tokens to the dependencies. const projectIdsAndTokens = [ ...versionResolution.projects, ...versionResolution.dependencies, ].map((p) => L.pick(p, "projectId", "projectApiToken")); context.api.attachProjectIdsAndTokens(projectIdsAndTokens); const externalNpmPackages = new Set(); const externalCssImports = new Set(); const doSync = async () => { // Sync in sequence (no parallelism) // going in reverse to get leaves of the dependency tree first for (const projectMeta of projectsToSync) { await syncProject( context, opts, projectIdsAndTokens, projectMeta.projectId, projectMeta.branchName, projectMeta.componentIds, projectMeta.version, projectMeta.dependencies, summary, projectMeta.indirect, externalNpmPackages, externalCssImports, metadataDefaults ); } // Materialize scheme into each component config. 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) ); // Update project version and indirect status if specified and // successfully synced. if (projectWithVersion.length) { const versionMap: Record = {}; projectWithVersion.forEach( (p) => (versionMap[p.projectId] = p.versionRange) ); const indirectMap: Record = {}; projectsToSync.forEach((p) => (indirectMap[p.projectId] = p.indirect)); context.config.projects.forEach((p) => { p.version = versionMap[p.projectId] || p.version; // Only update `indirect` if it is set in current config. if (p.projectId in indirectMap && p.indirect) { p.indirect = indirectMap[p.projectId]; } }); } // Now we know config.components are all correct, so we can go ahead and fix up all the import statements await fixAllImportStatements(context, summary); const codegenVersion = await context.api.latestCodegenVersion(); context.lock.projects.forEach((p) => { if ( projectsToSync.some( (syncedProject) => syncedProject.projectId === p.projectId ) ) { p.codegenVersion = codegenVersion; } }); // Write the new ComponentConfigs to disk await updateConfig(context, context.config); }; // Perform the actual sync if (opts.skipBuffering) { await doSync(); } else { await withBufferedFs(doSync); } await checkExternalPkgs( context, baseDir, opts, Array.from(externalNpmPackages.keys()) ); if (!opts.quiet && externalCssImports.size > 0) { logger.info( chalk.cyanBright.bold( `IMPORTANT: This project uses external packages and styles. Make sure to import the following global CSS: ` + Array.from(externalCssImports.keys()) .map((stmt) => `"${stmt}"`) .join(", ") ) ); } // Post-sync commands 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: PlasmicContext, baseDir: string, opts: SyncArgs, pkgs: string[] ) { const missingPkgs = pkgs .map((pkgSpec) => { // Handle both @package@version and package@version const splitPkgSpec = pkgSpec.split("@"); const [pkg, version] = splitPkgSpec.length === 2 ? splitPkgSpec : [`@${splitPkgSpec[1]}`, splitPkgSpec[2]]; const installedVersion = findInstalledVersion( context.config, baseDir, pkg ); if (!installedVersion) { // Not installed return pkg; } else if (version) { // Installed, so check against requested version; upgrade if // requested version is greater than installed version return semver.gt(version, installedVersion) ? pkg : undefined; } else { return undefined; } }) .filter((x): x is string => !!x); if (missingPkgs.length > 0) { const upgrade = await confirmWithUser( `The following packages aren't installed or are outdated, but are required by some projects; would you like to install them? ${missingPkgs.join( ", " )}`, opts.yes ); if (upgrade) { installUpgrade(context.config, missingPkgs.join(" "), baseDir); } } } function maybeRenamePathExt( context: PlasmicContext, filePath: string, fileExt: string, opts?: { continueOnFailure?: boolean; } ) { if (!filePath) { return filePath; } const correctPath = `${stripExtension(filePath, true)}${fileExt}`; if (filePath !== correctPath) { try { renameFile(context, filePath, correctPath); } catch (e) { if (!opts?.continueOnFailure) { throw e; } else { logger.warn(e); } } } return correctPath; } function fixFileExtension(context: PlasmicContext) { // Project-level CSS files are always non-module (.css), keyframes and // shared :where(.plasmic_tokens) vars need to be global. Component-level // CSS files follow the scheme. const componentCssExt = context.config.style.scheme === "css-modules" ? ".module.css" : ".css"; context.config.style.defaultStyleCssFilePath = maybeRenamePathExt( context, context.config.style.defaultStyleCssFilePath, ".css", { continueOnFailure: true } ); context.config.projects.forEach((project) => { project.cssFilePath = maybeRenamePathExt( context, project.cssFilePath, ".css", { continueOnFailure: true } ); project.components.forEach((component) => { component.cssFilePath = maybeRenamePathExt( context, component.cssFilePath, componentCssExt, { continueOnFailure: true } ); }); }); } async function syncProject( context: PlasmicContext, opts: SyncArgs, projectIdsAndTokens: ProjectIdAndToken[], projectId: string, branchName: string, componentIds: string[], projectVersion: string, dependencies: { [projectId: string]: string }, summary: Map, indirect: boolean, externalNpmPackages: Set, externalCssImports: Set, metadataDefaults?: Metadata ): Promise { 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 ); // Server-side code-gen const projectBundle = await context.api.projectComponents( projectId, branchName, { platform: context.config.platform, platformVersion: detectNextJsVersion(), platformOptions: context.config.platform === "nextjs" ? { nextjs: { appDir: context.config.nextjsConfig?.pagesDir?.endsWith("app") || false, }, } : {}, componentIdOrNames: componentIds, version: projectVersion, imageOpts: context.config.images, fontOpts: context.config.fontOpts, stylesOpts: context.config.style, i18nOpts: context.config.i18n, checksums: existingChecksums, codeOpts: context.config.code, metadata: generateMetadata( { ...metadataDefaults, platform: context.config.platform, }, opts.metadata ), indirect, wrapPagesWithGlobalContexts: context.config.wrapPagesWithGlobalContexts, } ); // Convert from TSX => JSX if (context.config.code.lang === "js") { for (const c of projectBundle.components) { [c.renderModuleFileName, c.renderModule] = await maybeConvertTsxToJsx( c.renderModuleFileName, c.renderModule ); [c.skeletonModuleFileName, c.skeletonModule] = await maybeConvertTsxToJsx( c.skeletonModuleFileName, c.skeletonModule ); } for (const icon of projectBundle.iconAssets) { [icon.fileName, icon.module] = await maybeConvertTsxToJsx( icon.fileName, icon.module ); } for (const gv of projectBundle.globalVariants) { [gv.contextFileName, gv.contextModule] = await maybeConvertTsxToJsx( gv.contextFileName, gv.contextModule ); } } await syncGlobalVariants( context, projectBundle.projectConfig, projectBundle.globalVariants, projectBundle.checksums, branchName ); await syncProjectConfig( context, projectBundle.projectConfig, projectApiToken, branchName, projectVersion, dependencies, projectBundle.components, opts.forceOverwrite, summary, projectBundle.checksums, indirect ); syncCodeComponentsMeta(context, projectId, projectBundle.codeComponentMetas); syncCustomFunctionsMeta( context, projectId, projectBundle.customFunctionMetas ); await upsertStyleTokens( context, projectBundle.usedTokens, projectBundle.projectConfig.projectId ); await syncProjectIconAssets( context, projectId, branchName, projectVersion, projectBundle.iconAssets, projectBundle.checksums ); await syncProjectImageAssets( context, projectId, branchName, projectVersion, projectBundle.imageAssets, projectBundle.checksums ); (projectBundle.usedNpmPackages || []).forEach((pkg) => externalNpmPackages.add(pkg) ); (projectBundle.externalCssImports || []).forEach((css) => externalCssImports.add(css) ); } async function syncStyleConfig( context: PlasmicContext, response: StyleConfigResponse ) { 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: PlasmicContext, projectBundle: ProjectMetaBundle, projectApiToken: string, branchName: string, version: string, dependencies: { [projectId: string]: string }, componentBundles: ComponentBundle[], forceOverwrite: boolean, summary: Map, checksums: ChecksumBundle, indirect: boolean ) { 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, }) ); // Update missing/outdated props projectConfig.projectName = projectBundle.projectName; if (!projectConfig.cssFilePath) { projectConfig.cssFilePath = defaultCssFilePath; } // plasmic.lock const projectLock = getOrAddProjectLock( context, projectConfig.projectId, branchName ); projectLock.version = version; projectLock.dependencies = dependencies; projectLock.lang = context.config.code.lang; if (projectBundle.cssRules) { const formattedCssRules = await formatAsLocal( projectBundle.cssRules, projectConfig.cssFilePath ); // Write out project css 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, }); await syncGlobalContexts( context, projectBundle, projectConfig, projectLock, checksums ); await syncSplitsProvider( context, projectBundle, projectConfig, projectLock, checksums ); await syncProjectModule( context, projectBundle, projectConfig, projectLock, checksums ); await syncStyleTokensProvider( context, projectBundle, projectConfig, projectLock, checksums ); await syncDataTokens( context, projectBundle, projectConfig, projectLock, checksums ); // Write out components await syncProjectComponents( context, projectConfig, version, componentBundles, forceOverwrite, summary, projectLock, checksums ); } function syncCodeComponentsMeta( context: PlasmicContext, projectId: string, codeComponentBundles: CodeComponentMeta[] ) { const projectConfig = getOrAddProjectConfig(context, projectId); projectConfig.codeComponents = codeComponentBundles.map((meta) => ({ id: meta.id, name: meta.name, displayName: meta.displayName, componentImportPath: meta.importPath, ...(meta.helper ? { helper: { name: meta.helper.name, importPath: meta.helper.importPath, }, } : {}), })); } function syncCustomFunctionsMeta( context: PlasmicContext, projectId: string, customFunctionBundles: CustomFunctionConfig[] ) { const projectConfig = getOrAddProjectConfig(context, projectId); projectConfig.customFunctions = customFunctionBundles.map((meta) => ({ defaultExport: meta.defaultExport, id: meta.id, importPath: meta.importPath, name: meta.name, namespace: meta.namespace ?? null, })); }