From 3c13dc1ab85ad477f2025c5f5da134862995b209 Mon Sep 17 00:00:00 2001 From: martmull Date: Thu, 2 Jul 2026 10:23:45 +0200 Subject: [PATCH] feat(twenty-sdk): include app readme in published package (#22431) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Context When running `yarn twenty app:publish`, no README was included in the npm-published app package. Closes twentyhq/core-team-issues#2632 ## What changed - Added `copy-readme-to-output.ts`, which finds the app's root readme file (matched case-insensitively, preferring the markdown variant, mirroring how npm ranks README candidates) and copies it into the build output directory (`.twenty/output/`). - Wired `copyReadmeToOutput` into `buildApplication` — the shared build path used by `publish`, `build`, and `dev` — so the readme is present when `npm publish`/`npm pack` runs from the output directory. npm only ships a README when the file lives in the package root, which for published apps is `.twenty/output/`. The readme is not tracked in the manifest checksums; it is a pure npm packaging artifact, so it is only copied into the output directory and does not affect app installation/validation. ## Tests - Added unit tests for `findReadmeFileName` (case-insensitivity, markdown preference, ignoring unrelated files) and `copyReadmeToOutput` (copies the readme into the output dir; no-ops when the app has no readme). https://claude.ai/code/session_01Qje6VemuMk8nunn6yVJNtL --- _Generated by [Claude Code](https://claude.ai/code/session_01Qje6VemuMk8nunn6yVJNtL)_ Review in cubic --- .../__tests__/copy-readme-to-output.test.ts | 63 +++++++++++++++++++ .../build/common/build-application.ts | 3 + .../build/common/copy-readme-to-output.ts | 35 +++++++++++ 3 files changed, 101 insertions(+) create mode 100644 packages/twenty-sdk/src/cli/utilities/build/common/__tests__/copy-readme-to-output.test.ts create mode 100644 packages/twenty-sdk/src/cli/utilities/build/common/copy-readme-to-output.ts diff --git a/packages/twenty-sdk/src/cli/utilities/build/common/__tests__/copy-readme-to-output.test.ts b/packages/twenty-sdk/src/cli/utilities/build/common/__tests__/copy-readme-to-output.test.ts new file mode 100644 index 0000000000..87de480e15 --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/build/common/__tests__/copy-readme-to-output.test.ts @@ -0,0 +1,63 @@ +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { OUTPUT_DIR } from 'twenty-shared/application'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + copyReadmeToOutput, + findReadmeFileName, +} from '@/cli/utilities/build/common/copy-readme-to-output'; +import { pathExists } from '@/cli/utilities/file/fs-utils'; + +describe('findReadmeFileName', () => { + it('should match readme files regardless of case', () => { + expect(findReadmeFileName(['README.md'])).toBe('README.md'); + expect(findReadmeFileName(['readme.md'])).toBe('readme.md'); + expect(findReadmeFileName(['Readme.txt'])).toBe('Readme.txt'); + expect(findReadmeFileName(['README'])).toBe('README'); + }); + + it('should prefer the markdown readme over other variants', () => { + expect(findReadmeFileName(['README.txt', 'README.md'])).toBe('README.md'); + }); + + it('should ignore unrelated files', () => { + expect( + findReadmeFileName(['index.ts', 'package.json', 'readme-notes.md']), + ).toBeUndefined(); + }); +}); + +describe('copyReadmeToOutput', () => { + let appPath: string; + + beforeEach(async () => { + appPath = await mkdtemp(join(tmpdir(), 'readme-test-')); + }); + + afterEach(async () => { + await rm(appPath, { recursive: true, force: true }); + }); + + it('should copy the readme into the output directory', async () => { + await writeFile(join(appPath, 'README.md'), '# My App', 'utf-8'); + + await copyReadmeToOutput(appPath); + + const copiedReadme = await readFile( + join(appPath, OUTPUT_DIR, 'README.md'), + 'utf-8', + ); + + expect(copiedReadme).toBe('# My App'); + }); + + it('should do nothing when the app has no readme', async () => { + await writeFile(join(appPath, 'package.json'), '{}', 'utf-8'); + + await copyReadmeToOutput(appPath); + + expect(await pathExists(join(appPath, OUTPUT_DIR))).toBe(false); + }); +}); diff --git a/packages/twenty-sdk/src/cli/utilities/build/common/build-application.ts b/packages/twenty-sdk/src/cli/utilities/build/common/build-application.ts index e30b972e82..b24a05e84d 100644 --- a/packages/twenty-sdk/src/cli/utilities/build/common/build-application.ts +++ b/packages/twenty-sdk/src/cli/utilities/build/common/build-application.ts @@ -8,6 +8,7 @@ import { } from 'twenty-shared/application'; import { FileFolder } from 'twenty-shared/types'; +import { copyReadmeToOutput } from '@/cli/utilities/build/common/copy-readme-to-output'; import { type GeneratedAsset } from '@/cli/utilities/build/cover/generated-asset.type'; import { esbuildOneShotBuild } from '@/cli/utilities/build/common/esbuild-one-shot-build'; import { LOGIC_FUNCTION_EXTERNAL_MODULES } from '@/cli/utilities/build/common/esbuild-watcher'; @@ -153,6 +154,8 @@ export const buildApplication = async ( }); } + await copyReadmeToOutput(options.appPath); + return { builtFileInfos }; }; diff --git a/packages/twenty-sdk/src/cli/utilities/build/common/copy-readme-to-output.ts b/packages/twenty-sdk/src/cli/utilities/build/common/copy-readme-to-output.ts new file mode 100644 index 0000000000..6a86d8b7d5 --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/build/common/copy-readme-to-output.ts @@ -0,0 +1,35 @@ +import { readdir } from 'node:fs/promises'; +import { join } from 'node:path'; +import { OUTPUT_DIR } from 'twenty-shared/application'; + +import { copy, ensureDir } from '@/cli/utilities/file/fs-utils'; + +// npm only ships a README when the file lives in the package root, so the +// app's readme has to be copied into the build output that gets published. +const README_FILE_NAME_REGEX = /^readme(\.[^.]+)?$/i; + +export const findReadmeFileName = ( + entries: string[], +): string | undefined => { + const readmeFileNames = entries.filter((entry) => + README_FILE_NAME_REGEX.test(entry), + ); + + // Prefer markdown, matching how npm ranks README candidates. + return ( + readmeFileNames.find((entry) => /\.md$/i.test(entry)) ?? readmeFileNames[0] + ); +}; + +export const copyReadmeToOutput = async (appPath: string): Promise => { + const readmeFileName = findReadmeFileName(await readdir(appPath)); + + if (readmeFileName === undefined) { + return; + } + + const outputDir = join(appPath, OUTPUT_DIR); + + await ensureDir(outputDir); + await copy(join(appPath, readmeFileName), join(outputDir, readmeFileName)); +};