Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add monorepo support to the CLI #7059

Merged
merged 1 commit into from
Nov 17, 2024
Merged
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
7 changes: 7 additions & 0 deletions .changeset/tiny-pants-scream.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@builder.io/qwik': minor
---

FEAT: add monorepo support to the `qwik add` command by adding a `projectDir` param

That way you can run `qwik add --projectDir=packages/my-package` and it will add the feature to the specified project/package (sub) folder, instead of the root folder.
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -764,7 +764,7 @@ jobs:
- run: pnpm install --frozen-lockfile

- name: CLI E2E Tests
run: pnpm run test.e2e-cli
run: pnpm run test.e2e.cli

########### LINT PACKAGES ############
lint-package:
Expand Down
6 changes: 3 additions & 3 deletions e2e/qwik-cli-e2e/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ This package provides isolated E2E tests by generating a new application with lo

## Description

Tests can be invoked by running `pnpm run test.e2e-cli`.
Tests can be invoked by running `pnpm run test.e2e.cli`.

**Note that running E2E tests requires the workspace projects to be prebuilt manually!**

Expand All @@ -16,8 +16,8 @@ E2E project does the following internally:

- By default `outputDir` is an auto-generated one using `tmp` npm package. The application that is created here will be removed after the test is executed
- It is possible to install into custom folder using environment variable `TEMP_E2E_PATH`. Here's how the command would look like in this case:
- with absolute path `TEMP_E2E_PATH=/Users/name/projects/tests pnpm run test.e2e-cli`
- with path relative to the qwik workspace `TEMP_E2E_PATH=temp/e2e-folder pnpm run test.e2e-cli`
- with absolute path `TEMP_E2E_PATH=/Users/name/projects/tests pnpm run test.e2e.cli`
- with path relative to the qwik workspace `TEMP_E2E_PATH=temp/e2e-folder pnpm run test.e2e.cli`

Note that provided folder should exist. If custom path is used, generated application will not be removed after the test completes, which is helpful for debugging.

Expand Down
2 changes: 1 addition & 1 deletion e2e/qwik-cli-e2e/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,6 @@
"private": true,
"scripts": {
"e2e": "vitest run --config=vite.config.ts",
"e2e:watch": "vitest watch --config=vite.config.ts"
"e2e.watch": "vitest watch --config=vite.config.ts"
}
}
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@
"execa": "8.0.1",
"express": "4.20.0",
"install": "0.13.0",
"memfs": "4.14.0",
"monaco-editor": "0.45.0",
"mri": "1.2.0",
"path-browserify": "1.0.1",
Expand Down Expand Up @@ -241,7 +242,7 @@
"start": "concurrently \"npm:build.watch\" \"npm:tsc.watch\" -n build,tsc -c green,cyan",
"test": "pnpm build.full && pnpm test.unit && pnpm test.e2e",
"test.e2e": "pnpm test.e2e.chromium && pnpm test.e2e.webkit",
"test.e2e-cli": "pnpm --filter qwik-cli-e2e e2e",
"test.e2e.cli": "pnpm --filter qwik-cli-e2e e2e",
"test.e2e.chromium": "playwright test starters --browser=chromium --config starters/playwright.config.ts",
"test.e2e.chromium.debug": "PWDEBUG=1 playwright test starters --browser=chromium --config starters/playwright.config.ts",
"test.e2e.city": "playwright test starters/e2e/qwikcity --browser=chromium --config starters/playwright.config.ts",
Expand Down
2 changes: 2 additions & 0 deletions packages/docs/src/routes/docs/integrations/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ bun run qwik add

This command will prompt you to select the integration you want to add. Once selected, the integration will be added to your application and you can start using it.

> **For Monorepos:** you can add integrations to a specific package by running the command with the `--projectDir=some/subDir` param.

### List of possible integrations

