Skip to content

Commit 1cf13bc

Browse files
committed
add projectDir param to the cli
1 parent cda8c72 commit 1cf13bc

File tree

19 files changed

+285
-70
lines changed

19 files changed

+285
-70
lines changed

.changeset/tiny-pants-scream.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@builder.io/qwik': minor
3+
---
4+
5+
FEAT: add monorepo support to the `qwik add` command by adding a `projectDir` param
6+
7+
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.

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -764,7 +764,7 @@ jobs:
764764
- run: pnpm install --frozen-lockfile
765765

766766
- name: CLI E2E Tests
767-
run: pnpm run test.e2e-cli
767+
run: pnpm run test.e2e.cli
768768

769769
########### LINT PACKAGES ############
770770
lint-package:

e2e/qwik-cli-e2e/README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ This package provides isolated E2E tests by generating a new application with lo
44

55
## Description
66

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

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

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

1717
- 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
1818
- 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:
19-
- with absolute path `TEMP_E2E_PATH=/Users/name/projects/tests pnpm run test.e2e-cli`
20-
- with path relative to the qwik workspace `TEMP_E2E_PATH=temp/e2e-folder pnpm run test.e2e-cli`
19+
- with absolute path `TEMP_E2E_PATH=/Users/name/projects/tests pnpm run test.e2e.cli`
20+
- with path relative to the qwik workspace `TEMP_E2E_PATH=temp/e2e-folder pnpm run test.e2e.cli`
2121

2222
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.
2323

e2e/qwik-cli-e2e/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,6 @@
66
"private": true,
77
"scripts": {
88
"e2e": "vitest run --config=vite.config.ts",
9-
"e2e:watch": "vitest watch --config=vite.config.ts"
9+
"e2e.watch": "vitest watch --config=vite.config.ts"
1010
}
1111
}

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,7 @@
139139
"execa": "8.0.1",
140140
"express": "4.20.0",
141141
"install": "0.13.0",
142+
"memfs": "4.14.0",
142143
"monaco-editor": "0.45.0",
143144
"mri": "1.2.0",
144145
"path-browserify": "1.0.1",
@@ -241,7 +242,7 @@
241242
"start": "concurrently \"npm:build.watch\" \"npm:tsc.watch\" -n build,tsc -c green,cyan",
242243
"test": "pnpm build.full && pnpm test.unit && pnpm test.e2e",
243244
"test.e2e": "pnpm test.e2e.chromium && pnpm test.e2e.webkit",
244-
"test.e2e-cli": "pnpm --filter qwik-cli-e2e e2e",
245+
"test.e2e.cli": "pnpm --filter qwik-cli-e2e e2e",
245246
"test.e2e.chromium": "playwright test starters --browser=chromium --config starters/playwright.config.ts",
246247
"test.e2e.chromium.debug": "PWDEBUG=1 playwright test starters --browser=chromium --config starters/playwright.config.ts",
247248
"test.e2e.city": "playwright test starters/e2e/qwikcity --browser=chromium --config starters/playwright.config.ts",

packages/docs/src/routes/docs/integrations/index.mdx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,8 @@ bun run qwik add
5555

5656
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.
5757

58+
> **For Monorepos:** you can add integrations to a specific package by running the command with the `--projectDir=some/subDir` param.
59+
5860
### List of possible integrations
5961

6062
<IntegrationsList />

packages/qwik/src/cli/add/run-add-interactive.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import { intro, isCancel, log, outro, select, spinner } from '@clack/prompts';
22
import { bgBlue, bgMagenta, blue, bold, cyan, magenta } from 'kleur/colors';
3-
import type { IntegrationData, UpdateAppResult } from '../types';
3+
import type { IntegrationData, UpdateAppOptions, UpdateAppResult } from '../types';
44
import { loadIntegrations, sortIntegrationsAndReturnAsClackOptions } from '../utils/integrations';
5-
import { bye, getPackageManager, note, panic, printHeader } from '../utils/utils';
5+
import { bye, getPackageManager, note, panic } from '../utils/utils';
66

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

