diff --git a/packages/cli/src/commands/__tests__/build.test.js b/packages/cli/src/commands/__tests__/build.test.js index d66404ab48..20abc0cd0e 100644 --- a/packages/cli/src/commands/__tests__/build.test.js +++ b/packages/cli/src/commands/__tests__/build.test.js @@ -1,9 +1,18 @@ -vi.mock('@cedarjs/project-config', async (importOriginal) => { - const originalProjectConfig = await importOriginal() +// mock Telemetry for CLI commands so they don't try to spawn a process +vi.mock('@cedarjs/telemetry', () => { + return { + errorTelemetry: () => vi.fn(), + timedTelemetry: (_argv, _options, callback) => { + return callback() + }, + } +}) + +vi.mock('@cedarjs/project-config', async () => { return { - ...originalProjectConfig, getPaths: () => { return { + base: '/mocked/project', api: { dist: '/mocked/project/api/dist', prismaConfig: '/mocked/project/api/prisma.config.js', @@ -20,22 +29,31 @@ vi.mock('@cedarjs/project-config', async (importOriginal) => { // the values it currently reads are optional. } }, + resolveFile: () => { + // Used by packages/cli/src/lib/index.js + }, } }) vi.mock('node:fs', async () => { - const actualFs = await vi.importActual('node:fs') - return { default: { - ...actualFs, - // Mock the existence of the Prisma config file existsSync: (path) => { if (path === '/mocked/project/api/prisma.config.js') { + // Mock the existence of the Prisma config file + return true + } else if (path.endsWith('package.json')) { + // Mock the existence of all packages//package.json files return true } - - return actualFs.existsSync(path) + }, + readFileSync: () => { + // Reading /mocked/project/package.json + // It just needs a workspace config section + return JSON.stringify({ + workspaces: ['api', 'web', 'packages/*'], + }) + // } }, }, } @@ -54,17 +72,21 @@ vi.mock('execa', () => ({ })), })) -import { handler } from '../build.js' +import { handler } from '../buildHandler.js' afterEach(() => { vi.clearAllMocks() }) test('the build tasks are in the correct sequence', async () => { + vi.spyOn(console, 'log').mockImplementation(() => {}) + await handler({}) + expect(Listr.mock.calls[0][0].map((x) => x.title)).toMatchInlineSnapshot(` [ "Generating Prisma Client...", + "Building Packages...", "Verifying graphql schema...", "Building API...", "Building Web...", diff --git a/packages/cli/src/commands/build.js b/packages/cli/src/commands/build.js index a9e3344db7..3ff49faf64 100644 --- a/packages/cli/src/commands/build.js +++ b/packages/cli/src/commands/build.js @@ -9,13 +9,12 @@ export const command = 'build [workspace..]' export const description = 'Build for production' export const builder = (yargs) => { - const choices = workspaces() - yargs .positional('workspace', { - choices, - default: choices, - description: 'What workspace(s) to build', + default: ['web', 'api', 'packages/*'], + description: + 'What workspace(s) to build. Valid values are: web, api, packages/*, ' + + '', type: 'array', }) .option('verbose', { @@ -47,6 +46,38 @@ export const builder = (yargs) => { includeEpilogue: false, }) }) + .check((argv) => { + const workspacesArg = argv.workspace + + if (!Array.isArray(workspacesArg)) { + return 'Workspace must be an array' + } + + // Remove all default workspace names and then check if there are any + // remaining workspaces to build. This is an optimization to avoid calling + // `workspaces({ includePackages: true }) as that's a somewhat expensive + // method call that hits the filesystem and parses files + + const filtered = workspacesArg.filter( + (item) => item !== 'api' && item !== 'web' && item !== 'packages/*', + ) + + if (filtered.length === 0) { + return true + } + + const workspaceNames = workspaces({ includePackages: true }) + + if (!workspacesArg.every((item) => workspaceNames.includes(item))) { + return ( + c.error(`Unknown workspace(s) ${workspacesArg.join(' ')}`) + + '\n\nValid values are: ' + + workspaceNames.join(', ') + ) + } + + return true + }) .epilogue( `Also see the ${terminalLink( 'CedarJS CLI Reference', diff --git a/packages/cli/src/commands/buildHandler.js b/packages/cli/src/commands/buildHandler.js index 388468103d..89bc18513e 100644 --- a/packages/cli/src/commands/buildHandler.js +++ b/packages/cli/src/commands/buildHandler.js @@ -2,6 +2,7 @@ import fs from 'node:fs' import { createRequire } from 'node:module' import path from 'node:path' +import concurrently from 'concurrently' import execa from 'execa' import { Listr } from 'listr2' import { terminalLink } from 'termi-link' @@ -11,13 +12,14 @@ import { buildApi, cleanApiBuild } from '@cedarjs/internal/dist/build/api' import { generate } from '@cedarjs/internal/dist/generate/generate' import { loadAndValidateSdls } from '@cedarjs/internal/dist/validateSchema' import { detectPrerenderRoutes } from '@cedarjs/prerender/detection' -import { timedTelemetry } from '@cedarjs/telemetry' +import { timedTelemetry, errorTelemetry } from '@cedarjs/telemetry' +import { exitWithError } from '../lib/exit.js' import { generatePrismaCommand } from '../lib/generatePrismaClient.js' import { getPaths, getConfig } from '../lib/index.js' export const handler = async ({ - workspace = ['api', 'web'], + workspace = ['api', 'web', 'packages/*'], verbose = false, prisma = true, prerender = true, @@ -43,6 +45,14 @@ export const handler = async ({ prismaSchemaExists && (workspace.includes('api') || prerenderRoutes.length > 0) + const packageJsonPath = path.join(cedarPaths.base, 'package.json') + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')) + const packageJsonWorkspaces = packageJson.workspaces + const restWorkspaces = + Array.isArray(packageJsonWorkspaces) && packageJsonWorkspaces.length > 2 + ? workspace.filter((w) => w !== 'api' && w !== 'web') + : [] + const gqlFeaturesTaskTitle = `Generating types needed for ${[ useFragments && 'GraphQL Fragments', useTrustedDocuments && 'Trusted Documents', @@ -62,6 +72,61 @@ export const handler = async ({ }) }, }, + restWorkspaces.length > 0 && { + title: 'Building Packages...', + task: async () => { + // fs.globSync requires forward slashes as path separators in patterns, + // even on Windows. + const globPattern = path + .join(cedarPaths.packages, '*') + .replaceAll('\\', '/') + const allPackagePaths = await Array.fromAsync( + fs.promises.glob(globPattern), + ) + + // restWorkspaces can be ['packages/*'] or + // ['@my-org/pkg-one', '@my-org/pkg-two', 'packages/pkg-three', etc] + // We need to map that to filesystem paths + const workspacePaths = restWorkspaces.some((w) => w === 'packages/*') + ? allPackagePaths + : restWorkspaces.map((w) => { + const workspacePath = path.join( + cedarPaths.packages, + w.split('/').at(-1), + ) + + if (!fs.existsSync(workspacePath)) { + throw new Error(`Workspace not found: ${workspacePath}`) + } + + return workspacePath + }) + + const { result } = concurrently( + workspacePaths.map((workspacePath) => { + return { + command: `yarn build`, + name: workspacePath.split('/').at(-1), + cwd: workspacePath, + } + }), + { + prefix: '{name} |', + timestampFormat: 'HH:mm:ss', + }, + ) + + await result.catch((e) => { + if (e?.message) { + errorTelemetry( + process.argv, + `Error concurrently building sides: ${e.message}`, + ) + exitWithError(e) + } + }) + }, + }, // If using GraphQL Fragments or Trusted Documents, then we need to use // codegen to generate the types needed for possible types and the trusted // document store hashes diff --git a/packages/cli/src/lib/project.js b/packages/cli/src/lib/project.js index 673efa93cf..1a8362a8a6 100644 --- a/packages/cli/src/lib/project.js +++ b/packages/cli/src/lib/project.js @@ -11,19 +11,40 @@ export const isTypeScriptProject = () => { ) } -export const workspaces = () => { - const paths = getPaths() +export function workspaces({ includePackages = false } = {}) { + const cedarPaths = getPaths() let workspaces = [] - if (fs.existsSync(path.join(paths.web.base, 'package.json'))) { + if (fs.existsSync(path.join(cedarPaths.web.base, 'package.json'))) { workspaces = [...workspaces, 'web'] } - if (fs.existsSync(path.join(paths.api.base, 'package.json'))) { + if (fs.existsSync(path.join(cedarPaths.api.base, 'package.json'))) { workspaces = [...workspaces, 'api'] } + if (includePackages) { + // fs.globSync requires forward slashes as path separators in patterns, + // even on Windows. + const globPattern = path + .join(cedarPaths.packages, '*') + .replaceAll('\\', '/') + // TODO: See if we can make this async + const allPackagePaths = fs.globSync(globPattern) + + workspaces = [ + ...workspaces, + 'packages/*', + ...allPackagePaths.map((p) => p.split('/').at(-1)), + ...allPackagePaths.map((p) => p.split('/').slice(-2).join('/')), + ...allPackagePaths.map((p) => { + const packageJsonPath = path.join(p, 'package.json') + return JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')).name + }), + ] + } + return workspaces }