diff --git a/.changeset/olive-bobcats-happen.md b/.changeset/olive-bobcats-happen.md new file mode 100644 index 0000000000..73780308f8 --- /dev/null +++ b/.changeset/olive-bobcats-happen.md @@ -0,0 +1,5 @@ +--- +'@redocly/cli': patch +--- + +Improved CLI install speed by bundling the CLI into a dependency-free package. diff --git a/.dockerignore b/.dockerignore index 0593c83433..4480b16f8f 100644 --- a/.dockerignore +++ b/.dockerignore @@ -3,5 +3,7 @@ !tsconfig.build.json !tsconfig.json !package-lock.json +!README.md +!LICENSE.md !packages/* !scripts/local-pack.sh diff --git a/.github/workflows/performance.yaml b/.github/workflows/performance.yaml index d17fd79f01..6efc80dcce 100644 --- a/.github/workflows/performance.yaml +++ b/.github/workflows/performance.yaml @@ -22,6 +22,7 @@ jobs: with: node-version: 24 cache: npm + - uses: oven-sh/setup-bun@v2 - name: Install Dependencies run: npm ci - name: Install External diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 0a815cf2cf..dc098affcb 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -37,6 +37,9 @@ jobs: - name: Install Dependencies run: npm ci + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + - name: Create Release Pull Request or Publish to npm id: changesets uses: RomanHotsiy/changesets-action@v1 @@ -200,6 +203,9 @@ jobs: cache: npm registry-url: https://registry.npmjs.org + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + - name: Update package versions run: | TIMESTAMP=$(date +%s) @@ -255,4 +261,7 @@ jobs: sleep 10 cd ../cli - npm publish --tag snapshot + PUBLISH_DIR=$(jq -r '.publishConfig.directory // ".publish"' package.json) + npm run prepare:publish-dir + npm publish "./$PUBLISH_DIR" --tag snapshot + npm run clean:publish-dir diff --git a/.github/workflows/smoke.yaml b/.github/workflows/smoke.yaml index 047b1fc0e7..fccc03cbf0 100644 --- a/.github/workflows/smoke.yaml +++ b/.github/workflows/smoke.yaml @@ -20,6 +20,7 @@ jobs: with: node-version: 24 cache: npm + - uses: oven-sh/setup-bun@v2 - name: Install dependencies run: npm ci - name: Prepare Smoke diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 4e7e9b8db7..85d1ee7cc3 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -32,6 +32,9 @@ jobs: continue-on-error: true # Do not fail if there is an error during reporting uses: davelosert/vitest-coverage-report-action@v2 + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + - name: E2E Tests run: npm run e2e diff --git a/.gitignore b/.gitignore index 0163899902..b52b76e841 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,6 @@ output/ *.tsbuildinfo *.tgz redoc-static.html -packages/cli/README.md +packages/cli/.publish/ .env packages/respect-core/src/modules/runtime-expressions/abnf-parser.js diff --git a/Dockerfile b/Dockerfile index 29976eed7d..cd8993c80b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,8 +7,6 @@ RUN apk add --no-cache jq git && \ npm run prepare && \ npm run pack:prepare && \ npm install --global redocly-cli.tgz && \ - cp packages/cli/src/commands/build-docs/template.hbs \ - /usr/local/lib/node_modules/@redocly/cli/lib/commands/build-docs/ && \ # Clean up to reduce image size npm cache clean --force && rm -rf /build diff --git a/package.json b/package.json index 33443f6396..3b6ea1f67a 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "scripts": { "test": "npm run compile && npm run typecheck && npm run unit && npm run e2e", "unit": "VITEST_SUITE=unit vitest run", - "e2e": "VITEST_SUITE=e2e vitest run", + "e2e": "npm run compile:cli:bundle && VITEST_SUITE=e2e vitest run", "smoke:rebilly": "VITEST_SUITE=smoke-rebilly vitest run", "format": "oxfmt .", "format:check": "oxfmt --check .", @@ -24,8 +24,9 @@ "prepare": "husky && npm run compile", "cli": "node --import tsx packages/cli/src/index.ts", "precli": "npm run compile", - "release": "changeset publish", + "release": "node ./scripts/release-publish.mjs", "pack:prepare": "./scripts/local-pack.sh", + "compile:cli:bundle": "npm --workspace packages/cli run compile", "respect:parser:generate": "pegjs --format es --output packages/respect-core/lib/modules/runtime-expressions/abnf-parser.js packages/respect-core/src/modules/runtime-expressions/abnf-parser.pegjs && cp packages/respect-core/lib/modules/runtime-expressions/abnf-parser.js packages/respect-core/src/modules/runtime-expressions/abnf-parser.js", "build-docs:copy-assets": "cp packages/cli/src/commands/build-docs/template.hbs packages/cli/lib/commands/build-docs/template.hbs ", "json-server": "json-server --watch tests/e2e/respect/local-json-server/fake-db.json --port 3000 --host 0.0.0.0" diff --git a/packages/cli/package.json b/packages/cli/package.json index 473c35ad0e..1411de76da 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -14,10 +14,12 @@ }, "engineStrict": true, "scripts": { - "compile": "tsc", - "copy-assets": "cp src/commands/build-docs/template.hbs lib/commands/build-docs/template.hbs ", - "prepack": "npm run copy-assets", - "prepublishOnly": "npm run copy-assets && cp ../../README.md ." + "compile": "node ./scripts/compile-bundle.mjs", + "prepare:publish-dir": "node ./scripts/prepare-publish-dir.mjs", + "clean:publish-dir": "rm -rf .publish" + }, + "publishConfig": { + "directory": ".publish" }, "repository": { "type": "git", diff --git a/packages/cli/scripts/compile-bundle.mjs b/packages/cli/scripts/compile-bundle.mjs new file mode 100644 index 0000000000..c2bf7cd329 --- /dev/null +++ b/packages/cli/scripts/compile-bundle.mjs @@ -0,0 +1,49 @@ +import { spawnSync } from 'node:child_process'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const packageDir = path.resolve(scriptDir, '..'); +const bunBuildArgs = [ + 'build', + 'src/index.ts', + '--target', + 'node', + '--define', + 'process.env.NODE_ENV=process.env.NODE_ENV', + '--outfile', + 'lib/index.js', +]; + +export function compileCliBundle() { + const directBun = spawnSync('bun', bunBuildArgs, { + cwd: packageDir, + stdio: 'inherit', + }); + + if (!directBun.error) { + return directBun; + } + + if (directBun.error.code !== 'ENOENT') { + throw directBun.error; + } + + console.warn('Bun binary not found on PATH; falling back to `npx bun@1.3.10`.'); + return spawnSync('npx', ['--yes', 'bun@1.3.10', ...bunBuildArgs], { + cwd: packageDir, + stdio: 'inherit', + }); +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const compileResult = compileCliBundle(); + + if (compileResult.error) { + throw compileResult.error; + } + + if (compileResult.status !== 0) { + process.exit(compileResult.status ?? 1); + } +} diff --git a/packages/cli/scripts/prepare-publish-dir.mjs b/packages/cli/scripts/prepare-publish-dir.mjs new file mode 100644 index 0000000000..605539573e --- /dev/null +++ b/packages/cli/scripts/prepare-publish-dir.mjs @@ -0,0 +1,64 @@ +import { copyFileSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { compileCliBundle } from './compile-bundle.mjs'; + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const packageDir = path.resolve(scriptDir, '..'); +const rootDir = path.resolve(packageDir, '..', '..'); + +const packageJsonPath = path.join(packageDir, 'package.json'); +const readmeSourcePath = path.join(rootDir, 'README.md'); +const licenseSourcePath = path.join(rootDir, 'LICENSE.md'); +const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8')); +const publishDirName = packageJson.publishConfig?.directory ?? '.publish'; +const publishDir = path.join(packageDir, publishDirName); + +const compileResult = compileCliBundle(); + +if (compileResult.error) { + throw compileResult.error; +} + +if (compileResult.status !== 0) { + process.exit(compileResult.status ?? 1); +} + +rmSync(publishDir, { recursive: true, force: true }); +mkdirSync(publishDir, { recursive: true }); + +const binEntrypoint = packageJson.bin?.redocly ?? 'bin/cli.js'; +const binTargetPath = path.join(publishDir, binEntrypoint); + +mkdirSync(path.dirname(binTargetPath), { recursive: true }); +mkdirSync(path.join(publishDir, 'lib'), { recursive: true }); + +copyFileSync(path.join(packageDir, binEntrypoint), binTargetPath); +copyFileSync(path.join(packageDir, 'lib/index.js'), path.join(publishDir, 'lib/index.js')); +copyFileSync(readmeSourcePath, path.join(publishDir, 'README.md')); +copyFileSync(licenseSourcePath, path.join(publishDir, 'LICENSE')); + +const publishPackageJson = { + name: packageJson.name, + version: packageJson.version, + description: packageJson.description, + license: packageJson.license, + type: packageJson.type, + repository: packageJson.repository, + homepage: packageJson.homepage, + keywords: packageJson.keywords, + contributors: packageJson.contributors, + engines: packageJson.engines, + engineStrict: packageJson.engineStrict, + bin: { + redocly: binEntrypoint, + openapi: binEntrypoint, + }, + files: [binEntrypoint, 'lib/index.js', 'README.md', 'LICENSE'], +}; + +writeFileSync( + path.join(publishDir, 'package.json'), + `${JSON.stringify(publishPackageJson, null, 2)}\n` +); diff --git a/packages/cli/src/commands/build-docs/index.ts b/packages/cli/src/commands/build-docs/index.ts index f63bb393aa..83e7c79c55 100644 --- a/packages/cli/src/commands/build-docs/index.ts +++ b/packages/cli/src/commands/build-docs/index.ts @@ -1,18 +1,16 @@ import { isAbsoluteUrl, logger } from '@redocly/openapi-core'; import { writeFileSync, mkdirSync } from 'node:fs'; -import { createRequire } from 'node:module'; import { dirname, resolve } from 'node:path'; import { performance } from 'node:perf_hooks'; import { default as redoc } from 'redoc'; import { exitWithError } from '../../utils/error.js'; import { getExecutionTime, getFallbackApisOrExit } from '../../utils/miscellaneous.js'; +import { redocVersion } from '../../utils/package.js'; import type { CommandArgs } from '../../wrapper.js'; import type { BuildDocsArgv } from './types.js'; import { getObjectOrJSON, getPageHTML } from './utils.js'; -const packageJson = createRequire(import.meta.url ?? __dirname)('../../../package.json'); - export const handlerBuildCommand = async ({ argv, config, @@ -31,8 +29,6 @@ export const handlerBuildCommand = async ({ redocOptions: getObjectOrJSON(argv.theme?.openapi, config.forAlias(alias)), }; - const redocCurrentVersion = packageJson.dependencies.redoc; - try { const elapsed = getExecutionTime(startedAt); @@ -43,7 +39,7 @@ export const handlerBuildCommand = async ({ const pageHTML = await getPageHTML( api, pathToApi, - { ...options, redocCurrentVersion }, + { ...options, redocCurrentVersion: redocVersion }, argv.config ); diff --git a/packages/cli/src/commands/build-docs/utils.ts b/packages/cli/src/commands/build-docs/utils.ts index 1838cd15de..a0fbd8fd29 100644 --- a/packages/cli/src/commands/build-docs/utils.ts +++ b/packages/cli/src/commands/build-docs/utils.ts @@ -15,6 +15,31 @@ import type { BuildDocsOptions } from './types.js'; const __internalDirname = import.meta.url ? path.dirname(url.fileURLToPath(import.meta.url)) : __dirname; +const DEFAULT_TEMPLATE_FILE_NAME = path.join(__internalDirname, './template.hbs'); +const DEFAULT_TEMPLATE_SOURCE = ` + + +
+ +OK
{- "message": "string"
}{- "message": "string"
}