From 57d89549737a5749c18db92effa07a23edd0972f Mon Sep 17 00:00:00 2001 From: Paul Rastoin <45004772+prastoin@users.noreply.github.com> Date: Thu, 5 Mar 2026 17:19:01 +0100 Subject: [PATCH] `[SDK]` Pure ESM (#18427) # Introduction While testing the sdk and overall apps in https://github.com/prastoin/twenty-app-hello-world Faced a lot of pure `CJS` external dependencies import issue Replaced all the cjs deps to either esm equivalent or node native replacement --- .cursor/rules/sdk-esm-dependencies.mdc | 53 ++++ packages/create-twenty-app/package.json | 2 +- packages/twenty-sdk/package.json | 13 +- .../manifest.integration.spec.ts | 7 +- .../app-dev/tests/entities.tests.ts | 6 +- .../app-dev/tests/manifest.tests.ts | 5 +- .../app-dev/tests/front-components.tests.ts | 4 +- .../app-dev/tests/logic-functions.tests.ts | 4 +- .../app-dev/tests/manifest.tests.ts | 8 +- .../src/cli/__tests__/constants/setupTest.ts | 10 +- .../utils/run-app-dev-in-process.util.ts | 7 +- .../src/cli/commands/entity/entity-add.ts | 54 ++-- .../build/common/build-application.ts | 38 +-- .../build/common/cleanup-removed-files.ts | 7 +- .../build/common/esbuild-result-processor.ts | 4 +- .../build/common/file-upload-watcher.ts | 21 +- ...form-to-remote-dom-worker-format-plugin.ts | 4 +- .../strip-comments-plugin.ts | 6 +- .../cli/utilities/build/common/tsc-watcher.ts | 5 +- .../build/manifest/manifest-build.ts | 4 +- .../manifest-extract-config-from-file.ts | 13 +- .../build/manifest/manifest-reader.ts | 10 +- .../build/manifest/manifest-writer.ts | 7 +- .../cli/utilities/client/client-service.ts | 34 ++- .../cli/utilities/config/config-service.ts | 20 +- .../dev/orchestrator/dev-mode-orchestrator.ts | 6 +- .../steps/upload-files-orchestrator-step.ts | 13 +- .../entity/entity-front-component-template.ts | 2 +- .../entity/entity-logic-function-template.ts | 2 +- .../entity-navigation-menu-item-template.ts | 2 +- .../utilities/entity/entity-role-template.ts | 2 +- .../utilities/entity/entity-skill-template.ts | 2 +- .../utilities/entity/entity-view-template.ts | 2 +- .../utilities/file/__tests__/fs-utils.test.ts | 234 ++++++++++++++++++ .../src/cli/utilities/file/file-find.ts | 5 +- .../src/cli/utilities/file/file-jsonc.ts | 4 +- .../src/cli/utilities/file/file-tarball.ts | 67 ----- .../src/cli/utilities/file/fs-utils.ts | 93 +++++++ .../src/cli/utilities/string/kebab-case.ts | 9 + packages/twenty-server/package.json | 1 + yarn.lock | 27 +- 41 files changed, 574 insertions(+), 243 deletions(-) create mode 100644 .cursor/rules/sdk-esm-dependencies.mdc create mode 100644 packages/twenty-sdk/src/cli/utilities/file/__tests__/fs-utils.test.ts delete mode 100644 packages/twenty-sdk/src/cli/utilities/file/file-tarball.ts create mode 100644 packages/twenty-sdk/src/cli/utilities/file/fs-utils.ts create mode 100644 packages/twenty-sdk/src/cli/utilities/string/kebab-case.ts diff --git a/.cursor/rules/sdk-esm-dependencies.mdc b/.cursor/rules/sdk-esm-dependencies.mdc new file mode 100644 index 0000000000..d16e9e06ba --- /dev/null +++ b/.cursor/rules/sdk-esm-dependencies.mdc @@ -0,0 +1,53 @@ +--- +description: ESM dependency guidelines for twenty-sdk and create-twenty-app packages +globs: ["packages/twenty-sdk/**", "packages/create-twenty-app/**"] +alwaysApply: false +--- + +# ESM Dependency Guidelines + +## Context + +`twenty-sdk` and `create-twenty-app` are published as dual-format npm packages (ESM `.mjs` + CJS `.cjs`). Dependencies listed in `dependencies` are **externalized** by the Vite/Rollup build — they are not bundled, and consumers resolve them from `node_modules` at runtime. + +This means **CJS-only dependencies break the ESM output**. When Rollup emits `import { foo } from 'cjs-package'`, Node.js ESM cannot resolve named exports from CommonJS modules, causing `SyntaxError: Named export 'foo' not found`. + +## Rules + +### Only add ESM-compatible dependencies + +Before adding a new dependency to `package.json`, verify it supports ESM: +- Check for `"type": "module"` in its `package.json` +- Or check for an `"exports"` map with ESM entries +- Or check for a `"module"` field pointing to an ESM build + +### Use native `node:fs/promises` for standard fs operations + +```typescript +// ✅ Import native fs functions directly +import { readFile, writeFile, mkdir, rm, cp } from 'node:fs/promises'; +import { createWriteStream, existsSync } from 'node:fs'; + +// ✅ Import only custom helpers from fs-utils (no native re-exports) +import { pathExists, ensureDir, emptyDir, copy, move, remove, readJson, writeJson, ensureFile } from '@/cli/utilities/file/fs-utils'; + +// ❌ Don't use fs-extra (CJS-only, breaks ESM bundle) +import * as fs from 'fs-extra'; + +// ❌ Don't use import * as fs from fs-utils (it doesn't re-export native fs) +import * as fs from '@/cli/utilities/file/fs-utils'; +``` + +### Use `@/cli/utilities/string/kebab-case` instead of lodash + +```typescript +// ✅ Use internal utility +import { kebabCase } from '@/cli/utilities/string/kebab-case'; + +// ❌ Don't use lodash single-function packages (CJS-only, unmaintained) +import kebabCase from 'lodash.kebabcase'; +``` + +### When no ESM alternative exists + +If a CJS-only package has no ESM replacement (e.g. `archiver`), add it to the `cjsOnlyPackages` list in `vite.config.node.ts` so it gets inlined into the bundle instead of externalized. diff --git a/packages/create-twenty-app/package.json b/packages/create-twenty-app/package.json index 516bb8f742..fa99d26979 100644 --- a/packages/create-twenty-app/package.json +++ b/packages/create-twenty-app/package.json @@ -1,6 +1,6 @@ { "name": "create-twenty-app", - "version": "0.6.3", + "version": "0.6.4", "description": "Command-line interface to create Twenty application", "main": "dist/cli.cjs", "bin": "dist/cli.cjs", diff --git a/packages/twenty-sdk/package.json b/packages/twenty-sdk/package.json index daee8c6b10..f6f87b61c4 100644 --- a/packages/twenty-sdk/package.json +++ b/packages/twenty-sdk/package.json @@ -1,6 +1,6 @@ { "name": "twenty-sdk", - "version": "0.6.3", + "version": "0.6.4", "main": "dist/index.cjs", "module": "dist/index.mjs", "types": "dist/sdk/index.d.ts", @@ -71,26 +71,21 @@ "@remote-dom/core": "^1.10.1", "@remote-dom/react": "^1.2.2", "@sniptt/guards": "^0.2.0", - "archiver": "^7.0.1", "axios": "^1.13.5", "chalk": "^5.3.0", "chokidar": "^4.0.0", "commander": "^12.0.0", "dotenv": "^16.4.0", "esbuild": "^0.25.0", - "fast-glob": "^3.3.0", - "form-data": "^4.0.5", - "fs-extra": "^11.2.0", "graphql": "^16.8.1", "graphql-sse": "^2.5.4", "ink": "^5.1.1", "inquirer": "^10.0.0", "jsonc-parser": "^3.2.0", - "lodash.camelcase": "^4.3.0", - "lodash.kebabcase": "^4.1.1", "preact": "^10.28.3", "react": "^18.2.0", "react-dom": "^18.2.0", + "tinyglobby": "^0.2.15", "typescript": "^5.9.2", "uuid": "^13.0.0", "vite": "^7.0.0", @@ -103,11 +98,7 @@ "@prettier/sync": "^0.5.2", "@storybook/addon-vitest": "^10.2.13", "@storybook/react-vite": "^10.2.13", - "@types/archiver": "^6.0.0", - "@types/fs-extra": "^11.0.0", "@types/inquirer": "^9.0.0", - "@types/lodash.camelcase": "^4.3.7", - "@types/lodash.kebabcase": "^4.1.9", "@types/node": "^24.0.0", "@types/react": "18.2.66", "@types/react-dom": "18.2.22", diff --git a/packages/twenty-sdk/src/cli/__tests__/apps/invalid-app/__integration__/manifest.integration.spec.ts b/packages/twenty-sdk/src/cli/__tests__/apps/invalid-app/__integration__/manifest.integration.spec.ts index 1b8891a7fe..18ab3e7f50 100644 --- a/packages/twenty-sdk/src/cli/__tests__/apps/invalid-app/__integration__/manifest.integration.spec.ts +++ b/packages/twenty-sdk/src/cli/__tests__/apps/invalid-app/__integration__/manifest.integration.spec.ts @@ -1,8 +1,9 @@ -import { runAppDevInProcess } from '@/cli/__tests__/integration/utils/run-app-dev-in-process.util'; -import * as fs from 'fs-extra'; import { join } from 'path'; import { OUTPUT_DIR } from 'twenty-shared/application'; +import { runAppDevInProcess } from '@/cli/__tests__/integration/utils/run-app-dev-in-process.util'; +import { pathExists } from '@/cli/utilities/file/fs-utils'; + const APP_PATH = join(__dirname, '..'); const MANIFEST_OUTPUT_PATH = join(APP_PATH, OUTPUT_DIR, 'manifest.json'); @@ -15,7 +16,7 @@ describe('invalid-app manifest', () => { expect(result.success).toBe(false); - const manifestExists = await fs.pathExists(MANIFEST_OUTPUT_PATH); + const manifestExists = await pathExists(MANIFEST_OUTPUT_PATH); expect(manifestExists).toBe(false); }, 30000); diff --git a/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-dev/tests/entities.tests.ts b/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-dev/tests/entities.tests.ts index 38d213daf6..95abd9c73c 100644 --- a/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-dev/tests/entities.tests.ts +++ b/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-dev/tests/entities.tests.ts @@ -1,4 +1,4 @@ -import * as fs from 'fs-extra'; +import { readdir } from 'node:fs/promises'; import { join } from 'path'; import { OUTPUT_DIR } from 'twenty-shared/application'; @@ -6,7 +6,7 @@ export const defineEntitiesTests = (appPath: string): void => { const outputDir = join(appPath, OUTPUT_DIR); describe('logicFunctions', () => { it('should have built logicFunctions preserving source path structure', async () => { - const files = await fs.readdir(outputDir, { recursive: true }); + const files = await readdir(outputDir, { recursive: true }); const sortedFiles = files.map((f) => f.toString()).sort(); expect(sortedFiles).toEqual([ @@ -43,7 +43,7 @@ export const defineEntitiesTests = (appPath: string): void => { }); it('should not create shared chunk files for utilities', async () => { - const files = await fs.readdir(outputDir, { recursive: true }); + const files = await readdir(outputDir, { recursive: true }); // Chunk files have a hash suffix like "greeting.util-CipJsYK0.mjs" const chunkFiles = files diff --git a/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-dev/tests/manifest.tests.ts b/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-dev/tests/manifest.tests.ts index e1379f70a0..c32dc96644 100644 --- a/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-dev/tests/manifest.tests.ts +++ b/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-dev/tests/manifest.tests.ts @@ -1,7 +1,8 @@ -import * as fs from 'fs-extra'; import { join } from 'path'; import { normalizeManifestForComparison } from '@/cli/__tests__/integration/utils/normalize-manifest.util'; +import { readJson } from '@/cli/utilities/file/fs-utils'; +import { type Manifest } from 'twenty-shared/application'; import { EXPECTED_MANIFEST } from '../expected-manifest'; export const defineManifestTests = (appPath: string): void => { @@ -9,7 +10,7 @@ export const defineManifestTests = (appPath: string): void => { describe('manifest', () => { it('should build manifest matching expected JSON', async () => { - const manifest = await fs.readJson(manifestOutputPath); + const manifest = await readJson(manifestOutputPath); expect(manifest).not.toBeNull(); diff --git a/packages/twenty-sdk/src/cli/__tests__/apps/root-app/__integration__/app-dev/tests/front-components.tests.ts b/packages/twenty-sdk/src/cli/__tests__/apps/root-app/__integration__/app-dev/tests/front-components.tests.ts index fe8dd8e738..ccfe17881c 100644 --- a/packages/twenty-sdk/src/cli/__tests__/apps/root-app/__integration__/app-dev/tests/front-components.tests.ts +++ b/packages/twenty-sdk/src/cli/__tests__/apps/root-app/__integration__/app-dev/tests/front-components.tests.ts @@ -1,11 +1,11 @@ -import * as fs from 'fs-extra'; +import { readdir } from 'node:fs/promises'; import { join } from 'path'; export const defineFrontComponentsTests = (appPath: string): void => { describe('front-components', () => { it('should have built front components at root level', async () => { const outputDir = join(appPath, '.twenty/output'); - const files = await fs.readdir(outputDir, { recursive: true }); + const files = await readdir(outputDir, { recursive: true }); const componentFiles = files .map((f) => f.toString()) .filter((f) => f.includes('.front-component.')) diff --git a/packages/twenty-sdk/src/cli/__tests__/apps/root-app/__integration__/app-dev/tests/logic-functions.tests.ts b/packages/twenty-sdk/src/cli/__tests__/apps/root-app/__integration__/app-dev/tests/logic-functions.tests.ts index 3156d56ea3..ab977fd8f5 100644 --- a/packages/twenty-sdk/src/cli/__tests__/apps/root-app/__integration__/app-dev/tests/logic-functions.tests.ts +++ b/packages/twenty-sdk/src/cli/__tests__/apps/root-app/__integration__/app-dev/tests/logic-functions.tests.ts @@ -1,11 +1,11 @@ -import * as fs from 'fs-extra'; +import { readdir } from 'node:fs/promises'; import { join } from 'path'; export const defineLogicFunctionsTests = (appPath: string): void => { describe('logicFunctions', () => { it('should have built logicFunctions at root level', async () => { const outputDir = join(appPath, '.twenty/output'); - const files = await fs.readdir(outputDir, { recursive: true }); + const files = await readdir(outputDir, { recursive: true }); const functionFiles = files .map((f) => f.toString()) .filter((f) => f.includes('.function.')) diff --git a/packages/twenty-sdk/src/cli/__tests__/apps/root-app/__integration__/app-dev/tests/manifest.tests.ts b/packages/twenty-sdk/src/cli/__tests__/apps/root-app/__integration__/app-dev/tests/manifest.tests.ts index dc3a1d960b..bc86510a41 100644 --- a/packages/twenty-sdk/src/cli/__tests__/apps/root-app/__integration__/app-dev/tests/manifest.tests.ts +++ b/packages/twenty-sdk/src/cli/__tests__/apps/root-app/__integration__/app-dev/tests/manifest.tests.ts @@ -1,15 +1,15 @@ -import * as fs from 'fs-extra'; import { join } from 'path'; import { type Manifest } from 'twenty-shared/application'; -import { normalizeManifestForComparison } from '@/cli/__tests__/integration/utils/normalize-manifest.util'; import { EXPECTED_MANIFEST } from '@/cli/__tests__/apps/root-app/__integration__/app-dev/expected-manifest'; +import { normalizeManifestForComparison } from '@/cli/__tests__/integration/utils/normalize-manifest.util'; +import { pathExists, readJson } from '@/cli/utilities/file/fs-utils'; export const defineManifestTests = (appPath: string): void => { describe('manifest', () => { it('should have generated manifest.json', async () => { const manifestPath = join(appPath, '.twenty/output/manifest.json'); - const exists = await fs.pathExists(manifestPath); + const exists = await pathExists(manifestPath); expect(exists).toBe(true); }); @@ -17,7 +17,7 @@ export const defineManifestTests = (appPath: string): void => { it('should have correct manifest content', async () => { const manifestPath = join(appPath, '.twenty/output/manifest.json'); const manifest: Manifest = normalizeManifestForComparison( - await fs.readJSON(manifestPath), + await readJson(manifestPath), ); expect(manifest).toEqual(EXPECTED_MANIFEST); diff --git a/packages/twenty-sdk/src/cli/__tests__/constants/setupTest.ts b/packages/twenty-sdk/src/cli/__tests__/constants/setupTest.ts index 50dde446b7..84700bd6ed 100644 --- a/packages/twenty-sdk/src/cli/__tests__/constants/setupTest.ts +++ b/packages/twenty-sdk/src/cli/__tests__/constants/setupTest.ts @@ -1,12 +1,14 @@ -import { getConfigPath } from '@/cli/utilities/config/get-config-path'; -import * as fs from 'fs-extra'; +import { writeFile } from 'node:fs/promises'; import * as path from 'path'; import { beforeAll } from 'vitest'; +import { ensureDir } from '@/cli/utilities/file/fs-utils'; +import { getConfigPath } from '@/cli/utilities/config/get-config-path'; + const testConfigPath = getConfigPath(); beforeAll(async () => { - await fs.ensureDir(path.dirname(testConfigPath)); + await ensureDir(path.dirname(testConfigPath)); const configFile = { profiles: { @@ -17,5 +19,5 @@ beforeAll(async () => { }, }; - await fs.writeFile(testConfigPath, JSON.stringify(configFile, null, 2)); + await writeFile(testConfigPath, JSON.stringify(configFile, null, 2)); }); diff --git a/packages/twenty-sdk/src/cli/__tests__/integration/utils/run-app-dev-in-process.util.ts b/packages/twenty-sdk/src/cli/__tests__/integration/utils/run-app-dev-in-process.util.ts index 08f91a70e6..c9f84172b9 100644 --- a/packages/twenty-sdk/src/cli/__tests__/integration/utils/run-app-dev-in-process.util.ts +++ b/packages/twenty-sdk/src/cli/__tests__/integration/utils/run-app-dev-in-process.util.ts @@ -1,8 +1,9 @@ -import { AppDevCommand } from '@/cli/commands/app/app-dev'; -import * as fs from 'fs-extra'; import { join } from 'path'; import { OUTPUT_DIR } from 'twenty-shared/application'; +import { AppDevCommand } from '@/cli/commands/app/app-dev'; +import { pathExists } from '@/cli/utilities/file/fs-utils'; + export type RunAppDevResult = { success: boolean; events?: { message: string; status: string }[]; @@ -23,7 +24,7 @@ export const runAppDevInProcess = async (options: { const startTime = Date.now(); while (Date.now() - startTime < timeout) { - if (await fs.pathExists(manifestPath)) { + if (await pathExists(manifestPath)) { await new Promise((resolve) => setTimeout(resolve, 500)); await command.close(); diff --git a/packages/twenty-sdk/src/cli/commands/entity/entity-add.ts b/packages/twenty-sdk/src/cli/commands/entity/entity-add.ts index 951a6b44fe..e1c503ed60 100644 --- a/packages/twenty-sdk/src/cli/commands/entity/entity-add.ts +++ b/packages/twenty-sdk/src/cli/commands/entity/entity-add.ts @@ -1,23 +1,25 @@ +import chalk from 'chalk'; +import inquirer from 'inquirer'; +import { writeFile } from 'node:fs/promises'; +import { join, relative } from 'path'; +import { SyncableEntity } from 'twenty-shared/application'; +import { FieldMetadataType } from 'twenty-shared/types'; +import { assertUnreachable } from 'twenty-shared/utils'; +import { v4 } from 'uuid'; + import { CURRENT_EXECUTION_DIRECTORY } from '@/cli/utilities/config/current-execution-directory'; +import { convertToLabel } from '@/cli/utilities/entity/entity-label'; +import { getFieldBaseFile } from '@/cli/utilities/entity/entity-field-template'; import { getFrontComponentBaseFile } from '@/cli/utilities/entity/entity-front-component-template'; import { getLogicFunctionBaseFile } from '@/cli/utilities/entity/entity-logic-function-template'; import { getNavigationMenuItemBaseFile } from '@/cli/utilities/entity/entity-navigation-menu-item-template'; -import { convertToLabel } from '@/cli/utilities/entity/entity-label'; import { getObjectBaseFile } from '@/cli/utilities/entity/entity-object-template'; import { getPageLayoutBaseFile } from '@/cli/utilities/entity/entity-page-layout-template'; import { getRoleBaseFile } from '@/cli/utilities/entity/entity-role-template'; import { getSkillBaseFile } from '@/cli/utilities/entity/entity-skill-template'; import { getViewBaseFile } from '@/cli/utilities/entity/entity-view-template'; -import chalk from 'chalk'; -import * as fs from 'fs-extra'; -import inquirer from 'inquirer'; -import kebabcase from 'lodash.kebabcase'; -import { join, relative } from 'path'; -import { SyncableEntity } from 'twenty-shared/application'; -import { FieldMetadataType } from 'twenty-shared/types'; -import { assertUnreachable } from 'twenty-shared/utils'; -import { getFieldBaseFile } from '@/cli/utilities/entity/entity-field-template'; -import { v4 } from 'uuid'; +import { ensureDir, pathExists } from '@/cli/utilities/file/fs-utils'; +import { kebabCase } from '@/cli/utilities/string/kebab-case'; const APP_FOLDER = 'src'; @@ -34,20 +36,20 @@ export class EntityAddCommand { ? join(CURRENT_EXECUTION_DIRECTORY, path) : join(CURRENT_EXECUTION_DIRECTORY, APP_FOLDER, entityName); - await fs.ensureDir(appPath); + await ensureDir(appPath); const { name, file } = await this.getEntityData(entity); const filePath = join(appPath, this.getFileName(name, entity)); - if (await fs.pathExists(filePath)) { + if (await pathExists(filePath)) { const { overwrite } = await this.handleFileExist(); if (!overwrite) { return; } } - await fs.writeFile(filePath, file); + await writeFile(filePath, file); console.log( chalk.green(`✓ Created ${entityName}:`), @@ -197,7 +199,7 @@ export class EntityAddCommand { const viewUniversalIdentifier = v4(); const viewFile = getViewBaseFile({ - name: `all-${kebabcase(objectName)}`, + name: `all-${kebabCase(objectName)}`, universalIdentifier: viewUniversalIdentifier, objectUniversalIdentifier: this.lastObjectUniversalIdentifier, }); @@ -210,12 +212,12 @@ export class EntityAddCommand { this.getFolderName(SyncableEntity.View), ); - await fs.ensureDir(viewFolderPath); + await ensureDir(viewFolderPath); - const viewFileName = `all-${kebabcase(objectName)}.ts`; + const viewFileName = `all-${kebabCase(objectName)}.ts`; const viewFilePath = join(viewFolderPath, viewFileName); - if (await fs.pathExists(viewFilePath)) { + if (await pathExists(viewFilePath)) { const { overwrite } = await this.handleFileExist(); if (!overwrite) { @@ -223,7 +225,7 @@ export class EntityAddCommand { } } - await fs.writeFile(viewFilePath, viewFile); + await writeFile(viewFilePath, viewFile); console.log( chalk.green(`✓ Created view:`), @@ -243,12 +245,12 @@ export class EntityAddCommand { this.getFolderName(SyncableEntity.NavigationMenuItem), ); - await fs.ensureDir(navFolderPath); + await ensureDir(navFolderPath); - const navFileName = `${kebabcase(objectName)}.ts`; + const navFileName = `${kebabCase(objectName)}.ts`; const navFilePath = join(navFolderPath, navFileName); - if (await fs.pathExists(navFilePath)) { + if (await pathExists(navFilePath)) { const { overwrite } = await this.handleFileExist(); if (!overwrite) { @@ -256,7 +258,7 @@ export class EntityAddCommand { } } - await fs.writeFile(navFilePath, navFile); + await writeFile(navFilePath, navFile); console.log( chalk.green(`✓ Created navigation menu item:`), @@ -471,16 +473,16 @@ export class EntityAddCommand { } getFolderName(entity: SyncableEntity) { - return `${kebabcase(entity)}s`; + return `${kebabCase(entity)}s`; } getFileName(name: string, entity: SyncableEntity) { switch (entity) { case SyncableEntity.FrontComponent: { - return `${kebabcase(name)}.tsx`; + return `${kebabCase(name)}.tsx`; } default: { - return `${kebabcase(name)}.ts`; + return `${kebabCase(name)}.ts`; } } } 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 195adf7e05..79bb02d4e0 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 @@ -1,3 +1,13 @@ +import crypto from 'crypto'; +import { readFile } from 'node:fs/promises'; +import { dirname, join } from 'path'; +import { + NODE_ESM_CJS_BANNER, + OUTPUT_DIR, + type Manifest, +} from 'twenty-shared/application'; +import { FileFolder } from 'twenty-shared/types'; + import { esbuildOneShotBuild } from '@/cli/utilities/build/common/esbuild-one-shot-build'; import { LOGIC_FUNCTION_EXTERNAL_MODULES, @@ -7,15 +17,13 @@ import { FRONT_COMPONENT_EXTERNAL_MODULES } from '@/cli/utilities/build/common/f import { getFrontComponentBuildPlugins } from '@/cli/utilities/build/common/front-component-build/utils/get-front-component-build-plugins'; import { type OnFileBuiltCallback } from '@/cli/utilities/build/common/restartable-watcher-interface'; import { type EntityFilePaths } from '@/cli/utilities/build/manifest/manifest-extract-config'; -import crypto from 'crypto'; -import * as fs from 'fs-extra'; -import { dirname, join } from 'path'; import { - NODE_ESM_CJS_BANNER, - OUTPUT_DIR, - type Manifest, -} from 'twenty-shared/application'; -import { FileFolder } from 'twenty-shared/types'; + copy, + emptyDir, + ensureDir, + pathExists, + pathExistsSync, +} from '@/cli/utilities/file/fs-utils'; export type AppBuildOptions = { appPath: string; @@ -39,8 +47,8 @@ export const buildApplication = async ( ): Promise => { const outputDir = join(options.appPath, OUTPUT_DIR); - await fs.ensureDir(outputDir); - await fs.emptyDir(outputDir); + await ensureDir(outputDir); + await emptyDir(outputDir); const builtFileInfos = new Map(); @@ -112,7 +120,7 @@ export const buildApplication = async ( appPath: options.appPath, fileFolder: FileFolder.Dependencies, filePaths: ['package.json', 'yarn.lock'].filter((filePath) => - fs.pathExistsSync(join(options.appPath, filePath)), + pathExistsSync(join(options.appPath, filePath)), ), collectFileBuilt, }); @@ -134,17 +142,17 @@ const copyStaticFiles = async ({ for (const sourcePath of filePaths) { const absoluteSourcePath = join(appPath, sourcePath); - if (!(await fs.pathExists(absoluteSourcePath))) { + if (!(await pathExists(absoluteSourcePath))) { continue; } const builtPath = join(OUTPUT_DIR, sourcePath); const absoluteBuiltPath = join(appPath, builtPath); - await fs.ensureDir(dirname(absoluteBuiltPath)); - await fs.copy(absoluteSourcePath, absoluteBuiltPath); + await ensureDir(dirname(absoluteBuiltPath)); + await copy(absoluteSourcePath, absoluteBuiltPath); - const content = await fs.readFile(absoluteBuiltPath); + const content = await readFile(absoluteBuiltPath); const checksum = crypto.createHash('md5').update(content).digest('hex'); collectFileBuilt({ diff --git a/packages/twenty-sdk/src/cli/utilities/build/common/cleanup-removed-files.ts b/packages/twenty-sdk/src/cli/utilities/build/common/cleanup-removed-files.ts index cf113ee78c..8afab9a4b3 100644 --- a/packages/twenty-sdk/src/cli/utilities/build/common/cleanup-removed-files.ts +++ b/packages/twenty-sdk/src/cli/utilities/build/common/cleanup-removed-files.ts @@ -1,6 +1,7 @@ -import * as fs from 'fs-extra'; import path from 'path'; +import { remove } from '@/cli/utilities/file/fs-utils'; + export const cleanupRemovedFiles = async ( outputDir: string, oldPaths: string[], @@ -14,7 +15,7 @@ export const cleanupRemovedFiles = async ( const outputFile = path.join(outputDir, outputBaseName); const sourceMapFile = `${outputFile}.map`; - await fs.remove(outputFile); - await fs.remove(sourceMapFile); + await remove(outputFile); + await remove(sourceMapFile); } }; diff --git a/packages/twenty-sdk/src/cli/utilities/build/common/esbuild-result-processor.ts b/packages/twenty-sdk/src/cli/utilities/build/common/esbuild-result-processor.ts index 92a405cfe2..bf20dfcdd5 100644 --- a/packages/twenty-sdk/src/cli/utilities/build/common/esbuild-result-processor.ts +++ b/packages/twenty-sdk/src/cli/utilities/build/common/esbuild-result-processor.ts @@ -1,6 +1,6 @@ import crypto from 'crypto'; import type * as esbuild from 'esbuild'; -import * as fs from 'fs-extra'; +import { readFile } from 'node:fs/promises'; import path from 'path'; import { type OnFileBuiltCallback } from '@/cli/utilities/build/common/restartable-watcher-interface'; import { type FileFolder } from 'twenty-shared/types'; @@ -31,7 +31,7 @@ export const processEsbuildResult = async ({ result.metafile?.outputs?.[outputFile]?.entryPoint || ''; const relativeSourcePath = path.relative(appPath, absoluteSourcePath); - const content = await fs.readFile(absoluteBuiltFile); + const content = await readFile(absoluteBuiltFile); const checksum = crypto.createHash('md5').update(content).digest('hex'); const lastChecksum = lastChecksums.get(relativeBuiltPath); diff --git a/packages/twenty-sdk/src/cli/utilities/build/common/file-upload-watcher.ts b/packages/twenty-sdk/src/cli/utilities/build/common/file-upload-watcher.ts index 21f71b1f62..c907673fe5 100644 --- a/packages/twenty-sdk/src/cli/utilities/build/common/file-upload-watcher.ts +++ b/packages/twenty-sdk/src/cli/utilities/build/common/file-upload-watcher.ts @@ -1,9 +1,16 @@ import chokidar, { type FSWatcher } from 'chokidar'; import crypto from 'crypto'; -import * as fs from 'fs-extra'; +import { readFile } from 'node:fs/promises'; import { dirname, join, relative } from 'path'; -import { type FileFolder } from 'twenty-shared/types'; import { OUTPUT_DIR } from 'twenty-shared/application'; +import { type FileFolder } from 'twenty-shared/types'; + +import { + copy, + ensureDir, + pathExists, + remove, +} from '@/cli/utilities/file/fs-utils'; export type AssetWatcherOptions = { appPath: string; @@ -37,7 +44,7 @@ export class FileUploadWatcher { ); for (const rootPath of rootPaths) { - const exists = await fs.pathExists(rootPath); + const exists = await pathExists(rootPath); if (!exists) { return; } @@ -74,10 +81,10 @@ export class FileUploadWatcher { const outputPath = join(OUTPUT_DIR, sourcePath); const absoluteOutputPath = join(this.appPath, outputPath); - await fs.ensureDir(dirname(absoluteOutputPath)); - await fs.copy(absoluteFilePath, absoluteOutputPath); + await ensureDir(dirname(absoluteOutputPath)); + await copy(absoluteFilePath, absoluteOutputPath); - const content = await fs.readFile(absoluteOutputPath); + const content = await readFile(absoluteOutputPath); const checksum = crypto.createHash('md5').update(content).digest('hex'); this.handleFileBuilt({ @@ -93,6 +100,6 @@ export class FileUploadWatcher { const builtPath = join(OUTPUT_DIR, sourcePath); const absoluteBuiltPath = join(this.appPath, builtPath); - await fs.remove(absoluteBuiltPath); + await remove(absoluteBuiltPath); } } diff --git a/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/jsx-transform-to-remote-dom-worker-format-plugin.ts b/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/jsx-transform-to-remote-dom-worker-format-plugin.ts index 77978e1a7e..3e6dcb07e7 100644 --- a/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/jsx-transform-to-remote-dom-worker-format-plugin.ts +++ b/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/jsx-transform-to-remote-dom-worker-format-plugin.ts @@ -1,5 +1,5 @@ import type * as esbuild from 'esbuild'; -import * as fs from 'node:fs/promises'; +import { readFile } from 'node:fs/promises'; import { unwrapDefineFrontComponentToDirectExport } from './utils/unwrap-define-front-component-to-direct-export'; @@ -10,7 +10,7 @@ export const jsxTransformToRemoteDomWorkerFormatPlugin: esbuild.Plugin = { { filter: /\.tsx$/ }, async ({ path }): Promise => { try { - const frontComponentSourceCode = await fs.readFile(path, 'utf8'); + const frontComponentSourceCode = await readFile(path, 'utf8'); const transformedContents = unwrapDefineFrontComponentToDirectExport( frontComponentSourceCode, diff --git a/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/strip-comments-plugin.ts b/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/strip-comments-plugin.ts index 0a2b1a0b52..20512d8d46 100644 --- a/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/strip-comments-plugin.ts +++ b/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/strip-comments-plugin.ts @@ -1,4 +1,4 @@ -import * as fs from 'fs/promises'; +import { readFile, writeFile } from 'node:fs/promises'; import path from 'path'; import type * as esbuild from 'esbuild'; @@ -19,11 +19,11 @@ export const stripCommentsPlugin: esbuild.Plugin = { for (const outputFile of outputFiles) { const absolutePath = path.resolve(outputFile); - const content = await fs.readFile(absolutePath, 'utf-8'); + const content = await readFile(absolutePath, 'utf-8'); const stripped = content.replace(SINGLE_LINE_COMMENT_PATTERN, ''); if (stripped !== content) { - await fs.writeFile(absolutePath, stripped, 'utf-8'); + await writeFile(absolutePath, stripped, 'utf-8'); } } }); diff --git a/packages/twenty-sdk/src/cli/utilities/build/common/tsc-watcher.ts b/packages/twenty-sdk/src/cli/utilities/build/common/tsc-watcher.ts index dc33fc4101..fbed37c1f9 100644 --- a/packages/twenty-sdk/src/cli/utilities/build/common/tsc-watcher.ts +++ b/packages/twenty-sdk/src/cli/utilities/build/common/tsc-watcher.ts @@ -1,7 +1,8 @@ import { spawn, type ChildProcess } from 'node:child_process'; -import * as fs from 'fs-extra'; import path from 'node:path'; +import { pathExists } from '@/cli/utilities/file/fs-utils'; + import { parseTscOutputLine, type TypecheckError, @@ -28,7 +29,7 @@ export class TscWatcher { async start(): Promise { const tscPath = path.join(this.appPath, 'node_modules', '.bin', 'tsc'); - if (!(await fs.pathExists(tscPath))) { + if (!(await pathExists(tscPath))) { return; } diff --git a/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-build.ts b/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-build.ts index 6080d05178..33cd3adbcb 100644 --- a/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-build.ts +++ b/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-build.ts @@ -15,9 +15,9 @@ import { import { type ObjectConfig } from '@/sdk/objects/object-config'; import { type PageLayoutConfig } from '@/sdk/page-layouts/page-layout-config'; import { type ViewConfig } from '@/sdk/views/view-config'; -import { glob } from 'fast-glob'; -import { readFile } from 'fs-extra'; +import { readFile } from 'node:fs/promises'; import { basename, extname, relative } from 'path'; +import { glob } from 'tinyglobby'; import { type ApplicationManifest, type AssetManifest, diff --git a/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-extract-config-from-file.ts b/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-extract-config-from-file.ts index 7a3aba3fa8..d32973a2ac 100644 --- a/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-extract-config-from-file.ts +++ b/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-extract-config-from-file.ts @@ -1,7 +1,8 @@ import { conditionalAvailabilityTransformPlugin } from '@/cli/utilities/build/common/conditional-availability/conditional-availability-transform-plugin'; import { type ValidationResult } from '@/sdk'; +import { pathExists, remove } from '@/cli/utilities/file/fs-utils'; import * as esbuild from 'esbuild'; -import * as fs from 'fs-extra'; +import { mkdtemp, writeFile } from 'node:fs/promises'; import { createRequire } from 'module'; import os from 'os'; import path from 'path'; @@ -48,7 +49,7 @@ const loadModule = async ({ appPath: string; }): Promise> => { const tsconfigPath = path.join(appPath, 'tsconfig.json'); - const hasTsconfig = await fs.pathExists(tsconfigPath); + const hasTsconfig = await pathExists(tsconfigPath); // Resolve react from the app's node_modules for the alias const appRequire = createRequire(path.join(appPath, 'package.json')); @@ -81,15 +82,15 @@ const loadModule = async ({ const code = result.outputFiles[0].text; - const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'twenty-manifest-')); + const tempDir = await mkdtemp(path.join(os.tmpdir(), 'twenty-manifest-')); const tempFile = path.join(tempDir, 'module.cjs'); try { - await fs.writeFile(tempFile, code); + await writeFile(tempFile, code); - return require(tempFile) as Record; + return appRequire(tempFile) as Record; } finally { - await fs.remove(tempDir); + await remove(tempDir); } }; diff --git a/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-reader.ts b/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-reader.ts index e3778badd1..da8d965392 100644 --- a/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-reader.ts +++ b/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-reader.ts @@ -1,21 +1,21 @@ -import * as fs from 'fs-extra'; +import { buildManifest } from '@/cli/utilities/build/manifest/manifest-build'; +import { ensureDir, pathExists, readJson } from '@/cli/utilities/file/fs-utils'; import path from 'path'; import { type Manifest, OUTPUT_DIR } from 'twenty-shared/application'; -import { buildManifest } from '@/cli/utilities/build/manifest/manifest-build'; export const readManifestFromFile = async ( appPath: string, ): Promise => { const outputDir = path.join(appPath, OUTPUT_DIR); - await fs.ensureDir(outputDir); + await ensureDir(outputDir); const manifestPath = path.join(outputDir, 'manifest.json'); - if (!(await fs.pathExists(manifestPath))) { + if (!(await pathExists(manifestPath))) { const { manifest } = await buildManifest(appPath); return manifest; } - return await fs.readJson(manifestPath); + return await readJson(manifestPath); }; diff --git a/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-writer.ts b/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-writer.ts index 579b474c08..de5478c77e 100644 --- a/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-writer.ts +++ b/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-writer.ts @@ -1,5 +1,6 @@ -import * as fs from 'fs-extra'; import path from 'path'; + +import { ensureDir, writeJson } from '@/cli/utilities/file/fs-utils'; import { type Manifest, OUTPUT_DIR } from 'twenty-shared/application'; export const writeManifestToOutput = async ( @@ -7,10 +8,10 @@ export const writeManifestToOutput = async ( manifest: Manifest, ): Promise => { const outputDir = path.join(appPath, OUTPUT_DIR); - await fs.ensureDir(outputDir); + await ensureDir(outputDir); const manifestPath = path.join(outputDir, 'manifest.json'); - await fs.writeJSON(manifestPath, manifest, { spaces: 2 }); + await writeJson(manifestPath, manifest); return manifestPath; }; diff --git a/packages/twenty-sdk/src/cli/utilities/client/client-service.ts b/packages/twenty-sdk/src/cli/utilities/client/client-service.ts index bfa0bd94a9..f46599a349 100644 --- a/packages/twenty-sdk/src/cli/utilities/client/client-service.ts +++ b/packages/twenty-sdk/src/cli/utilities/client/client-service.ts @@ -1,8 +1,16 @@ +import { appendFile, writeFile } from 'node:fs/promises'; +import { join } from 'path'; + import { ApiService } from '@/cli/utilities/api/api-service'; +import { + emptyDir, + ensureDir, + move, + pathExists, + remove, +} from '@/cli/utilities/file/fs-utils'; import twentyClientTemplateSource from '@/cli/utilities/client/twenty-client-template.ts?raw'; import { generate } from '@genql/cli'; -import * as fs from 'fs-extra'; -import { join } from 'path'; import { DEFAULT_API_URL_NAME, GENERATED_DIR } from 'twenty-shared/application'; type ClientWrapperOptions = { @@ -94,8 +102,8 @@ export class ClientService { ); } - await fs.ensureDir(tempPath); - await fs.emptyDir(tempPath); + await ensureDir(tempPath); + await emptyDir(tempPath); await Promise.all([ generate({ @@ -127,8 +135,8 @@ export class ClientService { await this.writeBarrelIndex(tempPath); - await fs.remove(outputPath); - await fs.move(tempPath, outputPath); + await remove(outputPath); + await move(tempPath, outputPath); } async ensureGeneratedClientStub({ @@ -138,18 +146,18 @@ export class ClientService { }): Promise { const outputPath = this.resolveGeneratedPath(appPath); - if (await fs.pathExists(join(outputPath, 'index.ts'))) { + if (await pathExists(join(outputPath, 'index.ts'))) { return; } - await fs.ensureDir(join(outputPath, 'core')); - await fs.ensureDir(join(outputPath, 'metadata')); + await ensureDir(join(outputPath, 'core')); + await ensureDir(join(outputPath, 'metadata')); - await fs.writeFile( + await writeFile( join(outputPath, 'core', 'index.ts'), 'export class CoreApiClient {}\n', ); - await fs.writeFile( + await writeFile( join(outputPath, 'metadata', 'index.ts'), 'export class MetadataApiClient {}\n', ); @@ -167,7 +175,7 @@ export * as CoreSchema from './core/schema'; export * as MetadataSchema from './metadata/schema'; `; - await fs.writeFile(join(outputDir, 'index.ts'), barrelContent); + await writeFile(join(outputDir, 'index.ts'), barrelContent); } private async injectClientWrapper( @@ -176,6 +184,6 @@ export * as MetadataSchema from './metadata/schema'; ): Promise { const clientContent = buildClientWrapperSource(options); - await fs.appendFile(join(output, 'index.ts'), clientContent); + await appendFile(join(output, 'index.ts'), clientContent); } } diff --git a/packages/twenty-sdk/src/cli/utilities/config/config-service.ts b/packages/twenty-sdk/src/cli/utilities/config/config-service.ts index 76eed0add8..e1f61ea0cf 100644 --- a/packages/twenty-sdk/src/cli/utilities/config/config-service.ts +++ b/packages/twenty-sdk/src/cli/utilities/config/config-service.ts @@ -1,6 +1,8 @@ -import * as fs from 'fs-extra'; +import { readFile, writeFile } from 'node:fs/promises'; import * as path from 'path'; +import { ensureDir, ensureFile } from '@/cli/utilities/file/fs-utils'; + import { getConfigPath } from '@/cli/utilities/config/get-config-path'; export type TwentyConfig = { @@ -40,8 +42,8 @@ export class ConfigService { } private async readRawConfig(): Promise { - await fs.ensureFile(this.configPath); - const content = await fs.readFile(this.configPath, 'utf8'); + await ensureFile(this.configPath); + const content = await readFile(this.configPath, 'utf8'); return JSON.parse(content || '{}'); } @@ -90,8 +92,8 @@ export class ConfigService { raw.profiles[profile] = { ...currentProfile, ...config }; - await fs.ensureDir(path.dirname(this.configPath)); - await fs.writeFile(this.configPath, JSON.stringify(raw, null, 2)); + await ensureDir(path.dirname(this.configPath)); + await writeFile(this.configPath, JSON.stringify(raw, null, 2)); } async clearConfig(): Promise { @@ -114,8 +116,8 @@ export class ConfigService { raw.apiUrl = defaultConfig.apiUrl; } - await fs.ensureDir(path.dirname(this.configPath)); - await fs.writeFile(this.configPath, JSON.stringify(raw, null, 2)); + await ensureDir(path.dirname(this.configPath)); + await writeFile(this.configPath, JSON.stringify(raw, null, 2)); } private getDefaultConfig(): TwentyConfig { @@ -155,7 +157,7 @@ export class ConfigService { async setDefaultWorkspace(name: string): Promise { const raw = await this.readRawConfig(); raw.defaultWorkspace = name; - await fs.ensureDir(path.dirname(this.configPath)); - await fs.writeFile(this.configPath, JSON.stringify(raw, null, 2)); + await ensureDir(path.dirname(this.configPath)); + await writeFile(this.configPath, JSON.stringify(raw, null, 2)); } } diff --git a/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/dev-mode-orchestrator.ts b/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/dev-mode-orchestrator.ts index 5128ecd105..3fbd0c3dae 100644 --- a/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/dev-mode-orchestrator.ts +++ b/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/dev-mode-orchestrator.ts @@ -15,7 +15,7 @@ import { import { SyncApplicationOrchestratorStep } from '@/cli/utilities/dev/orchestrator/steps/sync-application-orchestrator-step'; import { UploadFilesOrchestratorStep } from '@/cli/utilities/dev/orchestrator/steps/upload-files-orchestrator-step'; import { serializeError } from '@/cli/utilities/error/serialize-error'; -import * as fs from 'fs-extra'; +import { emptyDir, ensureDir } from '@/cli/utilities/file/fs-utils'; import path from 'path'; import { OUTPUT_DIR, type Manifest } from 'twenty-shared/application'; @@ -91,8 +91,8 @@ export class DevModeOrchestrator { async start(): Promise { const outputDir = path.join(this.state.appPath, OUTPUT_DIR); - await fs.ensureDir(outputDir); - await fs.emptyDir(outputDir); + await ensureDir(outputDir); + await emptyDir(outputDir); await this.clientService.ensureGeneratedClientStub({ appPath: this.state.appPath, diff --git a/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/steps/upload-files-orchestrator-step.ts b/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/steps/upload-files-orchestrator-step.ts index 338ef7fa0e..f4ef092311 100644 --- a/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/steps/upload-files-orchestrator-step.ts +++ b/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/steps/upload-files-orchestrator-step.ts @@ -3,8 +3,9 @@ import { type OrchestratorStateBuiltFileInfo, } from '@/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state'; import { FileUploader } from '@/cli/utilities/file/file-uploader'; +import { copy, ensureDir, pathExists } from '@/cli/utilities/file/fs-utils'; import crypto from 'crypto'; -import * as fs from 'fs-extra'; +import { readFile } from 'node:fs/promises'; import { join } from 'path'; import { OUTPUT_DIR, @@ -126,24 +127,24 @@ export class UploadFilesOrchestratorStep { GENERATED_DIR, ); - if (!(await fs.pathExists(generatedDir))) { + if (!(await pathExists(generatedDir))) { return; } const outputDir = join(appPath, OUTPUT_DIR, API_CLIENT_DIR); - await fs.ensureDir(outputDir); + await ensureDir(outputDir); for (const fileName of API_CLIENT_FILES) { const absoluteSourcePath = join(generatedDir, fileName); - if (!(await fs.pathExists(absoluteSourcePath))) { + if (!(await pathExists(absoluteSourcePath))) { continue; } - await fs.copy(absoluteSourcePath, join(outputDir, fileName)); + await copy(absoluteSourcePath, join(outputDir, fileName)); - const content = await fs.readFile(absoluteSourcePath); + const content = await readFile(absoluteSourcePath); const checksum = crypto.createHash('md5').update(content).digest('hex'); const builtPath = join(OUTPUT_DIR, API_CLIENT_DIR, fileName); diff --git a/packages/twenty-sdk/src/cli/utilities/entity/entity-front-component-template.ts b/packages/twenty-sdk/src/cli/utilities/entity/entity-front-component-template.ts index 7e62be4aaa..ccf0b22e13 100644 --- a/packages/twenty-sdk/src/cli/utilities/entity/entity-front-component-template.ts +++ b/packages/twenty-sdk/src/cli/utilities/entity/entity-front-component-template.ts @@ -1,4 +1,4 @@ -import kebabCase from 'lodash.kebabcase'; +import { kebabCase } from '@/cli/utilities/string/kebab-case'; import { v4 } from 'uuid'; export const getFrontComponentBaseFile = ({ diff --git a/packages/twenty-sdk/src/cli/utilities/entity/entity-logic-function-template.ts b/packages/twenty-sdk/src/cli/utilities/entity/entity-logic-function-template.ts index d61eae9de3..753a40ef38 100644 --- a/packages/twenty-sdk/src/cli/utilities/entity/entity-logic-function-template.ts +++ b/packages/twenty-sdk/src/cli/utilities/entity/entity-logic-function-template.ts @@ -1,4 +1,4 @@ -import kebabCase from 'lodash.kebabcase'; +import { kebabCase } from '@/cli/utilities/string/kebab-case'; import { v4 } from 'uuid'; export const getLogicFunctionBaseFile = ({ diff --git a/packages/twenty-sdk/src/cli/utilities/entity/entity-navigation-menu-item-template.ts b/packages/twenty-sdk/src/cli/utilities/entity/entity-navigation-menu-item-template.ts index 077d82607f..6e56b10e72 100644 --- a/packages/twenty-sdk/src/cli/utilities/entity/entity-navigation-menu-item-template.ts +++ b/packages/twenty-sdk/src/cli/utilities/entity/entity-navigation-menu-item-template.ts @@ -1,4 +1,4 @@ -import kebabCase from 'lodash.kebabcase'; +import { kebabCase } from '@/cli/utilities/string/kebab-case'; import { v4 } from 'uuid'; export const getNavigationMenuItemBaseFile = ({ diff --git a/packages/twenty-sdk/src/cli/utilities/entity/entity-role-template.ts b/packages/twenty-sdk/src/cli/utilities/entity/entity-role-template.ts index ecce28f44a..ace7256ce0 100644 --- a/packages/twenty-sdk/src/cli/utilities/entity/entity-role-template.ts +++ b/packages/twenty-sdk/src/cli/utilities/entity/entity-role-template.ts @@ -1,4 +1,4 @@ -import kebabCase from 'lodash.kebabcase'; +import { kebabCase } from '@/cli/utilities/string/kebab-case'; import { v4 } from 'uuid'; export const getRoleBaseFile = ({ diff --git a/packages/twenty-sdk/src/cli/utilities/entity/entity-skill-template.ts b/packages/twenty-sdk/src/cli/utilities/entity/entity-skill-template.ts index 29a36569e0..ce4e570215 100644 --- a/packages/twenty-sdk/src/cli/utilities/entity/entity-skill-template.ts +++ b/packages/twenty-sdk/src/cli/utilities/entity/entity-skill-template.ts @@ -1,4 +1,4 @@ -import kebabCase from 'lodash.kebabcase'; +import { kebabCase } from '@/cli/utilities/string/kebab-case'; import { v4 } from 'uuid'; export const getSkillBaseFile = ({ diff --git a/packages/twenty-sdk/src/cli/utilities/entity/entity-view-template.ts b/packages/twenty-sdk/src/cli/utilities/entity/entity-view-template.ts index 9f3ffbbb74..7f12c67ee2 100644 --- a/packages/twenty-sdk/src/cli/utilities/entity/entity-view-template.ts +++ b/packages/twenty-sdk/src/cli/utilities/entity/entity-view-template.ts @@ -1,4 +1,4 @@ -import kebabCase from 'lodash.kebabcase'; +import { kebabCase } from '@/cli/utilities/string/kebab-case'; import { v4 } from 'uuid'; export const getViewBaseFile = ({ diff --git a/packages/twenty-sdk/src/cli/utilities/file/__tests__/fs-utils.test.ts b/packages/twenty-sdk/src/cli/utilities/file/__tests__/fs-utils.test.ts new file mode 100644 index 0000000000..4ed4fd9fb3 --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/file/__tests__/fs-utils.test.ts @@ -0,0 +1,234 @@ +import { mkdtemp, readFile, writeFile, readdir, stat } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; +import os from 'node:os'; + +import { + copy, + emptyDir, + ensureDir, + ensureFile, + move, + pathExists, + pathExistsSync, + readJson, + remove, + writeJson, +} from '@/cli/utilities/file/fs-utils'; + +let tmpDir: string; + +beforeEach(async () => { + tmpDir = await mkdtemp(join(os.tmpdir(), 'fs-utils-test-')); +}); + +afterEach(async () => { + await remove(tmpDir); +}); + +describe('pathExists', () => { + it('should return true for an existing file', async () => { + const filePath = join(tmpDir, 'exists.txt'); + await writeFile(filePath, 'hello'); + + expect(await pathExists(filePath)).toBe(true); + }); + + it('should return false for a non-existent path', async () => { + expect(await pathExists(join(tmpDir, 'nope.txt'))).toBe(false); + }); +}); + +describe('pathExistsSync', () => { + it('should return true for an existing directory', () => { + expect(pathExistsSync(tmpDir)).toBe(true); + }); + + it('should return false for a non-existent path', () => { + expect(pathExistsSync(join(tmpDir, 'nope'))).toBe(false); + }); +}); + +describe('ensureDir', () => { + it('should create a deeply nested directory', async () => { + const deepPath = join(tmpDir, 'a', 'b', 'c'); + + await ensureDir(deepPath); + + expect(existsSync(deepPath)).toBe(true); + const dirStat = await stat(deepPath); + expect(dirStat.isDirectory()).toBe(true); + }); + + it('should not throw when the directory already exists', async () => { + await expect(ensureDir(tmpDir)).resolves.not.toThrow(); + }); +}); + +describe('ensureFile', () => { + it('should create the file and parent directories when missing', async () => { + const filePath = join(tmpDir, 'deep', 'nested', 'file.txt'); + + await ensureFile(filePath); + + expect(existsSync(filePath)).toBe(true); + const content = await readFile(filePath, 'utf-8'); + expect(content).toBe(''); + }); + + it('should not overwrite an existing file', async () => { + const filePath = join(tmpDir, 'existing.txt'); + await writeFile(filePath, 'keep me'); + + await ensureFile(filePath); + + const content = await readFile(filePath, 'utf-8'); + expect(content).toBe('keep me'); + }); +}); + +describe('emptyDir', () => { + it('should remove all contents including nested subdirectories', async () => { + await writeFile(join(tmpDir, 'a.txt'), 'a'); + const subDir = join(tmpDir, 'nested'); + await ensureDir(subDir); + await writeFile(join(subDir, 'deep.txt'), 'deep'); + + await emptyDir(tmpDir); + + const entries = await readdir(tmpDir); + expect(entries).toEqual([]); + expect(existsSync(tmpDir)).toBe(true); + }); + + it('should create the directory when it does not exist', async () => { + const newDir = join(tmpDir, 'new-dir'); + + await emptyDir(newDir); + + expect(existsSync(newDir)).toBe(true); + const entries = await readdir(newDir); + expect(entries).toEqual([]); + }); + + it('should rethrow non-ENOENT errors', async () => { + const filePath = join(tmpDir, 'not-a-dir.txt'); + await writeFile(filePath, 'file'); + + await expect(emptyDir(filePath)).rejects.toMatchObject({ + code: 'ENOTDIR', + }); + }); +}); + +describe('copy', () => { + it('should copy a file without removing the source', async () => { + const src = join(tmpDir, 'source.txt'); + const dest = join(tmpDir, 'dest.txt'); + await writeFile(src, 'content'); + + await copy(src, dest); + + expect(await readFile(dest, 'utf-8')).toBe('content'); + expect(await readFile(src, 'utf-8')).toBe('content'); + }); + + it('should recursively copy a directory', async () => { + const srcDir = join(tmpDir, 'src-dir'); + await ensureDir(srcDir); + await writeFile(join(srcDir, 'inner.txt'), 'inner'); + + const destDir = join(tmpDir, 'dest-dir'); + await copy(srcDir, destDir); + + expect(await readFile(join(destDir, 'inner.txt'), 'utf-8')).toBe('inner'); + }); +}); + +describe('move', () => { + it('should move a file to a new location', async () => { + const src = join(tmpDir, 'to-move.txt'); + const dest = join(tmpDir, 'moved.txt'); + await writeFile(src, 'data'); + + await move(src, dest); + + expect(existsSync(src)).toBe(false); + expect(await readFile(dest, 'utf-8')).toBe('data'); + }); + + it('should rethrow non-EXDEV errors', async () => { + const src = join(tmpDir, 'does-not-exist.txt'); + const dest = join(tmpDir, 'dest.txt'); + + await expect(move(src, dest)).rejects.toThrow(); + }); +}); + +describe('remove', () => { + it('should delete a file', async () => { + const filePath = join(tmpDir, 'to-delete.txt'); + await writeFile(filePath, 'bye'); + + await remove(filePath); + + expect(existsSync(filePath)).toBe(false); + }); + + it('should recursively delete a directory', async () => { + const dirPath = join(tmpDir, 'to-delete-dir'); + await ensureDir(dirPath); + await writeFile(join(dirPath, 'child.txt'), 'child'); + + await remove(dirPath); + + expect(existsSync(dirPath)).toBe(false); + }); + + it('should not throw when the path does not exist', async () => { + await expect(remove(join(tmpDir, 'already-gone'))).resolves.not.toThrow(); + }); +}); + +describe('readJson', () => { + it('should parse a JSON file and return typed data', async () => { + const filePath = join(tmpDir, 'data.json'); + await writeFile(filePath, JSON.stringify({ key: 'value', count: 42 })); + + const result = await readJson<{ key: string; count: number }>(filePath); + + expect(result).toEqual({ key: 'value', count: 42 }); + }); + + it('should throw on invalid JSON', async () => { + const filePath = join(tmpDir, 'bad.json'); + await writeFile(filePath, '{ broken }'); + + await expect(readJson(filePath)).rejects.toThrow(); + }); + + it('should throw when the file does not exist', async () => { + await expect(readJson(join(tmpDir, 'nope.json'))).rejects.toThrow(); + }); +}); + +describe('writeJson', () => { + it('should write pretty-printed JSON with a trailing newline', async () => { + const filePath = join(tmpDir, 'out.json'); + + await writeJson(filePath, { hello: 'world' }); + + const raw = await readFile(filePath, 'utf-8'); + expect(raw).toBe('{\n "hello": "world"\n}\n'); + }); + + it('should produce valid JSON that readJson can parse', async () => { + const filePath = join(tmpDir, 'roundtrip.json'); + const data = { nested: { array: [1, 2, 3] } }; + + await writeJson(filePath, data); + const result = await readJson(filePath); + + expect(result).toEqual(data); + }); +}); diff --git a/packages/twenty-sdk/src/cli/utilities/file/file-find.ts b/packages/twenty-sdk/src/cli/utilities/file/file-find.ts index 77154fb16f..96d30ba5ea 100644 --- a/packages/twenty-sdk/src/cli/utilities/file/file-find.ts +++ b/packages/twenty-sdk/src/cli/utilities/file/file-find.ts @@ -1,5 +1,6 @@ import path from 'path'; -import * as fs from 'fs-extra'; + +import { pathExists } from '@/cli/utilities/file/fs-utils'; export const findPathFile = async ( appPath: string, @@ -7,7 +8,7 @@ export const findPathFile = async ( ): Promise => { const jsonPath = path.join(appPath, fileName); - if (await fs.pathExists(jsonPath)) { + if (await pathExists(jsonPath)) { return jsonPath; } diff --git a/packages/twenty-sdk/src/cli/utilities/file/file-jsonc.ts b/packages/twenty-sdk/src/cli/utilities/file/file-jsonc.ts index 2f0744270c..54acf4399c 100644 --- a/packages/twenty-sdk/src/cli/utilities/file/file-jsonc.ts +++ b/packages/twenty-sdk/src/cli/utilities/file/file-jsonc.ts @@ -1,4 +1,4 @@ -import * as fs from 'fs-extra'; +import { readFile } from 'node:fs/promises'; import { type ParseError, parse as parseJsonc } from 'jsonc-parser'; export interface JsoncParseOptions { @@ -48,7 +48,7 @@ export const parseJsoncFile = async ( options: JsoncParseOptions = {}, ): Promise => { try { - const content = await fs.readFile(filePath, 'utf8'); + const content = await readFile(filePath, 'utf8'); return parseJsoncString(content, options); } catch (error) { if (error instanceof JsoncParseError) { diff --git a/packages/twenty-sdk/src/cli/utilities/file/file-tarball.ts b/packages/twenty-sdk/src/cli/utilities/file/file-tarball.ts deleted file mode 100644 index c0154f4c71..0000000000 --- a/packages/twenty-sdk/src/cli/utilities/file/file-tarball.ts +++ /dev/null @@ -1,67 +0,0 @@ -import * as fs from 'fs-extra'; -import path from 'path'; -import archiver from 'archiver'; - -/** - * TarballService creates distributable .tar.gz archives from built applications. - */ -export class TarballService { - /** - * Create a tarball from the build output directory. - * - * @param sourceDir - The directory containing built files - * @param outputPath - The path for the output .tar.gz file - * @returns The absolute path to the created tarball - */ - async create(options: { - sourceDir: string; - outputPath: string; - }): Promise { - const { sourceDir, outputPath } = options; - - // Ensure the source directory exists - if (!(await fs.pathExists(sourceDir))) { - throw new Error(`Source directory does not exist: ${sourceDir}`); - } - - // Ensure the output directory exists - await fs.ensureDir(path.dirname(outputPath)); - - // Normalize the output path to have .tar.gz extension - const normalizedOutputPath = outputPath.endsWith('.tar.gz') - ? outputPath - : `${outputPath}.tar.gz`; - - return new Promise((resolve, reject) => { - const output = fs.createWriteStream(normalizedOutputPath); - const archive = archiver('tar', { - gzip: true, - gzipOptions: { level: 9 }, // Maximum compression - }); - - output.on('close', () => { - resolve(normalizedOutputPath); - }); - - archive.on('error', (err: Error) => { - reject(err); - }); - - archive.on('warning', (err: Error & { code?: string }) => { - if (err.code === 'ENOENT') { - // Log warnings about missing files - console.warn('Archive warning:', err.message); - } else { - reject(err); - } - }); - - archive.pipe(output); - - // Add the entire source directory to the archive - archive.directory(sourceDir, false); - - archive.finalize(); - }); - } -} diff --git a/packages/twenty-sdk/src/cli/utilities/file/fs-utils.ts b/packages/twenty-sdk/src/cli/utilities/file/fs-utils.ts new file mode 100644 index 0000000000..9e8f99d420 --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/file/fs-utils.ts @@ -0,0 +1,93 @@ +// ESM-native helpers that have no direct native fs equivalent. +// For standard fs operations (readFile, writeFile, mkdir, etc.), +// import directly from 'node:fs/promises' or 'node:fs'. +import { + access, + cp, + mkdir, + readFile, + readdir, + rename as fsRename, + rm, + writeFile, +} from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { dirname, join } from 'node:path'; + +export const pathExists = async (filePath: string): Promise => { + try { + await access(filePath); + return true; + } catch { + return false; + } +}; + +export const pathExistsSync = (filePath: string): boolean => + existsSync(filePath); + +export const ensureDir = (dirPath: string) => + mkdir(dirPath, { recursive: true }); + +export const ensureFile = async (filePath: string): Promise => { + await mkdir(dirname(filePath), { recursive: true }); + + try { + await access(filePath); + } catch { + await writeFile(filePath, ''); + } +}; + +export const emptyDir = async (dirPath: string): Promise => { + let entries: string[]; + + try { + entries = await readdir(dirPath); + } catch (error: unknown) { + if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { + await mkdir(dirPath, { recursive: true }); + return; + } + throw error; + } + + await Promise.all( + entries.map((entry) => + rm(join(dirPath, entry), { recursive: true, force: true }), + ), + ); +}; + +export const copy = (src: string, dest: string) => + cp(src, dest, { recursive: true }); + +// Falls back to copy+delete when rename fails across devices +export const move = async (src: string, dest: string): Promise => { + try { + await fsRename(src, dest); + } catch (error: unknown) { + if (error instanceof Error && 'code' in error && error.code === 'EXDEV') { + await cp(src, dest, { recursive: true }); + await rm(src, { recursive: true, force: true }); + } else { + throw error; + } + } +}; + +export const remove = (filePath: string) => + rm(filePath, { recursive: true, force: true }); + +export const readJson = async (filePath: string): Promise => { + const content = await readFile(filePath, 'utf-8'); + + return JSON.parse(content) as T; +}; + +export const writeJson = async ( + filePath: string, + data: unknown, +): Promise => { + await writeFile(filePath, JSON.stringify(data, null, 2) + '\n'); +}; diff --git a/packages/twenty-sdk/src/cli/utilities/string/kebab-case.ts b/packages/twenty-sdk/src/cli/utilities/string/kebab-case.ts new file mode 100644 index 0000000000..34ea1ad580 --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/string/kebab-case.ts @@ -0,0 +1,9 @@ +export const kebabCase = (input: string): string => + input + .replace(/([a-z])([A-Z])/g, '$1-$2') + .replace(/([A-Z])([A-Z][a-z])/g, '$1-$2') + .replace(/([a-zA-Z])(\d)/g, '$1-$2') + .replace(/(\d)([a-zA-Z])/g, '$1-$2') + .replace(/[\s_]+/g, '-') + .replace(/[^a-zA-Z0-9-]+/g, '-') + .toLowerCase(); diff --git a/packages/twenty-server/package.json b/packages/twenty-server/package.json index c5c50280fe..94c2371286 100644 --- a/packages/twenty-server/package.json +++ b/packages/twenty-server/package.json @@ -192,6 +192,7 @@ "@nestjs/devtools-integration": "^0.2.1", "@nestjs/schematics": "^11.0.9", "@nestjs/testing": "^11.1.15", + "@types/archiver": "^6.0.0", "@types/babel__preset-env": "7.10.0", "@types/bytes": "^3.1.1", "@types/dompurify": "^3.0.5", diff --git a/yarn.lock b/yarn.lock index 9e5eefb519..e90ceaa934 100644 --- a/yarn.lock +++ b/yarn.lock @@ -24212,7 +24212,7 @@ __metadata: languageName: node linkType: hard -"@types/lodash.kebabcase@npm:^4.1.7, @types/lodash.kebabcase@npm:^4.1.9": +"@types/lodash.kebabcase@npm:^4.1.7": version: 4.1.9 resolution: "@types/lodash.kebabcase@npm:4.1.9" dependencies: @@ -37121,19 +37121,6 @@ __metadata: languageName: node linkType: hard -"fast-glob@npm:^3.3.0": - version: 3.3.3 - resolution: "fast-glob@npm:3.3.3" - dependencies: - "@nodelib/fs.stat": "npm:^2.0.2" - "@nodelib/fs.walk": "npm:^1.2.3" - glob-parent: "npm:^5.1.2" - merge2: "npm:^1.3.0" - micromatch: "npm:^4.0.8" - checksum: 10c0/f6aaa141d0d3384cf73cbcdfc52f475ed293f6d5b65bfc5def368b09163a9f7e5ec2b3014d80f733c405f58e470ee0cc451c2937685045cddcdeaa24199c43fe - languageName: node - linkType: hard - "fast-json-patch@npm:^3.0.0-1": version: 3.1.1 resolution: "fast-json-patch@npm:3.1.1" @@ -57509,37 +57496,28 @@ __metadata: "@sniptt/guards": "npm:^0.2.0" "@storybook/addon-vitest": "npm:^10.2.13" "@storybook/react-vite": "npm:^10.2.13" - "@types/archiver": "npm:^6.0.0" - "@types/fs-extra": "npm:^11.0.0" "@types/inquirer": "npm:^9.0.0" - "@types/lodash.camelcase": "npm:^4.3.7" - "@types/lodash.kebabcase": "npm:^4.1.9" "@types/node": "npm:^24.0.0" "@types/react": "npm:18.2.66" "@types/react-dom": "npm:18.2.22" "@vitest/browser-playwright": "npm:^4.0.18" - archiver: "npm:^7.0.1" axios: "npm:^1.13.5" chalk: "npm:^5.3.0" chokidar: "npm:^4.0.0" commander: "npm:^12.0.0" dotenv: "npm:^16.4.0" esbuild: "npm:^0.25.0" - fast-glob: "npm:^3.3.0" - form-data: "npm:^4.0.5" - fs-extra: "npm:^11.2.0" graphql: "npm:^16.8.1" graphql-sse: "npm:^2.5.4" ink: "npm:^5.1.1" inquirer: "npm:^10.0.0" jsonc-parser: "npm:^3.2.0" - lodash.camelcase: "npm:^4.3.0" - lodash.kebabcase: "npm:^4.1.1" playwright: "npm:^1.56.1" preact: "npm:^10.28.3" react: "npm:^18.2.0" react-dom: "npm:^18.2.0" storybook: "npm:^10.2.13" + tinyglobby: "npm:^0.2.15" ts-morph: "npm:^25.0.0" tsx: "npm:^4.7.0" twenty-shared: "workspace:*" @@ -57630,6 +57608,7 @@ __metadata: "@sentry/node": "npm:^10.27.0" "@sentry/profiling-node": "npm:^10.27.0" "@sniptt/guards": "npm:0.2.0" + "@types/archiver": "npm:^6.0.0" "@types/babel__preset-env": "npm:7.10.0" "@types/bytes": "npm:^3.1.1" "@types/dompurify": "npm:^3.0.5"