<IntegrationsList />
Expand Down
16 changes: 10 additions & 6 deletions packages/qwik/src/cli/add/run-add-interactive.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { intro, isCancel, log, outro, select, spinner } from '@clack/prompts';
import { bgBlue, bgMagenta, blue, bold, cyan, magenta } from 'kleur/colors';
import type { IntegrationData, UpdateAppResult } from '../types';
import type { IntegrationData, UpdateAppOptions, UpdateAppResult } from '../types';
import { loadIntegrations, sortIntegrationsAndReturnAsClackOptions } from '../utils/integrations';
import { bye, getPackageManager, note, panic, printHeader } from '../utils/utils';
import { bye, getPackageManager, note, panic } from '../utils/utils';

/* eslint-disable no-console */
import { relative } from 'node:path';
Expand All @@ -16,8 +16,6 @@ export async function runAddInteractive(app: AppCommand, id: string | undefined)
const integrations = await loadIntegrations();
let integration: IntegrationData | undefined;

printHeader();

if (typeof id === 'string') {
// cli passed a flag with the integration id to add
integration = integrations.find((i) => i.id === id);
Expand Down Expand Up @@ -62,11 +60,17 @@ export async function runAddInteractive(app: AppCommand, id: string | undefined)
runInstall = true;
}

const result = await updateApp(pkgManager, {
const updateAppOptions: UpdateAppOptions = {
rootDir: app.rootDir,
integration: integration.id,
installDeps: runInstall,
});
};
const projectDir = app.getArg('projectDir');
if (projectDir) {
updateAppOptions.projectDir = projectDir;
}

const result = await updateApp(pkgManager, updateAppOptions);

