Improve readme (#14858)

This commit is contained in:
martmull
2025-10-02 22:50:37 +02:00
committed by GitHub
parent d4160bf064
commit 87256e82f5
19 changed files with 158 additions and 357 deletions
@@ -3,11 +3,11 @@ import * as fs from 'fs-extra';
import inquirer from 'inquirer';
import path from 'path';
import { randomUUID } from 'crypto';
import { resolveAppPath } from '../utils/app-path-resolver';
import { parseJsoncFile, writeJsoncFile } from '../utils/jsonc-parser';
import { getSchemaUrls } from '../utils/schema-validator';
import { CURRENT_EXECUTION_DIRECTORY } from '../constants/current-execution-directory';
enum SyncableEntity {
export enum SyncableEntity {
AGENT = 'agent',
OBJECT = 'object',
SERVERLESS_FUNCTION = 'serverlessFunction',
@@ -27,12 +27,16 @@ const getFolderName = (entity: SyncableEntity) => {
}
};
export class AppAddCommand {
async execute(options: { path?: string }): Promise<void> {
try {
const appPath = await resolveAppPath(options.path);
export const isSyncableEntity = (value: string): value is SyncableEntity => {
return Object.values(SyncableEntity).includes(value as SyncableEntity);
};
const entity = await this.getEntity();
export class AppAddCommand {
async execute(entityType?: SyncableEntity): Promise<void> {
try {
const appPath = CURRENT_EXECUTION_DIRECTORY;
const entity = entityType ?? (await this.getEntity());
const appExists = await fs.pathExists(appPath);
@@ -99,12 +103,7 @@ export class AppAddCommand {
name: 'entity',
message: `What entity do you want to create?`,
default: '',
choices: [
SyncableEntity.AGENT,
SyncableEntity.OBJECT,
SyncableEntity.SERVERLESS_FUNCTION,
SyncableEntity.TRIGGER,
],
choices: [SyncableEntity.SERVERLESS_FUNCTION, SyncableEntity.TRIGGER],
},
]);
@@ -261,7 +260,8 @@ export class AppAddCommand {
{
type: 'input',
name: 'eventName',
message: 'Enter the database event name (e.g., company.created):',
message:
'Enter the database event name (e.g. company.created, *.updated, person.*):',
validate: (input) => {
if (input.length === 0) {
return 'Event name is required';
@@ -1,15 +1,16 @@
import chalk from 'chalk';
import * as chokidar from 'chokidar';
import { ApiService } from '../services/api.service';
import { resolveAppPath } from '../utils/app-path-resolver';
import { syncApp } from '../utils/app-sync';
import { CURRENT_EXECUTION_DIRECTORY } from '../constants/current-execution-directory';
export class AppDevCommand {
private apiService = new ApiService();
async execute(options: { path?: string; debounce: string }): Promise<void> {
async execute(options: { debounce: string }): Promise<void> {
try {
const appPath = await resolveAppPath(options.path);
const appPath = CURRENT_EXECUTION_DIRECTORY;
const debounceMs = parseInt(options.debounce, 10);
this.logStartupInfo(appPath, debounceMs);
@@ -6,10 +6,10 @@ import { copyBaseApplicationProject } from '../utils/app-template';
import kebabCase from 'lodash.kebabcase';
export class AppInitCommand {
async execute(options: { path?: string }): Promise<void> {
async execute(directory?: string): Promise<void> {
try {
const { appName, appDirectory, appDescription } =
await this.getAppInfos(options);
await this.getAppInfos(directory);
await this.validateDirectory(appDirectory);
@@ -33,7 +33,7 @@ export class AppInitCommand {
}
}
private async getAppInfos(options: { path?: string }): Promise<{
private async getAppInfos(directory?: string): Promise<{
appName: string;
appDirectory: string;
appDescription: string;
@@ -60,9 +60,9 @@ export class AppInitCommand {
const appDescription = description.trim();
const appDirectory = options.path
? path.resolve(options.path, kebabCase(appName))
: path.join(process.cwd(), kebabCase(appName)!);
const appDirectory = directory
? path.join(process.cwd(), kebabCase(directory))
: path.join(process.cwd(), kebabCase(appName));
return { appName, appDirectory, appDescription };
}
@@ -1,14 +1,14 @@
import chalk from 'chalk';
import { ApiService } from '../services/api.service';
import { resolveAppPath } from '../utils/app-path-resolver';
import { syncApp } from '../utils/app-sync';
import { CURRENT_EXECUTION_DIRECTORY } from '../constants/current-execution-directory';
export class AppSyncCommand {
private apiService = new ApiService();
async execute(options: { path?: string }): Promise<void> {
async execute(): Promise<void> {
try {
const appPath = await resolveAppPath(options.path);
const appPath = CURRENT_EXECUTION_DIRECTORY;
console.log(chalk.blue('🚀 Syncing Twenty Application'));
console.log(chalk.gray(`📁 App Path: ${appPath}`));
+32 -22
View File
@@ -2,7 +2,12 @@ import { Command } from 'commander';
import { AppSyncCommand } from './app-sync.command';
import { AppDevCommand } from './app-dev.command';
import { AppInitCommand } from './app-init.command';
import { AppAddCommand } from './app-add.command';
import {
AppAddCommand,
isSyncableEntity,
SyncableEntity,
} from './app-add.command';
import chalk from 'chalk';
export class AppCommand {
private devCommand = new AppDevCommand();
@@ -17,10 +22,6 @@ export class AppCommand {
appCommand
.command('dev')
.description('Watch and sync local application changes')
.option(
'-p, --path <path>',
'Application directory path (auto-detected if not specified)',
)
.option('-d, --debounce <ms>', 'Debounce delay in milliseconds', '1000')
.action(async (options) => {
await this.devCommand.execute(options);
@@ -29,31 +30,40 @@ export class AppCommand {
appCommand
.command('sync')
.description('Sync application to Twenty')
.option(
'-p, --path <path>',
'Application directory path (auto-detected if not specified)',
)
.action(async (options) => {
await this.syncCommand.execute(options);
.action(async () => {
await this.syncCommand.execute();
});
appCommand
.command('init')
.command('init [directory]')
.description('Initialize a new Twenty application')
.option('-p, --path <path>', 'Directory to create the application in')
.action(async (options) => {
await this.initCommand.execute(options);
.action(async (directory?: string) => {
if (directory && !/^[a-z0-9-]+$/.test(directory)) {
console.error(
chalk.red(
`Invalid directory "${directory}". Must contain only lowercase letters, numbers, and hyphens`,
),
);
process.exit(1);
}
await this.initCommand.execute(directory);
});
appCommand
.command('add')
.description('Add a new entity to your application')
.option(
'-p, --path <path>',
'Application directory path (auto-detected if not specified)',
.command('add [entityType]')
.description(
`Add a new entity to your application (${Object.values(SyncableEntity).join('|')})`,
)
.action(async (options) => {
await this.addCommand.execute(options);
.action(async (entityType?: string) => {
if (entityType && !isSyncableEntity(entityType)) {
console.error(
chalk.red(
`Invalid entity type "${entityType}". Must be one of: ${Object.values(SyncableEntity).join('|')}`,
),
);
process.exit(1);
}
await this.addCommand.execute(entityType as SyncableEntity);
});
return appCommand;
@@ -2,14 +2,14 @@
{description}
## Development
## Requirements
- twenty-cli `npm install -g twenty-cli`
- an `apiKey`. Go to `https://twenty.com/settings/api-webhooks` to generate one
## Install to your Twenty workspace
```bash
twenty app dev --path {appDir}
```
## Deployment
```bash
twenty app sync --path {appDir}
twenty auth login
twenty app sync
```
@@ -0,0 +1,2 @@
export const CURRENT_EXECUTION_DIRECTORY =
process.env.INIT_CWD || process.cwd();
@@ -131,7 +131,7 @@ export class ApiService {
return {
success: true,
data: response.data.data.syncApplication,
message: `Successfully synced application: ${manifest.label}`,
message: `Successfully synced application: ${manifest.name}`,
};
} catch (error) {
if (axios.isAxiosError(error) && error.response) {
@@ -7,7 +7,7 @@ export interface TwentyConfig {
export type PackageJson = {
$schema?: string;
universalIdentifier: string;
label: string;
name: string;
license: string;
description?: string;
engines: {
@@ -1,87 +0,0 @@
import * as fs from 'fs-extra';
import * as path from 'path';
import { APP_MANIFEST_SCHEMA_URL } from '../constants/schemas';
export const findProjectRoot = async (): Promise<string | null> => {
let currentDir = process.cwd();
const maxDepth = 10;
let depth = 0;
while (depth < maxDepth) {
const nxConfig = path.join(currentDir, 'nx.json');
const packageJson = path.join(currentDir, 'package.json');
if (await fs.pathExists(nxConfig)) {
return currentDir;
}
if (await fs.pathExists(packageJson)) {
try {
const pkg = await fs.readJson(packageJson);
if (pkg.workspaces || pkg.name === 'twenty') {
return currentDir;
}
} catch {
// Ignore JSON parse errors
}
}
const parentDir = path.dirname(currentDir);
if (parentDir === currentDir) break;
currentDir = parentDir;
depth++;
}
return null;
};
export const findNearbyApps = async (startDir: string): Promise<string[]> => {
const apps: string[] = [];
try {
const searchPaths = [
startDir,
path.join(startDir, '..'),
path.join(startDir, '../..'),
path.join(startDir, 'packages/twenty-apps'),
path.join(startDir, '../../packages/twenty-apps'),
];
for (const searchPath of searchPaths) {
if (await fs.pathExists(searchPath)) {
const items = await fs.readdir(searchPath, { withFileTypes: true });
for (const item of items) {
if (item.isDirectory()) {
const itemPath = path.join(searchPath, item.name);
if (await isValidAppPath(itemPath)) {
apps.push(itemPath);
}
}
}
}
}
} catch {
// Ignore errors during search
}
return apps.slice(0, 5);
};
export const isValidAppPath = async (appPath: string): Promise<boolean> => {
const packageJsonPath = path.join(appPath, 'package.json');
if (!(await fs.pathExists(packageJsonPath))) {
return false;
}
try {
const packageJson = await fs.readJson(packageJsonPath);
// Check if this is a Twenty app by looking for the exact $schema URL
return packageJson.$schema === APP_MANIFEST_SCHEMA_URL;
} catch {
return false;
}
};
@@ -1,101 +0,0 @@
import * as fs from 'fs-extra';
import * as path from 'path';
import {
findNearbyApps,
findProjectRoot,
isValidAppPath,
} from './app-discovery';
export const resolveAppPath = async (
providedPath?: string,
): Promise<string> => {
if (providedPath && path.isAbsolute(providedPath)) {
return validateAppPath(providedPath);
}
if (providedPath) {
return resolveRelativePath(providedPath);
}
return autoDetectAppPath();
};
const resolveRelativePath = async (providedPath: string): Promise<string> => {
const fromCwd = path.resolve(process.cwd(), providedPath);
if (await isValidAppPath(fromCwd)) {
return fromCwd;
}
const projectRoot = await findProjectRoot();
if (projectRoot) {
const fromProjectRoot = path.resolve(projectRoot, providedPath);
if (await isValidAppPath(fromProjectRoot)) {
return fromProjectRoot;
}
}
throw new Error(`Cannot find Twenty app package.json at any of these locations:
- ${fromCwd}
- ${projectRoot ? path.resolve(projectRoot, providedPath) : 'N/A (no project root found)'}
Please check the path or run from the correct directory.`);
};
const autoDetectAppPath = async (): Promise<string> => {
let currentDir = process.cwd();
const maxDepth = 10;
let depth = 0;
while (depth < maxDepth) {
if (await isValidAppPath(currentDir)) {
return currentDir;
}
const parentDir = path.dirname(currentDir);
if (parentDir === currentDir) break;
currentDir = parentDir;
depth++;
}
const suggestions = await findNearbyApps(process.cwd());
let errorMessage =
'No Twenty app found in current directory or parent directories.';
if (suggestions.length > 0) {
errorMessage += '\n\nFound Twenty applications nearby:';
suggestions.forEach((suggestion: string, i: number) => {
errorMessage += `\n ${i + 1}. ${suggestion}`;
});
errorMessage +=
'\n\nTry running from one of these directories or use --path option.';
} else {
errorMessage += '\n\nRun `twenty app init` to create a new application.';
}
throw new Error(errorMessage);
};
const validateAppPath = async (appPath: string): Promise<string> => {
if (!(await fs.pathExists(appPath))) {
throw new Error(`Directory does not exist: ${appPath}`);
}
if (!(await isValidAppPath(appPath))) {
let errorMessage = `Not a valid Twenty app: ${appPath}`;
const packageJsonPath = path.join(appPath, 'package.json');
if (await fs.pathExists(packageJsonPath)) {
errorMessage +=
'\n\npackage.json found but missing required $schema field for Twenty apps.';
} else {
errorMessage += '\n\npackage.json not found.';
}
errorMessage += '\n\nRun `twenty app init` to create a new application.';
throw new Error(errorMessage);
}
return appPath;
};
@@ -69,8 +69,6 @@ const createReadmeContent = async ({
readmeContent = readmeContent.replace(/\{description}/g, appDescription);
readmeContent = readmeContent.replace(/\{appDir}/g, appDirectory);
await fs.writeFile(path.join(appDirectory, 'README.md'), readmeContent);
};