diff --git a/packages/create-twenty-app/package.json b/packages/create-twenty-app/package.json index 5fc011902b..7fc93acd2c 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.8.0", + "version": "0.9.0-canary.0", "description": "Command-line interface to create Twenty application", "main": "dist/cli.cjs", "bin": "dist/cli.cjs", diff --git a/packages/twenty-client-sdk/package.json b/packages/twenty-client-sdk/package.json index 426f8dd47a..869c62ea5c 100644 --- a/packages/twenty-client-sdk/package.json +++ b/packages/twenty-client-sdk/package.json @@ -1,6 +1,6 @@ { "name": "twenty-client-sdk", - "version": "0.8.0", + "version": "0.9.0-canary.0", "sideEffects": false, "license": "AGPL-3.0", "scripts": { diff --git a/packages/twenty-docs/developers/extend/apps/building.mdx b/packages/twenty-docs/developers/extend/apps/building.mdx index 4a5644fbd7..de69582e03 100644 --- a/packages/twenty-docs/developers/extend/apps/building.mdx +++ b/packages/twenty-docs/developers/extend/apps/building.mdx @@ -756,7 +756,7 @@ export default defineFrontComponent({ }); ``` -After syncing with `yarn twenty dev`, the quick action appears in the top-right corner of the page: +After syncing with `yarn twenty dev` (or running a one-shot `yarn twenty dev --once`), the quick action appears in the top-right corner of the page:
Quick action button in the top-right corner @@ -1402,7 +1402,7 @@ export default defineFrontComponent({ ### How bundling works -The build step (`yarn twenty dev` or `yarn twenty build`) uses esbuild to produce a single self-contained file per logic function and per front component. All imported packages are inlined into the bundle. +The build step uses esbuild to produce a single self-contained file per logic function and per front component. All imported packages are inlined into the bundle. **Logic functions** run in a Node.js environment. Node built-in modules (`fs`, `path`, `crypto`, `http`, etc.) are available and do not need to be installed. diff --git a/packages/twenty-docs/developers/extend/apps/getting-started.mdx b/packages/twenty-docs/developers/extend/apps/getting-started.mdx index a2dbabc672..de240b822a 100644 --- a/packages/twenty-docs/developers/extend/apps/getting-started.mdx +++ b/packages/twenty-docs/developers/extend/apps/getting-started.mdx @@ -98,6 +98,21 @@ Dev mode is only available on Twenty instances running in development (`NODE_ENV Dev mode terminal output
+#### One-shot sync with `yarn twenty dev --once` + +If you do not want a watcher running in the background (for example in a CI pipeline, a git hook, or a scripted workflow), pass the `--once` flag. It runs the same pipeline as `yarn twenty dev` — build manifest, bundle files, upload, sync, regenerate the typed API client — but **exits as soon as the sync completes**: + +```bash filename="Terminal" +yarn twenty dev --once +``` + +| Command | Behavior | When to use | +|---------|----------|-------------| +| `yarn twenty dev` | Watches your source files and re-syncs on every change. Keeps running until you stop it. | Interactive local development — you want the live status panel and instant feedback loop. | +| `yarn twenty dev --once` | Performs a single build + sync, then exits with code `0` on success or `1` on failure. | Scripts, CI, pre-commit hooks, AI agents, and any non-interactive workflow. | + +Both modes require a Twenty server running in development mode and an authenticated remote — the same prerequisites apply. + ### See your app in Twenty Open [http://localhost:2020/settings/applications#developer](http://localhost:2020/settings/applications#developer) in your browser. Navigate to **Settings > Apps** and select the **Developer** tab. You should see your app listed under **Your Apps**: diff --git a/packages/twenty-sdk/package.json b/packages/twenty-sdk/package.json index e7e245e4e2..26d50f52d4 100644 --- a/packages/twenty-sdk/package.json +++ b/packages/twenty-sdk/package.json @@ -1,6 +1,6 @@ { "name": "twenty-sdk", - "version": "0.8.0", + "version": "0.9.0-canary.0", "main": "dist/index.cjs", "module": "dist/index.mjs", "types": "dist/sdk/index.d.ts", diff --git a/packages/twenty-sdk/src/cli/__tests__/apps/minimal-app/__integration__/app-dev-once/app-dev-once.integration.spec.ts b/packages/twenty-sdk/src/cli/__tests__/apps/minimal-app/__integration__/app-dev-once/app-dev-once.integration.spec.ts new file mode 100644 index 0000000000..bd3b7450d7 --- /dev/null +++ b/packages/twenty-sdk/src/cli/__tests__/apps/minimal-app/__integration__/app-dev-once/app-dev-once.integration.spec.ts @@ -0,0 +1,111 @@ +import { readdir } from 'node:fs/promises'; +import { join } from 'path'; +import { type Manifest, OUTPUT_DIR } from 'twenty-shared/application'; + +import { MINIMAL_APP_PATH } from '@/cli/__tests__/apps/fixture-paths'; +import { EXPECTED_MANIFEST } from '@/cli/__tests__/apps/minimal-app/__integration__/app-dev/expected-manifest'; +import { normalizeManifestForComparison } from '@/cli/__tests__/integration/utils/normalize-manifest.util'; +import { appDevOnce, type AppDevOnceResult } from '@/cli/operations/dev-once'; +import { type CommandResult } from '@/cli/types'; +import { pathExists, readJson, remove } from '@/cli/utilities/file/fs-utils'; + +const OUTPUT_PATH = join(MINIMAL_APP_PATH, OUTPUT_DIR); +const MANIFEST_PATH = join(OUTPUT_PATH, 'manifest.json'); + +describe('minimal-app dev-once', () => { + let result: CommandResult; + + beforeAll(async () => { + // Make sure we are starting from a clean slate so we know the + // generated files come from this run, not a previous one. + await remove(OUTPUT_PATH); + + result = await appDevOnce({ appPath: MINIMAL_APP_PATH }); + + if (!result.success) { + throw new Error( + `appDevOnce did not succeed: ${result.error.code} - ${result.error.message}`, + ); + } + }, 60000); + + describe('result', () => { + it('should return success', () => { + expect(result.success).toBe(true); + }); + + it('should report the application display name from the manifest', () => { + if (!result.success) { + throw new Error('expected success'); + } + + expect(result.data.applicationDisplayName).toBe( + EXPECTED_MANIFEST.application.displayName, + ); + }); + + it('should report the application universal identifier', () => { + if (!result.success) { + throw new Error('expected success'); + } + + expect(result.data.applicationUniversalIdentifier).toBe( + EXPECTED_MANIFEST.application.universalIdentifier, + ); + }); + + it('should report a non-zero file count', () => { + if (!result.success) { + throw new Error('expected success'); + } + + expect(result.data.fileCount).toBeGreaterThan(0); + }); + + it('should report the output directory', () => { + if (!result.success) { + throw new Error('expected success'); + } + + expect(result.data.outputDir).toBe(OUTPUT_PATH); + }); + }); + + describe('manifest', () => { + it('should have generated manifest.json', async () => { + expect(await pathExists(MANIFEST_PATH)).toBe(true); + }); + + it('should write the same manifest content as `dev`', async () => { + const manifest = normalizeManifestForComparison( + await readJson(MANIFEST_PATH), + ); + + expect(manifest).toEqual( + normalizeManifestForComparison(EXPECTED_MANIFEST), + ); + }); + }); + + describe('built files', () => { + it('should have built the logic function', async () => { + const files = (await readdir(OUTPUT_PATH, { recursive: true })).map( + (file) => file.toString(), + ); + + expect( + files.filter((file) => file.includes('.function.')).sort(), + ).toEqual(['my.function.mjs', 'my.function.mjs.map']); + }); + + it('should have built the front component', async () => { + const files = (await readdir(OUTPUT_PATH, { recursive: true })).map( + (file) => file.toString(), + ); + + expect( + files.filter((file) => file.includes('.front-component.')).sort(), + ).toEqual(['my.front-component.mjs', 'my.front-component.mjs.map']); + }); + }); +}); diff --git a/packages/twenty-sdk/src/cli/__tests__/integration/utils/setup-app-dev-mocks.ts b/packages/twenty-sdk/src/cli/__tests__/integration/utils/setup-app-dev-mocks.ts index 402b506002..1b3001baea 100644 --- a/packages/twenty-sdk/src/cli/__tests__/integration/utils/setup-app-dev-mocks.ts +++ b/packages/twenty-sdk/src/cli/__tests__/integration/utils/setup-app-dev-mocks.ts @@ -29,6 +29,9 @@ const mockApiService = { }), syncApplication: vi.fn().mockResolvedValue({ success: true, data: true }), uploadFile: vi.fn().mockResolvedValue({ success: true, data: true }), + getSchema: vi + .fn() + .mockResolvedValue({ success: true, data: 'mock-core-schema' }), }; vi.mock('@/cli/utilities/api/api-service', () => ({ @@ -43,6 +46,7 @@ vi.mock('@/cli/utilities/api/api-service', () => ({ createDevelopmentApplication = mockApiService.createDevelopmentApplication; syncApplication = mockApiService.syncApplication; uploadFile = mockApiService.uploadFile; + getSchema = mockApiService.getSchema; }, })); @@ -52,6 +56,12 @@ vi.mock('@/cli/utilities/file/file-uploader', () => ({ }, })); +vi.mock('@/cli/utilities/client/client-service', () => ({ + ClientService: class { + generateCoreClient = vi.fn().mockResolvedValue(undefined); + }, +})); + vi.mock('@/cli/utilities/dev/ui/components/dev-ui', () => ({ renderDevUI: vi.fn().mockResolvedValue({ unmount: vi.fn() }), })); diff --git a/packages/twenty-sdk/src/cli/commands/app-command.ts b/packages/twenty-sdk/src/cli/commands/app-command.ts index 216cddb200..327dbbb0b0 100644 --- a/packages/twenty-sdk/src/cli/commands/app-command.ts +++ b/packages/twenty-sdk/src/cli/commands/app-command.ts @@ -13,12 +13,14 @@ import { LogicFunctionLogsCommand } from './logs'; import { AppPublishCommand } from './publish'; import { registerRemoteCommands } from './remote'; import { registerServerCommands } from './server'; +import { AppDevOnceCommand } from './dev-once'; import { AppTypecheckCommand } from './typecheck'; import { AppUninstallCommand } from './uninstall'; export const registerCommands = (program: Command): void => { const buildCommand = new AppBuildCommand(); const devCommand = new AppDevCommand(); + const devOnceCommand = new AppDevOnceCommand(); const installCommand = new AppInstallCommand(); const publishCommand = new AppPublishCommand(); const typecheckCommand = new AppTypecheckCommand(); @@ -31,14 +33,40 @@ export const registerCommands = (program: Command): void => { program .command('dev [appPath]') - .description('Watch and sync local application changes') + .description( + 'Build and sync local application changes (watches by default; use --once for a one-shot sync)', + ) + .option( + '-w, --watch', + 'Watch source files and re-sync on every change (default behavior)', + ) + .option( + '-o, --once', + 'Build and sync once, then exit (useful for CI, scripts, and pre-commit hooks)', + ) .option('-v, --verbose', 'Show detailed logs') .option('-d, --debug', 'Show detailed logs (alias for --verbose)') .action(async (appPath, options) => { - await devCommand.execute({ + if (options.once && options.watch) { + console.error( + chalk.red( + 'Error: --once and --watch are mutually exclusive. Watch mode is the default.', + ), + ); + process.exit(1); + } + + const commonOptions = { appPath: formatPath(appPath), verbose: options.verbose || options.debug, - }); + }; + + if (options.once) { + await devOnceCommand.execute(commonOptions); + return; + } + + await devCommand.execute(commonOptions); }); program diff --git a/packages/twenty-sdk/src/cli/commands/dev-once.ts b/packages/twenty-sdk/src/cli/commands/dev-once.ts new file mode 100644 index 0000000000..5d335d4860 --- /dev/null +++ b/packages/twenty-sdk/src/cli/commands/dev-once.ts @@ -0,0 +1,38 @@ +import { appDevOnce } from '@/cli/operations/dev-once'; +import { CURRENT_EXECUTION_DIRECTORY } from '@/cli/utilities/config/current-execution-directory'; +import { checkSdkVersionCompatibility } from '@/cli/utilities/version/check-sdk-version-compatibility'; +import chalk from 'chalk'; + +export type AppDevOnceCommandOptions = { + appPath?: string; + verbose?: boolean; +}; + +export class AppDevOnceCommand { + async execute(options: AppDevOnceCommandOptions): Promise { + const appPath = options.appPath ?? CURRENT_EXECUTION_DIRECTORY; + + await checkSdkVersionCompatibility(appPath); + + console.log(chalk.blue('Syncing application...')); + console.log(chalk.gray(`App path: ${appPath}\n`)); + + const result = await appDevOnce({ + appPath, + verbose: options.verbose, + onProgress: (message) => console.log(chalk.gray(message)), + }); + + if (!result.success) { + console.error(chalk.red(result.error.message)); + process.exit(1); + } + + console.log( + chalk.green( + `\n✓ Synced ${result.data.applicationDisplayName} (${result.data.fileCount} file${result.data.fileCount === 1 ? '' : 's'})`, + ), + ); + console.log(chalk.gray(`Output: ${result.data.outputDir}`)); + } +} diff --git a/packages/twenty-sdk/src/cli/operations/dev-once.ts b/packages/twenty-sdk/src/cli/operations/dev-once.ts new file mode 100644 index 0000000000..d117795111 --- /dev/null +++ b/packages/twenty-sdk/src/cli/operations/dev-once.ts @@ -0,0 +1,273 @@ +import path from 'path'; +import { OUTPUT_DIR, type Manifest } from 'twenty-shared/application'; + +import { ApiService } from '@/cli/utilities/api/api-service'; +import { buildApplication } from '@/cli/utilities/build/common/build-application'; +import { runTypecheck } from '@/cli/utilities/build/common/typecheck-plugin'; +import { buildAndValidateManifest } from '@/cli/utilities/build/manifest/build-and-validate-manifest'; +import { manifestUpdateChecksums } from '@/cli/utilities/build/manifest/manifest-update-checksums'; +import { writeManifestToOutput } from '@/cli/utilities/build/manifest/manifest-writer'; +import { ClientService } from '@/cli/utilities/client/client-service'; +import { ConfigService } from '@/cli/utilities/config/config-service'; +import { formatSyncErrorEvents } from '@/cli/utilities/dev/orchestrator/steps/format-sync-error-events'; +import { serializeError } from '@/cli/utilities/error/serialize-error'; +import { FileUploader } from '@/cli/utilities/file/file-uploader'; +import { runSafe } from '@/cli/utilities/run-safe'; +import { APP_ERROR_CODES, type CommandResult } from '@/cli/types'; + +export type AppDevOnceOptions = { + appPath: string; + verbose?: boolean; + onProgress?: (message: string) => void; +}; + +export type AppDevOnceResult = { + outputDir: string; + fileCount: number; + applicationDisplayName: string; + applicationUniversalIdentifier: string; +}; + +const innerAppDevOnce = async ( + options: AppDevOnceOptions, +): Promise> => { + const { appPath, onProgress, verbose } = options; + + onProgress?.('Checking server...'); + + const apiService = new ApiService({ disableInterceptors: true }); + const validateAuth = await apiService.validateAuth(); + + if (!validateAuth.serverUp) { + return { + success: false, + error: { + code: APP_ERROR_CODES.SYNC_FAILED, + message: + 'Cannot reach Twenty server.\n\n' + + ' Start a local server:\n' + + ' yarn twenty server start\n\n' + + ' Check server status:\n' + + ' yarn twenty server status', + }, + }; + } + + if (!validateAuth.authValid) { + return { + success: false, + error: { + code: APP_ERROR_CODES.SYNC_FAILED, + message: + 'Authentication failed. Run `yarn twenty remote add --local` to authenticate.', + }, + }; + } + + onProgress?.('Building manifest...'); + + const manifestResult = await buildAndValidateManifest(appPath); + + if (!manifestResult.success) { + return { + success: false, + error: { + code: APP_ERROR_CODES.MANIFEST_BUILD_FAILED, + message: manifestResult.errors.join('\n'), + }, + }; + } + + for (const warning of manifestResult.warnings) { + onProgress?.(`⚠ ${warning}`); + } + + onProgress?.('Building application files...'); + + const buildResult = await buildApplication({ + appPath, + manifest: manifestResult.manifest, + filePaths: manifestResult.filePaths, + }); + + onProgress?.('Running typecheck...'); + + const typecheckErrors = await runTypecheck(appPath); + + if (typecheckErrors.length > 0) { + const errorMessages = typecheckErrors.map( + (error) => + `${error.file}(${error.line},${error.column + 1}): ${error.text}`, + ); + + return { + success: false, + error: { + code: APP_ERROR_CODES.TYPECHECK_FAILED, + message: `Typecheck failed:\n${errorMessages.join('\n')}`, + }, + }; + } + + const manifest: Manifest = manifestUpdateChecksums({ + manifest: manifestResult.manifest, + builtFileInfos: buildResult.builtFileInfos, + }); + + await writeManifestToOutput(appPath, manifest); + + onProgress?.('Registering application...'); + + const configService = new ConfigService(); + const registrationResult = + await apiService.findApplicationRegistrationByUniversalIdentifier( + manifest.application.universalIdentifier, + ); + + if (!registrationResult.success) { + return { + success: false, + error: { + code: APP_ERROR_CODES.SYNC_FAILED, + message: `Failed to check app registration: ${serializeError(registrationResult.error)}`, + }, + }; + } + + if (!registrationResult.data) { + const createRegistrationResult = + await apiService.createApplicationRegistration({ + name: manifest.application.displayName, + universalIdentifier: manifest.application.universalIdentifier, + }); + + if (!createRegistrationResult.success) { + return { + success: false, + error: { + code: APP_ERROR_CODES.SYNC_FAILED, + message: `Failed to create app registration: ${serializeError(createRegistrationResult.error)}`, + }, + }; + } + + await configService.setConfig({ + oauthClientId: + createRegistrationResult.data.applicationRegistration.oAuthClientId, + }); + } + + const createDevAppResult = await apiService.createDevelopmentApplication({ + universalIdentifier: manifest.application.universalIdentifier, + name: manifest.application.displayName, + }); + + if (!createDevAppResult.success) { + return { + success: false, + error: { + code: APP_ERROR_CODES.SYNC_FAILED, + message: `Failed to create development application: ${serializeError(createDevAppResult.error)}`, + }, + }; + } + + onProgress?.( + `Uploading ${buildResult.builtFileInfos.size} file${buildResult.builtFileInfos.size === 1 ? '' : 's'}...`, + ); + + const fileUploader = new FileUploader({ + appPath, + applicationUniversalIdentifier: manifest.application.universalIdentifier, + }); + + const uploadErrors: string[] = []; + + const uploadPromises = Array.from(buildResult.builtFileInfos.values()).map( + async (builtFileInfo) => { + if (verbose) { + onProgress?.(`Uploading ${builtFileInfo.builtPath}`); + } + + const result = await fileUploader.uploadFile({ + builtPath: builtFileInfo.builtPath, + fileFolder: builtFileInfo.fileFolder, + }); + + if (!result.success) { + uploadErrors.push( + `Failed to upload ${builtFileInfo.builtPath}: ${serializeError(result.error)}`, + ); + } + }, + ); + + await Promise.all(uploadPromises); + + if (uploadErrors.length > 0) { + return { + success: false, + error: { + code: APP_ERROR_CODES.SYNC_FAILED, + message: uploadErrors.join('\n'), + }, + }; + } + + onProgress?.('Syncing manifest...'); + + const syncResult = await apiService.syncApplication(manifest); + + if (!syncResult.success) { + const errorEvents = verbose + ? null + : formatSyncErrorEvents(syncResult.error); + + const message = errorEvents + ? errorEvents.map((event) => event.message).join('\n') + : `Sync failed with error: ${serializeError(syncResult.error)}`; + + return { + success: false, + error: { + code: APP_ERROR_CODES.SYNC_FAILED, + message, + }, + }; + } + + onProgress?.('Generating API client...'); + + try { + const config = await configService.getConfig(); + const clientService = new ClientService(); + + await clientService.generateCoreClient({ + appPath, + authToken: config.accessToken, + }); + } catch (error) { + return { + success: false, + error: { + code: APP_ERROR_CODES.SYNC_FAILED, + message: `Failed to generate API client: ${serializeError(error)}`, + }, + }; + } + + return { + success: true, + data: { + outputDir: path.join(appPath, OUTPUT_DIR), + fileCount: buildResult.builtFileInfos.size, + applicationDisplayName: manifest.application.displayName, + applicationUniversalIdentifier: manifest.application.universalIdentifier, + }, + }; +}; + +export const appDevOnce = ( + options: AppDevOnceOptions, +): Promise> => + runSafe(() => innerAppDevOnce(options), APP_ERROR_CODES.SYNC_FAILED); diff --git a/packages/twenty-sdk/src/cli/operations/index.ts b/packages/twenty-sdk/src/cli/operations/index.ts index b69fb04af5..6507282ef9 100644 --- a/packages/twenty-sdk/src/cli/operations/index.ts +++ b/packages/twenty-sdk/src/cli/operations/index.ts @@ -11,6 +11,8 @@ export { appBuild } from './build'; export type { AppBuildOptions, AppBuildResult } from './build'; export { appDeploy } from './deploy'; export type { AppDeployOptions, AppDeployResult } from './deploy'; +export { appDevOnce } from './dev-once'; +export type { AppDevOnceOptions, AppDevOnceResult } from './dev-once'; export { appInstall } from './install'; export type { AppInstallOptions } from './install'; export { appPublish } from './publish';