if (app.getArg('skipConfirmation') !== 'true') {
await logUpdateAppResult(pkgManager, result);
Expand Down
20 changes: 13 additions & 7 deletions packages/qwik/src/cli/add/update-app.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import type { FsUpdates, UpdateAppOptions, UpdateAppResult } from '../types';
import { dirname } from 'node:path';
import { log, spinner } from '@clack/prompts';
import { bgRed, cyan } from 'kleur/colors';
import fs from 'node:fs';
import { panic } from '../utils/utils';
import { loadIntegrations } from '../utils/integrations';
import { dirname } from 'node:path';
import type { FsUpdates, UpdateAppOptions, UpdateAppResult } from '../types';
import { installDeps } from '../utils/install-deps';
import { loadIntegrations } from '../utils/integrations';
import { panic } from '../utils/utils';
import { mergeIntegrationDir } from './update-files';
import { updateViteConfigs } from './update-vite-config';
import { bgRed, cyan } from 'kleur/colors';
import { spinner, log } from '@clack/prompts';

export async function updateApp(pkgManager: string, opts: UpdateAppOptions) {
const integrations = await loadIntegrations();
Expand All @@ -29,7 +29,13 @@ export async function updateApp(pkgManager: string, opts: UpdateAppOptions) {
};
}

await mergeIntegrationDir(fileUpdates, opts, integration.dir, opts.rootDir);
await mergeIntegrationDir(
fileUpdates,
opts,
integration.dir,
opts.rootDir,
integration.alwaysInRoot
);

if ((globalThis as any).CODE_MOD) {
await updateViteConfigs(fileUpdates, integration, opts.rootDir);
Expand Down
53 changes: 41 additions & 12 deletions packages/qwik/src/cli/add/update-files.ts
Original file line number Diff line number Diff line change
@@ -1,49 +1,54 @@
import fs from 'node:fs';
import type { FsUpdates, UpdateAppOptions } from '../types';
import { extname, join } from 'node:path';
import type { FsUpdates, UpdateAppOptions } from '../types';
import { getPackageManager } from '../utils/utils';

export async function mergeIntegrationDir(
fileUpdates: FsUpdates,
opts: UpdateAppOptions,
srcDir: string,
destDir: string
destDir: string,
alwaysInRoot?: string[]
) {
const items = await fs.promises.readdir(srcDir);
await Promise.all(
items.map(async (itemName) => {
const destName = itemName === 'gitignore' ? '.gitignore' : itemName;
const ext = extname(destName);
const srcChildPath = join(srcDir, itemName);
const destChildPath = join(destDir, destName);

const destRootPath = join(destDir, destName);

const s = await fs.promises.stat(srcChildPath);

if (s.isDirectory()) {
await mergeIntegrationDir(fileUpdates, opts, srcChildPath, destChildPath);
await mergeIntegrationDir(fileUpdates, opts, srcChildPath, destRootPath, alwaysInRoot);
} else if (s.isFile()) {
const finalDestPath = getFinalDestPath(opts, destRootPath, destDir, destName, alwaysInRoot);

if (destName === 'package.json') {
await mergePackageJsons(fileUpdates, srcChildPath, destChildPath);
await mergePackageJsons(fileUpdates, srcChildPath, destRootPath);
} else if (destName === 'settings.json') {
await mergeJsons(fileUpdates, srcChildPath, destChildPath);
await mergeJsons(fileUpdates, srcChildPath, finalDestPath);
} else if (destName === 'README.md') {
await mergeReadmes(fileUpdates, srcChildPath, destChildPath);
await mergeReadmes(fileUpdates, srcChildPath, finalDestPath);
} else if (
destName === '.gitignore' ||
destName === '.prettierignore' ||
destName === '.eslintignore'
) {
await mergeIgnoresFile(fileUpdates, srcChildPath, destChildPath);
await mergeIgnoresFile(fileUpdates, srcChildPath, destRootPath);
} else if (ext === '.css') {
await mergeCss(fileUpdates, srcChildPath, destChildPath, opts);
} else if (fs.existsSync(destChildPath)) {
await mergeCss(fileUpdates, srcChildPath, finalDestPath, opts);
} else if (fs.existsSync(finalDestPath)) {
fileUpdates.files.push({
path: destChildPath,
path: finalDestPath,
content: await fs.promises.readFile(srcChildPath),
type: 'overwrite',
});
} else {
fileUpdates.files.push({
path: destChildPath,
path: finalDestPath,
content: await fs.promises.readFile(srcChildPath),
type: 'create',
});
Expand All @@ -53,6 +58,30 @@ export async function mergeIntegrationDir(
);
}

function getFinalDestPath(
opts: UpdateAppOptions,
destRootPath: string,
destDir: string,
destName: string,
alwaysInRoot?: string[]
) {
// If the integration has a projectDir, copy the files to the projectDir
// Unless that path is part of "alwaysInRoot"
const projectDir = opts.projectDir ? opts.projectDir : '';
const rootDirEndIndex = destDir.indexOf(opts.rootDir) + opts.rootDir.length;
const destWithoutRoot = destDir.slice(rootDirEndIndex);

const destChildPath = join(opts.rootDir, projectDir, destWithoutRoot, destName);

const finalDestPath =
alwaysInRoot &&
alwaysInRoot.some((rootItem) => destName.includes(rootItem) || destDir.includes(rootItem))
? destRootPath
: destChildPath;

return finalDestPath;
}

async function mergePackageJsons(fileUpdates: FsUpdates, srcPath: string, destPath: string) {
const srcContent = await fs.promises.readFile(srcPath, 'utf-8');
try {
Expand Down
98 changes: 98 additions & 0 deletions packages/qwik/src/cli/add/update-files.unit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { fs } from 'memfs';
import { join } from 'path';
import { describe, expect, test, vi } from 'vitest';
import type { FsUpdates, UpdateAppOptions } from '../types';
import { mergeIntegrationDir } from './update-files';

vi.mock('node:fs', () => ({
default: fs,
}));

function setup() {
const fakeSrcDir = 'srcDir/subSrcDir';
createFakeFiles(fakeSrcDir);

const fakeDestDir = 'destDir/subDestDir';

const fakeFileUpdates: FsUpdates = {
files: [],
installedDeps: {},
installedScripts: [],
};

const fakeOpts: UpdateAppOptions = {
rootDir: fakeDestDir,
integration: 'integration',
};

return {
fakeSrcDir,
fakeDestDir,
fakeFileUpdates,
fakeOpts,
};
}

function createFakeFiles(dir: string) {
// Create fake src files
fs.mkdirSync(join(dir, 'src'), { recursive: true });
fs.writeFileSync(join(dir, 'fake.ts'), 'fake file');
fs.writeFileSync(join(dir, 'package.json'), '{"name": "fake"}');
fs.writeFileSync(join(dir, 'src', 'global.css'), 'p{color: red}');
}

describe('mergeIntegrationDir', () => {
test('should merge integration directory', async () => {
const { fakeSrcDir, fakeDestDir, fakeFileUpdates, fakeOpts } = setup();

await mergeIntegrationDir(fakeFileUpdates, fakeOpts, fakeSrcDir, fakeDestDir);

const actualResults = fakeFileUpdates.files.map((f) => f.path);
const expectedResults = [
'destDir/subDestDir/fake.ts',
'destDir/subDestDir/package.json',
'destDir/subDestDir/src/global.css',
];

expect(actualResults).toEqual(expectedResults);
});

test('should merge integration directory in a monorepo', async () => {
const { fakeSrcDir, fakeDestDir, fakeFileUpdates, fakeOpts } = setup();

// Create a global file in the destination director
const monorepoSubDir = join(fakeDestDir, 'apps', 'subpackage', 'src');
fs.mkdirSync(monorepoSubDir, { recursive: true });
fs.writeFileSync(join(monorepoSubDir, 'global.css'), '/* CSS */');

// Add a file that should stay in the root
fs.writeFileSync(join(fakeSrcDir, 'should-stay-in-root.ts'), 'fake file');

// Creating a folder that should stay in the root
fs.mkdirSync(join(fakeSrcDir, 'should-stay'), { recursive: true });
fs.writeFileSync(join(fakeSrcDir, 'should-stay', 'should-also-stay.ts'), 'fake file');

fakeOpts.projectDir = 'apps/subpackage';
fakeOpts.installDeps = true;
const fakeAlwaysInRoot = ['should-stay-in-root.ts', 'should-stay'];

await mergeIntegrationDir(fakeFileUpdates, fakeOpts, fakeSrcDir, fakeDestDir, fakeAlwaysInRoot);

const actualResults = fakeFileUpdates.files.map((f) => f.path);
const expectedResults = [
`destDir/subDestDir/apps/subpackage/fake.ts`,
`destDir/subDestDir/should-stay-in-root.ts`,
`destDir/subDestDir/package.json`,
`destDir/subDestDir/should-stay/should-also-stay.ts`,
`destDir/subDestDir/apps/subpackage/src/global.css`,
];

expect(actualResults).toEqual(expectedResults);

const actualGlobalCssContent = fakeFileUpdates.files.find(
(f) => f.path === `destDir/subDestDir/apps/subpackage/src/global.css`
)?.content;

expect(actualGlobalCssContent).toBe('p{color: red}\n\n/* CSS */\n');
});
});
22 changes: 14 additions & 8 deletions packages/qwik/src/cli/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export interface UpdateAppOptions {
rootDir: string;
integration: string;
installDeps?: boolean;
projectDir?: string;
}

export interface UpdateAppResult {
Expand Down Expand Up @@ -43,6 +44,8 @@ export interface IntegrationData {
priority: number;
docs: string[];
viteConfig?: ViteConfigUpdates;
// Files and folders that should be copied to root ignoring `projectDir`
alwaysInRoot?: string[];
}

export type IntegrationType = 'app' | 'feature' | 'adapter';
Expand Down Expand Up @@ -77,14 +80,17 @@ export interface IntegrationPackageJson {
qwikTemplates?: string[];
types?: string;
type?: string;
__qwik__?: {
displayName?: string;
nextSteps?: NextSteps;
docs?: string[];
priority: number;
postInstall?: string;
viteConfig?: ViteConfigUpdates;
};
__qwik__?: QwikIntegrationConfig;
}

export interface QwikIntegrationConfig {
displayName?: string;
nextSteps?: NextSteps;
docs?: string[];
priority: number;
postInstall?: string;
viteConfig?: ViteConfigUpdates;
alwaysInRoot?: string[];
}

export interface EnsureImport {
Expand Down
Loading