19-
printHeader();
20-
2119
if (typeof id === 'string') {
2220
// cli passed a flag with the integration id to add
2321
integration = integrations.find((i) => i.id === id);
@@ -62,11 +60,17 @@ export async function runAddInteractive(app: AppCommand, id: string | undefined)
6260
runInstall = true;
6361
}
6462

65-
const result = await updateApp(pkgManager, {
63+
const updateAppOptions: UpdateAppOptions = {
6664
rootDir: app.rootDir,
6765
integration: integration.id,
6866
installDeps: runInstall,
69-
});
67+
};
68+
const projectDir = app.getArg('projectDir');
69+
if (projectDir) {
70+
updateAppOptions.projectDir = projectDir;
71+
}
72+
73+
const result = await updateApp(pkgManager, updateAppOptions);
7074

7175
if (app.getArg('skipConfirmation') !== 'true') {
7276
await logUpdateAppResult(pkgManager, result);

packages/qwik/src/cli/add/update-app.ts

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
1-
import type { FsUpdates, UpdateAppOptions, UpdateAppResult } from '../types';
2-
import { dirname } from 'node:path';
1+
import { log, spinner } from '@clack/prompts';
2+
import { bgRed, cyan } from 'kleur/colors';
33
import fs from 'node:fs';
4-
import { panic } from '../utils/utils';
5-
import { loadIntegrations } from '../utils/integrations';
4+
import { dirname } from 'node:path';
5+
import type { FsUpdates, UpdateAppOptions, UpdateAppResult } from '../types';
66
import { installDeps } from '../utils/install-deps';
7+
import { loadIntegrations } from '../utils/integrations';
8+
import { panic } from '../utils/utils';
79
import { mergeIntegrationDir } from './update-files';
810
import { updateViteConfigs } from './update-vite-config';
9-
import { bgRed, cyan } from 'kleur/colors';
10-
import { spinner, log } from '@clack/prompts';
1111

1212
export async function updateApp(pkgManager: string, opts: UpdateAppOptions) {
1313
const integrations = await loadIntegrations();
@@ -29,7 +29,13 @@ export async function updateApp(pkgManager: string, opts: UpdateAppOptions) {
2929
};
3030
}
3131

32-
await mergeIntegrationDir(fileUpdates, opts, integration.dir, opts.rootDir);
32+
await mergeIntegrationDir(
33+
fileUpdates,
34+
opts,
35+
integration.dir,
36+
opts.rootDir,
37+
integration.alwaysInRoot
38+
);
3339

3440
if ((globalThis as any).CODE_MOD) {
3541
await updateViteConfigs(fileUpdates, integration, opts.rootDir);

packages/qwik/src/cli/add/update-files.ts

Lines changed: 41 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,49 +1,54 @@
11
import fs from 'node:fs';
2-
import type { FsUpdates, UpdateAppOptions } from '../types';
32
import { extname, join } from 'node:path';
3+
import type { FsUpdates, UpdateAppOptions } from '../types';
44
import { getPackageManager } from '../utils/utils';
55

66
export async function mergeIntegrationDir(
77
fileUpdates: FsUpdates,
88
opts: UpdateAppOptions,
99
srcDir: string,
10-
destDir: string
10+
destDir: string,
11+
alwaysInRoot?: string[]
1112
) {
1213
const items = await fs.promises.readdir(srcDir);
1314
await Promise.all(
1415
items.map(async (itemName) => {
1516
const destName = itemName === 'gitignore' ? '.gitignore' : itemName;
1617
const ext = extname(destName);
1718
const srcChildPath = join(srcDir, itemName);
18-
const destChildPath = join(destDir, destName);
19+
20+
const destRootPath = join(destDir, destName);
21+
1922
const s = await fs.promises.stat(srcChildPath);
2023

2124
if (s.isDirectory()) {
22-
await mergeIntegrationDir(fileUpdates, opts, srcChildPath, destChildPath);
25+
await mergeIntegrationDir(fileUpdates, opts, srcChildPath, destRootPath, alwaysInRoot);
2326
} else if (s.isFile()) {
27+
const finalDestPath = getFinalDestPath(opts, destRootPath, destDir, destName, alwaysInRoot);
28+
2429
if (destName === 'package.json') {
25-
await mergePackageJsons(fileUpdates, srcChildPath, destChildPath);
30+
await mergePackageJsons(fileUpdates, srcChildPath, destRootPath);
2631
} else if (destName === 'settings.json') {
27-
await mergeJsons(fileUpdates, srcChildPath, destChildPath);
32+
await mergeJsons(fileUpdates, srcChildPath, finalDestPath);
2833
} else if (destName === 'README.md') {
29-
await mergeReadmes(fileUpdates, srcChildPath, destChildPath);
34+
await mergeReadmes(fileUpdates, srcChildPath, finalDestPath);
3035
} else if (
3136
destName === '.gitignore' ||
3237
destName === '.prettierignore' ||
3338
destName === '.eslintignore'
3439
) {
35-
await mergeIgnoresFile(fileUpdates, srcChildPath, destChildPath);
40+
await mergeIgnoresFile(fileUpdates, srcChildPath, destRootPath);
3641
} else if (ext === '.css') {
37-
await mergeCss(fileUpdates, srcChildPath, destChildPath, opts);
38-
} else if (fs.existsSync(destChildPath)) {
42+
await mergeCss(fileUpdates, srcChildPath, finalDestPath, opts);
43+
} else if (fs.existsSync(finalDestPath)) {
3944
fileUpdates.files.push({
40-
path: destChildPath,
45+
path: finalDestPath,
4146
content: await fs.promises.readFile(srcChildPath),
4247
type: 'overwrite',
4348
});
4449
} else {
4550
fileUpdates.files.push({
46-
path: destChildPath,
51+
path: finalDestPath,
4752
content: await fs.promises.readFile(srcChildPath),
4853
type: 'create',
4954
});
@@ -53,6 +58,30 @@ export async function mergeIntegrationDir(
5358
);
5459
}
5560

61+
function getFinalDestPath(
62+
opts: UpdateAppOptions,
63+
destRootPath: string,
64+
destDir: string,
65+
destName: string,
66+
alwaysInRoot?: string[]
67+
) {
68+
// If the integration has a projectDir, copy the files to the projectDir
69+
// Unless that path is part of "alwaysInRoot"
70+
const projectDir = opts.projectDir ? opts.projectDir : '';
71+
const rootDirEndIndex = destDir.indexOf(opts.rootDir) + opts.rootDir.length;
72+
const destWithoutRoot = destDir.slice(rootDirEndIndex);
73+
74+
const destChildPath = join(opts.rootDir, projectDir, destWithoutRoot, destName);
75+
76+
const finalDestPath =
77+
alwaysInRoot &&
78+
alwaysInRoot.some((rootItem) => destName.includes(rootItem) || destDir.includes(rootItem))
79+
? destRootPath
80+
: destChildPath;
81+
82+
return finalDestPath;
83+
}
84+
5685
async function mergePackageJsons(fileUpdates: FsUpdates, srcPath: string, destPath: string) {
5786
const srcContent = await fs.promises.readFile(srcPath, 'utf-8');
5887
try {
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import { fs } from 'memfs';
2+
import { join } from 'path';
3+
import { describe, expect, test, vi } from 'vitest';
4+
import type { FsUpdates, UpdateAppOptions } from '../types';
5+
import { mergeIntegrationDir } from './update-files';
6+
7+
vi.mock('node:fs', () => ({
8+
default: fs,
9+
}));
10+
11+
function setup() {
12+
const fakeSrcDir = 'srcDir/subSrcDir';
13+
createFakeFiles(fakeSrcDir);
14+
15+
const fakeDestDir = 'destDir/subDestDir';
16+
17+
const fakeFileUpdates: FsUpdates = {
18+
files: [],
19+
installedDeps: {},
20+
installedScripts: [],
21+
};
22+
23+
const fakeOpts: UpdateAppOptions = {
24+
rootDir: fakeDestDir,
25+
integration: 'integration',
26+
};
27+
28+
return {
29+
fakeSrcDir,
30+
fakeDestDir,
31+
fakeFileUpdates,
32+
fakeOpts,
33+
};
34+
}
35+
36+
function createFakeFiles(dir: string) {
37+
// Create fake src files
38+
fs.mkdirSync(join(dir, 'src'), { recursive: true });
39+
fs.writeFileSync(join(dir, 'fake.ts'), 'fake file');
40+
fs.writeFileSync(join(dir, 'package.json'), '{"name": "fake"}');
41+
fs.writeFileSync(join(dir, 'src', 'global.css'), 'p{color: red}');
42+
}
43+
44+
describe('mergeIntegrationDir', () => {
45+
test('should merge integration directory', async () => {
46+
const { fakeSrcDir, fakeDestDir, fakeFileUpdates, fakeOpts } = setup();
47+
48+
await mergeIntegrationDir(fakeFileUpdates, fakeOpts, fakeSrcDir, fakeDestDir);
49+
50+
const actualResults = fakeFileUpdates.files.map((f) => f.path);
51+
const expectedResults = [
52+
'destDir/subDestDir/fake.ts',
53+
'destDir/subDestDir/package.json',
54+
'destDir/subDestDir/src/global.css',
55+
];
56+
57+
expect(actualResults).toEqual(expectedResults);
58+
});
59+
60+
test('should merge integration directory in a monorepo', async () => {
61+
const { fakeSrcDir, fakeDestDir, fakeFileUpdates, fakeOpts } = setup();
62+
63+
// Create a global file in the destination director
64+
const monorepoSubDir = join(fakeDestDir, 'apps', 'subpackage', 'src');
65+
fs.mkdirSync(monorepoSubDir, { recursive: true });
66+
fs.writeFileSync(join(monorepoSubDir, 'global.css'), '/* CSS */');
67+
68+
// Add a file that should stay in the root
69+
fs.writeFileSync(join(fakeSrcDir, 'should-stay-in-root.ts'), 'fake file');
70+
71+
// Creating a folder that should stay in the root
72+
fs.mkdirSync(join(fakeSrcDir, 'should-stay'), { recursive: true });
73+
fs.writeFileSync(join(fakeSrcDir, 'should-stay', 'should-also-stay.ts'), 'fake file');
74+
75+
fakeOpts.projectDir = 'apps/subpackage';
76+
fakeOpts.installDeps = true;
77+
const fakeAlwaysInRoot = ['should-stay-in-root.ts', 'should-stay'];
78+
79+
await mergeIntegrationDir(fakeFileUpdates, fakeOpts, fakeSrcDir, fakeDestDir, fakeAlwaysInRoot);
80+
81+
const actualResults = fakeFileUpdates.files.map((f) => f.path);
82+
const expectedResults = [
83+
`destDir/subDestDir/apps/subpackage/fake.ts`,
84+
`destDir/subDestDir/should-stay-in-root.ts`,
85+
`destDir/subDestDir/package.json`,
86+
`destDir/subDestDir/should-stay/should-also-stay.ts`,
87+
`destDir/subDestDir/apps/subpackage/src/global.css`,
88+
];
89+
90+
expect(actualResults).toEqual(expectedResults);
91+
92+
const actualGlobalCssContent = fakeFileUpdates.files.find(
93+
(f) => f.path === `destDir/subDestDir/apps/subpackage/src/global.css`
94+
)?.content;
95+
96+
expect(actualGlobalCssContent).toBe('p{color: red}\n\n/* CSS */\n');
97+
});
98+
});

0 commit comments

Comments
 (0)