feat(twenty-sdk): include app readme in published package (#22431)

## 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)_

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22431?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
martmull
2026-07-02 10:23:45 +02:00
committed by GitHub
parent 7b682bced9
commit 3c13dc1ab8
3 changed files with 101 additions and 0 deletions
@@ -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);
});
});
@@ -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 };
};
@@ -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<void> => {
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));
};