Update twenty sdk commands (#20735)

Performs twenty-sdk cli command migration:

Summary

``` ┌─────┬──────────────────────────┬────────────────────────────┬───────────────────────┐
 │  #  │       Old command        │        New command         │        Status         │
 ├─────┼──────────────────────────┼────────────────────────────┼───────────────────────┤
 │ 1   │ twenty dev [appPath]     │ twenty dev [appPath]       │ Unchanged (now also   │
 │     │                          │                            │ DEFAULT)              │
 ├─────┼──────────────────────────┼────────────────────────────┼───────────────────────┤
 │ 2   │ twenty dev --once        │ twenty dev --once          │ Unchanged             │
 │     │ [appPath]                │ [appPath]                  │                       │
 ├─────┼──────────────────────────┼────────────────────────────┼───────────────────────┤
 │ 3   │ twenty dev --watch       │ twenty dev [appPath]       │ --watch flag removed  │
 │     │ [appPath]                │                            │ (was default)         │
 ├─────┼──────────────────────────┼────────────────────────────┼───────────────────────┤
 │ 4   │ twenty dev --verbose     │ twenty dev --verbose       │ Unchanged             │
 │     │ [appPath]                │ [appPath]                  │                       │
 ├─────┼──────────────────────────┼────────────────────────────┼───────────────────────┤
 │ 5   │ twenty dev --debug       │ twenty dev --debug         │ Unchanged             │
 │     │ [appPath]                │ [appPath]                  │                       │
 ├─────┼──────────────────────────┼────────────────────────────┼───────────────────────┤
 │ 6   │ twenty dev --debounceMs  │ twenty dev --debounceMs    │ Unchanged             │
 │     │ <ms> [appPath]           │ <ms> [appPath]             │                       │
 ├─────┼──────────────────────────┼────────────────────────────┼───────────────────────┤
 │ 7   │ twenty build [appPath]   │ twenty dev:build [appPath] │ Deprecated → colon    │
 │     │                          │                            │ command               │
 ├─────┼──────────────────────────┼────────────────────────────┼───────────────────────┤
 │ 8   │ twenty build --tarball   │ twenty dev:build --tarball │ Deprecated → colon    │
 │     │ [appPath]                │  [appPath]                 │ command               │
 ├─────┼──────────────────────────┼────────────────────────────┼───────────────────────┤
 │ 9   │ twenty typecheck         │ twenty dev:typecheck       │ Deprecated → colon    │
 │     │ [appPath]                │ [appPath]                  │ command               │
 ├─────┼──────────────────────────┼────────────────────────────┼───────────────────────┤
 │ 10  │ twenty logs [appPath]    │ twenty dev:fn-logs         │ Deprecated → colon    │
 │     │                          │ [appPath]                  │ command               │
 ├─────┼──────────────────────────┼────────────────────────────┼───────────────────────┤
 │ 11  │ twenty logs -n <name>    │ twenty dev:fn-logs -n      │ Deprecated → colon    │
 │     │ [appPath]                │ <name> [appPath]           │ command               │
 ├─────┼──────────────────────────┼────────────────────────────┼───────────────────────┤
 │ 12  │ twenty logs -u <id>      │ twenty dev:fn-logs -u <id> │ Deprecated → colon    │
 │     │ [appPath]                │  [appPath]                 │ command               │
 ├─────┼──────────────────────────┼────────────────────────────┼───────────────────────┤
 │ 13  │ twenty exec [appPath]    │ twenty dev:fn-exec         │ Deprecated → colon    │
 │     │                          │ [appPath]                  │ command               │
 ├─────┼──────────────────────────┼────────────────────────────┼───────────────────────┤
 │ 14  │ twenty exec -n <name>    │ twenty dev:fn-exec -n      │ Deprecated → colon    │
 │     │ [appPath]                │ <name> [appPath]           │ command               │
 ├─────┼──────────────────────────┼────────────────────────────┼───────────────────────┤
 │ 15  │ twenty exec -u <id>      │ twenty dev:fn-exec -u <id> │ Deprecated → colon    │
 │     │ [appPath]                │  [appPath]                 │ command               │
 ├─────┼──────────────────────────┼────────────────────────────┼───────────────────────┤
 │ 16  │ twenty exec -p <json>    │ twenty dev:fn-exec -p      │ Deprecated → colon    │
 │     │ [appPath]                │ <json> [appPath]           │ command               │
 ├─────┼──────────────────────────┼────────────────────────────┼───────────────────────┤
 │ 17  │ twenty exec              │ twenty dev:fn-exec         │ Deprecated → colon    │
 │     │ --postInstall [appPath]  │ --postInstall [appPath]    │ command               │
 ├─────┼──────────────────────────┼────────────────────────────┼───────────────────────┤
 │ 18  │ twenty exec --preInstall │ twenty dev:fn-exec         │ Deprecated → colon    │
 │     │  [appPath]               │ --preInstall [appPath]     │ command               │
 ├─────┼──────────────────────────┼────────────────────────────┼───────────────────────┤
 │ 19  │ twenty add [entityType]  │ twenty dev:add             │ Deprecated → colon    │
 │     │                          │ [entityType]               │ command               │
 ├─────┼──────────────────────────┼────────────────────────────┼───────────────────────┤
 │ 20  │ twenty add --path <path> │ twenty dev:add --path      │ Deprecated → colon    │
 │     │  [entityType]            │ <path> [entityType]        │ command               │
 └─────┴──────────────────────────┴────────────────────────────┴───────────────────────┘

 App lifecycle commands

 ┌─────┬────────────────────────┬────────────────────────────┬─────────────────────────┐
 │  #  │      Old command       │        New command         │         Status          │
 ├─────┼────────────────────────┼────────────────────────────┼─────────────────────────┤
 │ 21  │ twenty publish         │ twenty app:publish         │ Deprecated → colon      │
 │     │ [appPath]              │ [appPath]                  │ command                 │
 ├─────┼────────────────────────┼────────────────────────────┼─────────────────────────┤
 │ 22  │ twenty publish --tag   │ twenty app:publish --tag   │ Deprecated → colon      │
 │     │ <tag> [appPath]        │ <tag> [appPath]            │ command                 │
 ├─────┼────────────────────────┼────────────────────────────┼─────────────────────────┤
 │ 23  │ twenty deploy          │ twenty app:publish         │ Deprecated → colon      │
 │     │ [appPath]              │ --private [appPath]        │ command + --private     │
 ├─────┼────────────────────────┼────────────────────────────┼─────────────────────────┤
 │ 24  │ twenty install         │ twenty app:install         │ Deprecated → colon      │
 │     │ [appPath]              │ [appPath]                  │ command                 │
 ├─────┼────────────────────────┼────────────────────────────┼─────────────────────────┤
 │ 25  │ twenty uninstall       │ twenty app:uninstall       │ Deprecated → colon      │
 │     │ [appPath]              │ [appPath]                  │ command                 │
 ├─────┼────────────────────────┼────────────────────────────┼─────────────────────────┤
 │ 26  │ twenty uninstall -y    │ twenty app:uninstall -y    │ Deprecated → colon      │
 │     │ [appPath]              │ [appPath]                  │ command                 │
 └─────┴────────────────────────┴────────────────────────────┴─────────────────────────┘

 Server commands

 ┌─────┬─────────────────────────┬─────────────────────────────┬──────────────────────┐
 │  #  │       Old command       │         New command         │        Status        │
 ├─────┼─────────────────────────┼─────────────────────────────┼──────────────────────┤
 │ 27  │ twenty server start     │ twenty docker:start         │ Deprecated → colon   │
 │     │                         │                             │ syntax               │
 ├─────┼─────────────────────────┼─────────────────────────────┼──────────────────────┤
 │ 28  │ twenty server start -p  │ twenty docker:start -p      │ Deprecated → colon   │
 │     │ <port>                  │ <port>                      │ syntax               │
 ├─────┼─────────────────────────┼─────────────────────────────┼──────────────────────┤
 │ 29  │ twenty server start     │ twenty docker:start --test  │ Deprecated → colon   │
 │     │ --test                  │                             │ syntax               │
 ├─────┼─────────────────────────┼─────────────────────────────┼──────────────────────┤
 │ 30  │ twenty server stop      │ twenty docker:stop          │ Deprecated → colon   │
 │     │                         │                             │ syntax               │
 ├─────┼─────────────────────────┼─────────────────────────────┼──────────────────────┤
 │ 31  │ twenty server stop      │ twenty docker:stop --test   │ Deprecated → colon   │
 │     │ --test                  │                             │ syntax               │
 ├─────┼─────────────────────────┼─────────────────────────────┼──────────────────────┤
 │ 32  │ twenty server status    │ twenty docker:status        │ Deprecated → colon   │
 │     │                         │                             │ syntax               │
 ├─────┼─────────────────────────┼─────────────────────────────┼──────────────────────┤
 │ 33  │ twenty server status    │ twenty docker:status --test │ Deprecated → colon   │
 │     │ --test                  │                             │ syntax               │
 ├─────┼─────────────────────────┼─────────────────────────────┼──────────────────────┤
 │ 34  │ twenty server logs      │ twenty docker:logs          │ Deprecated → colon   │
 │     │                         │                             │ syntax               │
 ├─────┼─────────────────────────┼─────────────────────────────┼──────────────────────┤
 │ 35  │ twenty server logs -n   │ twenty docker:logs -n       │ Deprecated → colon   │
 │     │ <lines>                 │ <lines>                     │ syntax               │
 ├─────┼─────────────────────────┼─────────────────────────────┼──────────────────────┤
 │ 36  │ twenty server logs      │ twenty docker:logs --test   │ Deprecated → colon   │
 │     │ --test                  │                             │ syntax               │
 ├─────┼─────────────────────────┼─────────────────────────────┼──────────────────────┤
 │ 37  │ twenty server reset     │ twenty docker:reset         │ Deprecated → colon   │
 │     │                         │                             │ syntax               │
 ├─────┼─────────────────────────┼─────────────────────────────┼──────────────────────┤
 │ 38  │ twenty server reset     │ twenty docker:reset --test  │ Deprecated → colon   │
 │     │ --test                  │                             │ syntax               │
 ├─────┼─────────────────────────┼─────────────────────────────┼──────────────────────┤
 │ 39  │ twenty server upgrade   │ twenty docker:upgrade       │ Deprecated → colon   │
 │     │ [version]               │ [version]                   │ syntax               │
 ├─────┼─────────────────────────┼─────────────────────────────┼──────────────────────┤
 │ 40  │ twenty server upgrade   │ twenty docker:upgrade       │ Deprecated → colon   │
 │     │ --test [version]        │ --test [version]            │ syntax               │
 ├─────┼─────────────────────────┼─────────────────────────────┼──────────────────────┤
 │ 41  │ twenty server           │ twenty app:catalog-sync  │ Deprecated → colon   │
 │     │ catalog-sync            │                             │ syntax               │
 ├─────┼─────────────────────────┼─────────────────────────────┼──────────────────────┤
 │ 42  │ twenty server           │ twenty app:catalog-sync  │ Deprecated → colon   │
 │     │ catalog-sync -r <name>  │ -r <name>                   │ syntax               │
 ├─────┼─────────────────────────┼─────────────────────────────┼──────────────────────┤
 │ 43  │ twenty catalog-sync     │ (removed)                   │ Removed (was already │
 │     │                         │                             │  deprecated)         │
 └─────┴─────────────────────────┴─────────────────────────────┴──────────────────────┘

 Remote commands

 ┌─────┬────────────────────────┬──────────────────────────┬──────────────────────────┐
 │  #  │      Old command       │       New command        │          Status          │
 ├─────┼────────────────────────┼──────────────────────────┼──────────────────────────┤
 │ 44  │ twenty remote add      │ twenty remote:add        │ Deprecated → colon       │
 │     │                        │                          │ syntax                   │
 ├─────┼────────────────────────┼──────────────────────────┼──────────────────────────┤
 │ 45  │ twenty remote add --as │ twenty remote:add --as   │ Deprecated → colon       │
 │     │  <name>                │ <name>                   │ syntax                   │
 ├─────┼────────────────────────┼──────────────────────────┼──────────────────────────┤
 │ 46  │ twenty remote add      │ twenty remote:add        │ Deprecated → colon       │
 │     │ --api-key <key>        │ --api-key <key>          │ syntax                   │
 ├─────┼────────────────────────┼──────────────────────────┼──────────────────────────┤
 │ 47  │ twenty remote add      │ twenty remote:add        │ Deprecated → colon       │
 │     │ --api-url <url>        │ --api-url <url>          │ syntax                   │
 ├─────┼────────────────────────┼──────────────────────────┼──────────────────────────┤
 │ 48  │ twenty remote add      │ twenty remote:add        │ Deprecated → colon       │
 │     │ --local                │ --local                  │ syntax                   │
 ├─────┼────────────────────────┼──────────────────────────┼──────────────────────────┤
 │ 49  │ twenty remote add      │ twenty remote:add --test │ Deprecated → colon       │
 │     │ --test                 │                          │ syntax                   │
 ├─────┼────────────────────────┼──────────────────────────┼──────────────────────────┤
 │ 50  │ twenty remote list     │ twenty remote:list       │ Deprecated → colon       │
 │     │                        │                          │ syntax                   │
 ├─────┼────────────────────────┼──────────────────────────┼──────────────────────────┤
 │ 51  │ twenty remote switch   │ twenty remote:use [name] │ Deprecated → colon       │
 │     │ [name]                 │                          │ syntax + renamed         │
 ├─────┼────────────────────────┼──────────────────────────┼──────────────────────────┤
 │ 52  │ twenty remote status   │ twenty remote:status     │ Deprecated → colon       │
 │     │                        │                          │ syntax                   │
 ├─────┼────────────────────────┼──────────────────────────┼──────────────────────────┤
 │ 53  │ twenty remote remove   │ twenty remote:remove     │ Deprecated → colon       │
 │     │ <name>                 │ <name>                   │ syntax                   │
 └─────┴────────────────────────┴──────────────────────────┴──────────────────────────┘
```

---------

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
This commit is contained in:
martmull
2026-05-20 17:12:39 +02:00
committed by GitHub
parent 6e5e7963b5
commit 237a943947
73 changed files with 1364 additions and 1002 deletions
@@ -14,7 +14,7 @@ beforeAll(async () => {
if (!apiUrl || !token) {
throw new Error(
'TWENTY_API_URL and TWENTY_API_KEY must be set.\n' +
'Run: twenty server start --test\n' +
'Run: twenty docker:start --test\n' +
'Or set them in vitest env config.',
);
}
@@ -24,7 +24,7 @@ beforeAll(async () => {
if (!response?.ok) {
throw new Error(
`Twenty server not reachable at ${apiUrl}. ` +
'Run: twenty server start --test',
'Run: twenty docker:start --test',
);
}
@@ -1,7 +1,7 @@
import { join } from 'path';
import { OUTPUT_DIR } from 'twenty-shared/application';
import { AppDevCommand } from '@/cli/commands/dev';
import { AppDevCommand } from '@/cli/commands/dev/dev';
import { pathExists } from '@/cli/utilities/file/fs-utils';
export type RunAppDevResult = {
+2 -2
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env node
import { registerCommands } from '@/cli/commands/app-command';
import { registerCommands } from '@/cli/commands';
import { ConfigService } from '@/cli/utilities/config/config-service';
import chalk from 'chalk';
import { Command, CommanderError } from 'commander';
@@ -17,7 +17,7 @@ program
program.option(
'-r, --remote <name>',
'Use a specific remote (overrides the default set by remote switch)',
'Use a specific remote (overrides the default set by remote:use)',
);
program.hook('preAction', async (thisCommand) => {
@@ -1,252 +0,0 @@
import { formatPath } from '@/cli/utilities/file/file-path';
import chalk from 'chalk';
import type { Command } from 'commander';
import { SyncableEntity } from 'twenty-shared/application';
import { EntityAddCommand } from './add';
import { AppBuildCommand } from './build';
import { CatalogSyncCommand } from './catalog-sync';
import { DeployCommand } from './deploy';
import { AppDevCommand } from './dev';
import { LogicFunctionExecuteCommand } from './exec';
import { AppInstallCommand } from './install';
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();
const uninstallCommand = new AppUninstallCommand();
const catalogSyncCommand = new CatalogSyncCommand();
const deployCommand = new DeployCommand();
const addCommand = new EntityAddCommand();
const logsCommand = new LogicFunctionLogsCommand();
const executeCommand = new LogicFunctionExecuteCommand();
program
.command('dev [appPath]')
.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('--debounceMs <ms>', 'Debounce in ms (default: 2 000)')
.option('-v, --verbose', 'Show detailed logs')
.option('-d, --debug', 'Show detailed logs (alias for --verbose)')
.action(async (appPath, options) => {
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,
debounceMs: options.debounceMs
? parseInt(options.debounceMs, 10)
: undefined,
};
if (options.once) {
await devOnceCommand.execute(commonOptions);
return;
}
await devCommand.execute(commonOptions);
});
program
.command('build [appPath]')
.description('Build, sync, and generate API client into .twenty/output/')
.option('--tarball', 'Also pack into a .tgz tarball')
.action(async (appPath, options) => {
await buildCommand.execute({
appPath: formatPath(appPath),
tarball: options.tarball,
});
});
program
.command('install [appPath]')
.description('Install a deployed application on the connected server')
.option('-r, --remote <name>', 'Install on a specific remote')
.action(async (appPath, options) => {
await installCommand.execute({
appPath: formatPath(appPath),
remote: options.remote,
});
});
program
.command('deploy [appPath]')
.description("Publish a new version to a Twenty server's registry")
.option('-r, --remote <name>', 'Deploy to a specific remote')
.action(async (appPath, options) => {
await deployCommand.execute({
appPath: formatPath(appPath),
remote: options.remote,
});
});
program
.command('publish [appPath]')
.description('Build and publish to npm')
.option('--tag <tag>', 'npm dist-tag (e.g. beta, next)')
.action(async (appPath, options) => {
await publishCommand.execute({
appPath: formatPath(appPath),
tag: options.tag,
});
});
program
.command('catalog-sync')
.description(
'[Deprecated] Moved under server. Use `yarn twenty server catalog-sync`.',
)
.option('-r, --remote <name>', 'Sync on a specific remote')
.action(async (options) => {
console.warn(
chalk.yellow(
'`yarn twenty catalog-sync` is deprecated and will be removed in a future release.\n' +
'Use `yarn twenty server catalog-sync` instead.\n',
),
);
await catalogSyncCommand.execute({
remote: options.remote,
});
});
program
.command('typecheck [appPath]')
.description('Run TypeScript type checking on the application')
.action(async (appPath) => {
await typecheckCommand.execute({
appPath: formatPath(appPath),
});
});
program
.command('uninstall [appPath]')
.description('Uninstall application from Twenty')
.option('-y, --yes', 'Skip confirmation prompt')
.action(async (appPath?: string, options?: { yes?: boolean }) => {
try {
const result = await uninstallCommand.execute({
appPath: formatPath(appPath),
askForConfirmation: !options?.yes,
});
process.exit(result.success ? 0 : 1);
} catch {
process.exit(1);
}
});
registerRemoteCommands(program);
registerServerCommands(program);
program
.command('add [entityType]')
.option('--path <path>', 'Path in which the entity should be created.')
.description(
`Add a new entity to your application (${Object.values(SyncableEntity).join('|')})`,
)
.action(async (entityType?: string, options?: { path?: string }) => {
await addCommand.execute(entityType as SyncableEntity, options?.path);
});
program
.command('exec [appPath]')
.option('--postInstall', 'Execute post-install logic function if defined')
.option('--preInstall', 'Execute pre-install logic function if defined')
.option(
'-p, --payload <payload>',
'JSON payload to send to the function',
'{}',
)
.option(
'-u, --functionUniversalIdentifier <functionUniversalIdentifier>',
'Universal ID of the function to execute',
)
.option(
'-n, --functionName <functionName>',
'Name of the function to execute',
)
.description('Execute a logic function with a JSON payload')
.action(
async (
appPath?: string,
options?: {
postInstall?: boolean;
preInstall?: boolean;
payload?: string;
functionUniversalIdentifier?: string;
functionName?: string;
},
) => {
if (
!options?.postInstall &&
!options?.preInstall &&
!options?.functionUniversalIdentifier &&
!options?.functionName
) {
console.error(
chalk.red(
'Error: Either --postInstall, --preInstall, --functionName (-n), or --functionUniversalIdentifier (-u) is required.',
),
);
process.exit(1);
}
await executeCommand.execute({
...options,
payload: options?.payload ?? '{}',
appPath: formatPath(appPath),
});
},
);
program
.command('logs [appPath]')
.option(
'-u, --functionUniversalIdentifier <functionUniversalIdentifier>',
'Only show logs for the function with this universal ID',
)
.option(
'-n, --functionName <functionName>',
'Only show logs for the function with this name',
)
.description('Watch application function logs')
.action(
async (
appPath?: string,
options?: {
functionUniversalIdentifier?: string;
functionName?: string;
},
) => {
await logsCommand.execute({
...options,
appPath: formatPath(appPath),
});
},
);
};
@@ -2,6 +2,7 @@ import path from 'path';
import { appBuild } from '@/cli/operations/build';
import { appDeploy } from '@/cli/operations/deploy';
import { appPublish } from '@/cli/operations/publish';
import { ConfigService } from '@/cli/utilities/config/config-service';
import { CURRENT_EXECUTION_DIRECTORY } from '@/cli/utilities/config/current-execution-directory';
import { readJson } from '@/cli/utilities/file/fs-utils';
@@ -11,10 +12,42 @@ import chalk from 'chalk';
export type DeployCommandOptions = {
appPath?: string;
remote?: string;
private?: boolean;
tag?: string;
};
export class DeployCommand {
async execute(options: DeployCommandOptions): Promise<void> {
if (options.private) {
await this.executePrivate(options);
} else {
await this.executeNpm(options);
}
}
private async executeNpm(options: DeployCommandOptions): Promise<void> {
const appPath = options.appPath ?? CURRENT_EXECUTION_DIRECTORY;
await checkSdkVersionCompatibility(appPath);
console.log(chalk.blue('Publishing to npm...'));
console.log(chalk.gray(`App path: ${appPath}\n`));
const result = await appPublish({
appPath,
npmTag: options.tag,
onProgress: (message) => console.log(chalk.gray(message)),
});
if (!result.success) {
console.error(chalk.red(result.error.message));
process.exit(1);
}
console.log(chalk.green('✓ Published to npm successfully'));
}
private async executePrivate(options: DeployCommandOptions): Promise<void> {
const appPath = options.appPath ?? CURRENT_EXECUTION_DIRECTORY;
await checkSdkVersionCompatibility(appPath);
@@ -57,6 +90,6 @@ export class DeployCommand {
console.log(
chalk.green(`\n✓ Published ${appName} v${appVersion} to ${remoteName}\n`),
);
console.log(' To install deployed application: `yarn twenty install`');
console.log(' To install deployed application: `yarn twenty app:install`');
}
}
@@ -0,0 +1,56 @@
import { formatPath } from '@/cli/utilities/file/file-path';
import type { Command } from 'commander';
import { DeployCommand } from './deploy';
import { AppInstallCommand } from './install';
import { AppUninstallCommand } from './uninstall';
export const registerAppCommands = (program: Command): void => {
const deployCommand = new DeployCommand();
const installCommand = new AppInstallCommand();
const uninstallCommand = new AppUninstallCommand();
program
.command('app:publish [appPath]')
.description('Build and publish to npm (default) or server registry')
.option('--private', "Push to a Twenty server's registry instead of npm")
.option(
'-r, --remote <name>',
'Publish to a specific remote (with --private)',
)
.option('--tag <tag>', 'npm dist-tag (e.g. beta, next)')
.action(async (appPath, options) => {
await deployCommand.execute({
appPath: formatPath(appPath),
private: options.private,
remote: options.remote,
tag: options.tag,
});
});
program
.command('app:install [appPath]')
.description('Install a deployed app on the server')
.option('-r, --remote <name>', 'Install on a specific remote')
.action(async (appPath, options) => {
await installCommand.execute({
appPath: formatPath(appPath),
remote: options.remote,
});
});
program
.command('app:uninstall [appPath]')
.description('Uninstall app from server')
.option('-y, --yes', 'Skip confirmation prompt')
.action(async (appPath?: string, options?: { yes?: boolean }) => {
try {
const result = await uninstallCommand.execute({
appPath: formatPath(appPath),
askForConfirmation: !options?.yes,
});
process.exit(result.success ? 0 : 1);
} catch {
process.exit(1);
}
});
};
@@ -0,0 +1,200 @@
import { formatPath } from '@/cli/utilities/file/file-path';
import chalk from 'chalk';
import type { Command } from 'commander';
import type { SyncableEntity } from 'twenty-shared/application';
import { EntityAddCommand } from './dev/add';
import { AppBuildCommand } from './dev/build';
import { DeployCommand } from './app/deploy';
import { LogicFunctionExecuteCommand } from './dev/exec';
import { AppInstallCommand } from './app/install';
import { LogicFunctionLogsCommand } from './dev/logs';
import { AppTypecheckCommand } from './dev/typecheck';
import { AppUninstallCommand } from './app/uninstall';
const deprecate = (oldCmd: string, newCmd: string) =>
console.warn(
chalk.yellow(
`\`twenty ${oldCmd}\` is deprecated. Use \`twenty ${newCmd}\` instead.`,
),
);
export const registerDeprecatedCommands = (program: Command): void => {
const buildCommand = new AppBuildCommand();
const typecheckCommand = new AppTypecheckCommand();
const logsCommand = new LogicFunctionLogsCommand();
const executeCommand = new LogicFunctionExecuteCommand();
const addCommand = new EntityAddCommand();
const deployCommand = new DeployCommand();
const installCommand = new AppInstallCommand();
const uninstallCommand = new AppUninstallCommand();
program
.command('build [appPath]', { hidden: true })
.option('--tarball', 'Also pack into a .tgz tarball')
.action(
async (appPath: string | undefined, options: { tarball?: boolean }) => {
deprecate('build', 'dev:build');
await buildCommand.execute({
appPath: formatPath(appPath),
tarball: options.tarball,
});
},
);
program
.command('typecheck [appPath]', { hidden: true })
.action(async (appPath: string | undefined) => {
deprecate('typecheck', 'dev:typecheck');
await typecheckCommand.execute({
appPath: formatPath(appPath),
});
});
program
.command('logs [appPath]', { hidden: true })
.option(
'-u, --functionUniversalIdentifier <functionUniversalIdentifier>',
'Only show logs for the function with this universal ID',
)
.option(
'-n, --functionName <functionName>',
'Only show logs for the function with this name',
)
.action(
async (
appPath?: string,
options?: {
functionUniversalIdentifier?: string;
functionName?: string;
},
) => {
deprecate('logs', 'dev:function:logs');
await logsCommand.execute({
...options,
appPath: formatPath(appPath),
});
},
);
program
.command('exec [appPath]', { hidden: true })
.option('--postInstall', 'Execute post-install logic function if defined')
.option('--preInstall', 'Execute pre-install logic function if defined')
.option(
'-p, --payload <payload>',
'JSON payload to send to the function',
'{}',
)
.option(
'-u, --functionUniversalIdentifier <functionUniversalIdentifier>',
'Universal ID of the function to execute',
)
.option(
'-n, --functionName <functionName>',
'Name of the function to execute',
)
.action(
async (
appPath?: string,
options?: {
postInstall?: boolean;
preInstall?: boolean;
payload?: string;
functionUniversalIdentifier?: string;
functionName?: string;
},
) => {
deprecate('exec', 'dev:function:exec');
if (
!options?.postInstall &&
!options?.preInstall &&
!options?.functionUniversalIdentifier &&
!options?.functionName
) {
console.error(
chalk.red(
'Error: Either --postInstall, --preInstall, --functionName (-n), or --functionUniversalIdentifier (-u) is required.',
),
);
process.exit(1);
}
await executeCommand.execute({
...options,
payload: options?.payload ?? '{}',
appPath: formatPath(appPath),
});
},
);
program
.command('add [entityType]', { hidden: true })
.option('--path <path>', 'Path in which the entity should be created.')
.action(async (entityType?: string, options?: { path?: string }) => {
deprecate('add', 'dev:add');
await addCommand.execute(entityType as SyncableEntity, options?.path);
});
program
.command('publish [appPath]', { hidden: true })
.option('--tag <tag>', 'npm dist-tag (e.g. beta, next)')
.action(async (appPath: string | undefined, options: { tag?: string }) => {
deprecate('publish', 'app:publish');
await deployCommand.execute({
appPath: formatPath(appPath),
tag: options.tag,
});
});
program
.command('deploy [appPath]', { hidden: true })
.option('-r, --remote <name>', 'Deploy to a specific remote')
.action(
async (appPath: string | undefined, options: { remote?: string }) => {
deprecate('deploy', 'app:publish --private');
await deployCommand.execute({
appPath: formatPath(appPath),
private: true,
remote: options.remote,
});
},
);
program
.command('install [appPath]', { hidden: true })
.option('-r, --remote <name>', 'Install on a specific remote')
.action(
async (appPath: string | undefined, options: { remote?: string }) => {
deprecate('install', 'app:install');
await installCommand.execute({
appPath: formatPath(appPath),
remote: options.remote,
});
},
);
program
.command('uninstall [appPath]', { hidden: true })
.option('-y, --yes', 'Skip confirmation prompt')
.action(async (appPath?: string, options?: { yes?: boolean }) => {
deprecate('uninstall', 'app:uninstall');
try {
const result = await uninstallCommand.execute({
appPath: formatPath(appPath),
askForConfirmation: !options?.yes,
});
process.exit(result.success ? 0 : 1);
} catch {
process.exit(1);
}
});
program
.command('catalog-sync', { hidden: true })
.option('-r, --remote <name>', 'Sync on a specific remote')
.action(async (options: { remote?: string }) => {
deprecate('catalog-sync', 'dev:catalog-sync');
const { CatalogSyncCommand } = await import('./dev/catalog-sync');
const cmd = new CatalogSyncCommand();
await cmd.execute({ remote: options.remote });
});
};
@@ -412,7 +412,7 @@ export class EntityAddCommand {
// Connection providers reference two serverVariables (`<NAME>_CLIENT_ID`
// and `<NAME>_CLIENT_SECRET`) that the dev needs to declare on
// `defineApplication.serverVariables`. Auto-append them so the dev
// doesn't have to remember the wiring after `twenty add connection-provider`.
// doesn't have to remember the wiring after `twenty dev:add connection-provider`.
// The util is best-effort: it handles the common file shapes and falls
// back to a printed snippet for anything it can't safely modify.
private async registerConnectionProviderServerVariables(
@@ -0,0 +1,86 @@
import { formatPath } from '@/cli/utilities/file/file-path';
import chalk from 'chalk';
import type { Command } from 'commander';
import { LogicFunctionExecuteCommand } from '../exec';
import { LogicFunctionLogsCommand } from '../logs';
export const registerDevFunctionCommands = (program: Command): void => {
const logsCommand = new LogicFunctionLogsCommand();
const executeCommand = new LogicFunctionExecuteCommand();
program
.command('dev:function:logs [appPath]')
.description('Stream logic function logs')
.option(
'-u, --functionUniversalIdentifier <functionUniversalIdentifier>',
'Only show logs for the function with this universal ID',
)
.option(
'-n, --functionName <functionName>',
'Only show logs for the function with this name',
)
.action(
async (
appPath?: string,
options?: {
functionUniversalIdentifier?: string;
functionName?: string;
},
) => {
await logsCommand.execute({
...options,
appPath: formatPath(appPath),
});
},
);
program
.command('dev:function:exec [appPath]')
.description('Execute a logic function')
.option('--postInstall', 'Execute post-install logic function if defined')
.option('--preInstall', 'Execute pre-install logic function if defined')
.option(
'-p, --payload <payload>',
'JSON payload to send to the function',
'{}',
)
.option(
'-u, --functionUniversalIdentifier <functionUniversalIdentifier>',
'Universal ID of the function to execute',
)
.option(
'-n, --functionName <functionName>',
'Name of the function to execute',
)
.action(
async (
appPath?: string,
options?: {
postInstall?: boolean;
preInstall?: boolean;
payload?: string;
functionUniversalIdentifier?: string;
functionName?: string;
},
) => {
if (
!options?.postInstall &&
!options?.preInstall &&
!options?.functionUniversalIdentifier &&
!options?.functionName
) {
console.error(
chalk.red(
'Error: Either --postInstall, --preInstall, --functionName (-n), or --functionUniversalIdentifier (-u) is required.',
),
);
process.exit(1);
}
await executeCommand.execute({
...options,
payload: options?.payload ?? '{}',
appPath: formatPath(appPath),
});
},
);
};
@@ -0,0 +1,97 @@
import { formatPath } from '@/cli/utilities/file/file-path';
import type { Command } from 'commander';
import { SyncableEntity } from 'twenty-shared/application';
import { EntityAddCommand } from './add';
import { AppBuildCommand } from './build';
import { AppDevCommand } from './dev';
import { AppDevOnceCommand } from './dev-once';
import { AppTypecheckCommand } from './typecheck';
import { registerDevFunctionCommands } from './function';
export const registerDevCommands = (program: Command): void => {
const buildCommand = new AppBuildCommand();
const devCommand = new AppDevCommand();
const devOnceCommand = new AppDevOnceCommand();
const typecheckCommand = new AppTypecheckCommand();
const addCommand = new EntityAddCommand();
const devAction = async (
appPath: string | undefined,
options: {
once?: boolean;
verbose?: boolean;
debug?: boolean;
debounceMs?: string;
},
) => {
const commonOptions = {
appPath: formatPath(appPath),
verbose: options.verbose || options.debug,
debounceMs: options.debounceMs
? parseInt(options.debounceMs, 10)
: undefined,
};
if (options.once) {
await devOnceCommand.execute(commonOptions);
return;
}
await devCommand.execute(commonOptions);
};
program
.command('dev [appPath]', { isDefault: true })
.description('Build and sync local changes (default command)')
.option(
'-o, --once',
'Build and sync once, then exit (useful for CI, scripts, and pre-commit hooks)',
)
.option('--debounceMs <ms>', 'Debounce in ms (default: 2 000)')
.option('-v, --verbose', 'Show detailed logs')
.option('-d, --debug', 'Show detailed logs (alias for --verbose)')
.action(devAction);
program
.command('dev:build [appPath]')
.description('Build and generate API client')
.option('--tarball', 'Also pack into a .tgz tarball')
.action(async (appPath, options) => {
await buildCommand.execute({
appPath: formatPath(appPath),
tarball: options.tarball,
});
});
program
.command('dev:typecheck [appPath]')
.description('Run TypeScript type checking')
.action(async (appPath) => {
await typecheckCommand.execute({
appPath: formatPath(appPath),
});
});
program
.command('dev:add [entityType]')
.description(
`Scaffold a new entity (${Object.values(SyncableEntity).join('|')})`,
)
.option('--path <path>', 'Path in which the entity should be created.')
.action(async (entityType?: string, options?: { path?: string }) => {
await addCommand.execute(entityType as SyncableEntity, options?.path);
});
program
.command('dev:catalog-sync')
.description('Trigger marketplace catalog sync')
.option('-r, --remote <name>', 'Sync on a specific remote')
.action(async (options: { remote?: string }) => {
const { CatalogSyncCommand } = await import('./catalog-sync');
const cmd = new CatalogSyncCommand();
await cmd.execute({ remote: options.remote });
});
registerDevFunctionCommands(program);
};
@@ -0,0 +1,288 @@
import { serverStart } from '@/cli/operations/server-start';
import { serverUpgrade } from '@/cli/operations/server-upgrade';
import {
CONTAINER_NAME,
containerExists,
DEFAULT_PORT,
DEFAULT_TEST_PORT,
getContainerEnvVar,
getContainerPort,
isContainerRunning,
TEST_CONTAINER_NAME,
} from '@/cli/utilities/server/docker-container';
import { checkServerHealth } from '@/cli/utilities/server/detect-local-server';
import chalk from 'chalk';
import type { Command } from 'commander';
import { execSync, spawnSync } from 'node:child_process';
const startAction = async (options: { port?: string; test?: boolean }) => {
const defaultPort = options.test ? DEFAULT_TEST_PORT : DEFAULT_PORT;
const port = options.port ? parseInt(options.port, 10) : defaultPort;
if (isNaN(port) || port < 1 || port > 65535) {
console.error(chalk.red('Invalid port number.'));
process.exit(1);
}
const result = await serverStart({
port,
test: options.test,
onProgress: (message) => console.log(chalk.gray(message)),
});
if (!result.success) {
console.error(chalk.red(result.error.message));
process.exit(1);
}
};
const stopAction = (options: { test?: boolean }) => {
const containerName = options.test ? TEST_CONTAINER_NAME : CONTAINER_NAME;
if (!containerExists(containerName)) {
console.log(chalk.yellow('No Twenty server container found.'));
return;
}
execSync(`docker stop ${containerName}`, { stdio: 'ignore' });
console.log(chalk.green('Twenty server stopped.'));
};
const logsAction = (options: { lines: string; test?: boolean }) => {
const containerName = options.test ? TEST_CONTAINER_NAME : CONTAINER_NAME;
if (!containerExists(containerName)) {
console.log(chalk.yellow('No Twenty server container found.'));
return;
}
try {
spawnSync(
'docker',
['logs', '-f', '--tail', options.lines, containerName],
{ stdio: 'inherit' },
);
} catch {
// User hit Ctrl-C
}
};
const statusAction = async (options: { test?: boolean }) => {
const containerName = options.test ? TEST_CONTAINER_NAME : CONTAINER_NAME;
const defaultPort = options.test ? DEFAULT_TEST_PORT : DEFAULT_PORT;
if (!containerExists(containerName)) {
console.log(` Status: ${chalk.gray('not created')}`);
console.log(
chalk.gray(
` Run 'yarn twenty docker:start${options.test ? ' --test' : ''}' to create one.`,
),
);
return;
}
const running = isContainerRunning(containerName);
const port = running ? getContainerPort(containerName) : defaultPort;
const healthy = running ? await checkServerHealth(port) : false;
const statusText = healthy
? chalk.green('running (healthy)')
: running
? chalk.yellow('running (starting...)')
: chalk.gray('stopped');
const appVersion = getContainerEnvVar('APP_VERSION', containerName);
console.log(` Status: ${statusText}`);
console.log(` URL: http://localhost:${port}`);
if (appVersion) {
console.log(` Version: ${chalk.gray(appVersion)}`);
}
if (healthy) {
console.log(chalk.gray(' Login: tim@apple.dev / tim@apple.dev'));
}
};
const resetAction = (options: { test?: boolean }) => {
const containerName = options.test ? TEST_CONTAINER_NAME : CONTAINER_NAME;
const volumeData = options.test
? 'twenty-app-dev-test-data'
: 'twenty-app-dev-data';
const volumeStorage = options.test
? 'twenty-app-dev-test-storage'
: 'twenty-app-dev-storage';
if (containerExists(containerName)) {
execSync(`docker rm -f ${containerName}`, { stdio: 'ignore' });
}
try {
execSync(`docker volume rm ${volumeData} ${volumeStorage}`, {
stdio: 'ignore',
});
} catch {
// Volumes may not exist
}
console.log(chalk.green('Twenty server data reset.'));
console.log(
chalk.gray(
`Run 'yarn twenty docker:start${options.test ? ' --test' : ''}' to start a fresh instance.`,
),
);
};
const upgradeAction = async (
version: string | undefined,
options: { test?: boolean },
) => {
const result = await serverUpgrade({
version: version ?? 'latest',
test: options.test,
onProgress: (message) => console.log(chalk.gray(message)),
});
if (!result.success) {
console.error(chalk.red(result.error.message));
process.exit(1);
}
const { data } = result;
if (!data.imageUpdated) {
console.log(chalk.green(` Already up to date (${data.image}).`));
return;
}
console.log(chalk.green(` Upgraded to: ${data.image}`));
if (data.containerRecreated) {
console.log(
chalk.gray(
` Run 'yarn twenty docker:start${options.test ? ' --test' : ''}' to wait for the server to be ready.`,
),
);
}
};
export const registerServerCommands = (program: Command): void => {
program
.command('docker:start')
.description('Start the local Twenty container')
.option('-p, --port <port>', 'HTTP port')
.option('--test', 'Start a separate test instance (port 2021)')
.action(startAction);
program
.command('docker:stop')
.description('Stop the local Twenty container')
.option('--test', 'Stop the test instance')
.action(stopAction);
program
.command('docker:logs')
.description('Stream container logs')
.option('-n, --lines <lines>', 'Number of lines to show', '50')
.option('--test', 'Show logs for the test instance')
.action(logsAction);
program
.command('docker:status')
.description('Show container status')
.option('--test', 'Show status of the test instance')
.action(statusAction);
program
.command('docker:reset')
.description('Delete all data and start fresh')
.option('--test', 'Reset the test instance')
.action(resetAction);
program
.command('docker:upgrade [version]')
.description('Upgrade the Docker image')
.option('--test', 'Upgrade the test instance')
.action(upgradeAction);
// Deprecated: `server <subcommand>` forwarding to `docker:<subcommand>`
const server = program
.command('server', { hidden: true })
.description(
'Manage a Twenty server (local instance and server-side actions)',
);
const deprecate = (oldCmd: string, newCmd: string) =>
console.warn(
chalk.yellow(
`\`twenty server ${oldCmd}\` is deprecated. Use \`twenty ${newCmd}\` instead.`,
),
);
server
.command('start')
.option('-p, --port <port>', 'HTTP port')
.option('--test', 'Start a separate test instance (port 2021)')
.action(async (options: { port?: string; test?: boolean }) => {
deprecate('start', 'docker:start');
await startAction(options);
});
server
.command('stop')
.option('--test', 'Stop the test instance')
.action((options: { test?: boolean }) => {
deprecate('stop', 'docker:stop');
stopAction(options);
});
server
.command('logs')
.option('-n, --lines <lines>', 'Number of lines to show', '50')
.option('--test', 'Show logs for the test instance')
.action((options: { lines: string; test?: boolean }) => {
deprecate('logs', 'docker:logs');
logsAction(options);
});
server
.command('status')
.option('--test', 'Show status of the test instance')
.action(async (options: { test?: boolean }) => {
deprecate('status', 'docker:status');
await statusAction(options);
});
server
.command('reset')
.option('--test', 'Reset the test instance')
.action((options: { test?: boolean }) => {
deprecate('reset', 'docker:reset');
resetAction(options);
});
server
.command('upgrade [version]')
.option('--test', 'Upgrade the test instance')
.action(
async (version: string | undefined, options: { test?: boolean }) => {
deprecate('upgrade', 'docker:upgrade');
await upgradeAction(version, options);
},
);
server
.command('catalog-sync')
.option('-r, --remote <name>', 'Sync on a specific remote')
.action(async (options: { remote?: string }) => {
deprecate('catalog-sync', 'dev:catalog-sync');
const { CatalogSyncCommand } = await import('../dev/catalog-sync');
const cmd = new CatalogSyncCommand();
await cmd.execute({ remote: options.remote });
});
};
@@ -0,0 +1,14 @@
import type { Command } from 'commander';
import { registerAppCommands } from './app';
import { registerDeprecatedCommands } from './deprecated';
import { registerDevCommands } from './dev';
import { registerServerCommands } from './docker';
import { registerRemoteCommands } from './remote';
export const registerCommands = (program: Command): void => {
registerDevCommands(program);
registerAppCommands(program);
registerServerCommands(program);
registerRemoteCommands(program);
registerDeprecatedCommands(program);
};
@@ -1,298 +0,0 @@
import { authLogin } from '@/cli/operations/login';
import { authLoginOAuth } from '@/cli/operations/login-oauth';
import { ApiService } from '@/cli/utilities/api/api-service';
import { ConfigService } from '@/cli/utilities/config/config-service';
import { getConfigPath } from '@/cli/utilities/config/get-config-path';
import { detectLocalServer } from '@/cli/utilities/server/detect-local-server';
import chalk from 'chalk';
import type { Command } from 'commander';
import inquirer from 'inquirer';
import { normalizeUrl } from 'twenty-shared/utils';
const deriveRemoteName = (url: string): string => {
try {
const hostname = new URL(url).hostname;
return hostname.replace(/\./g, '-');
} catch {
return 'remote';
}
};
type AuthMethod = 'OAuth' | 'API key';
const authenticate = async (
apiUrl: string,
apiKey?: string,
): Promise<AuthMethod> => {
if (apiKey) {
const result = await authLogin({ apiKey, apiUrl });
if (!result.success) {
console.error(chalk.red('✗ Authentication failed.'));
process.exit(1);
}
return 'API key';
}
return runOAuthWithApiKeyFallback(apiUrl);
};
const runOAuthWithApiKeyFallback = async (
apiUrl: string,
): Promise<AuthMethod> => {
console.log(chalk.gray('Opening browser for authentication...'));
const oauthResult = await authLoginOAuth({ apiUrl });
if (oauthResult.success) {
return 'OAuth';
}
console.log(chalk.yellow(oauthResult.error.message));
const keyAnswer = await inquirer.prompt([
{
type: 'password',
name: 'apiKey',
message: 'API Key:',
mask: '*',
validate: (input: string) => input.length > 0 || 'API key is required',
},
]);
const fallbackResult = await authLogin({
apiKey: keyAnswer.apiKey,
apiUrl,
});
if (!fallbackResult.success) {
console.error(chalk.red('✗ Authentication failed.'));
process.exit(1);
}
return 'API key';
};
export const registerRemoteCommands = (program: Command): void => {
const remote = program
.command('remote')
.description('Manage remote Twenty servers');
remote
.command('add')
.description('Add a new remote or re-authenticate an existing one')
.option('--as <name>', 'Name for this remote')
.option('--api-key <apiKey>', 'API key for non-interactive auth')
.option('--api-url <apiUrl>', 'Server URL')
.option('--local', 'Connect to a local Twenty server (auto-detect)')
.option('--test', 'Write to config.test.json (for integration tests)')
.action(
async (options: {
as?: string;
apiKey?: string;
apiUrl?: string;
local?: boolean;
test?: boolean;
}) => {
const configPath = options.test ? getConfigPath(true) : undefined;
const configService = new ConfigService(
configPath ? { configPath } : undefined,
);
const existingRemotes = await configService.getRemotes();
if (options.as !== undefined && existingRemotes.includes(options.as)) {
const config = await configService.getConfigForRemote(options.as);
ConfigService.setActiveRemote(options.as);
const method = await authenticate(config.apiUrl, options.apiKey);
console.log(
chalk.green(`✓ Re-authenticated "${options.as}" via ${method}.`),
);
await configService.setDefaultRemote(options.as);
console.log(chalk.green(`✓ Default remote set to "${options.as}".`));
return;
}
let apiUrl = options.apiUrl ? normalizeUrl(options.apiUrl) : undefined;
if (!apiUrl) {
const detectedUrl = await detectLocalServer();
if (options.local) {
if (!detectedUrl) {
console.error(
chalk.red(
'No local Twenty server found.\n' +
'Start one with: yarn twenty server start',
),
);
process.exit(1);
}
console.log(chalk.gray(`Found local server at ${detectedUrl}`));
apiUrl = detectedUrl;
} else {
apiUrl = normalizeUrl(
(
await inquirer.prompt<{ apiUrl: string }>([
{
type: 'input',
name: 'apiUrl',
message: 'Twenty server URL:',
validate: (input: string) => {
try {
new URL(input);
return true;
} catch {
return 'Please enter a valid URL';
}
},
},
])
).apiUrl,
);
}
}
const name = options.as ?? deriveRemoteName(apiUrl);
ConfigService.setActiveRemote(name);
const method = await authenticate(apiUrl, options.apiKey);
console.log(
chalk.green(`✓ Remote "${name}" added (${apiUrl}) via ${method}.`),
);
await configService.setDefaultRemote(name);
console.log(chalk.green(`✓ Default remote set to "${name}".`));
},
);
remote
.command('list')
.description('List all configured remotes')
.action(async () => {
const configService = new ConfigService();
const remotes = await configService.getRemotes();
const defaultRemote = await configService.getDefaultRemote();
if (remotes.length === 0) {
console.log('No remotes configured.');
console.log("Use 'twenty remote add' to add one.");
return;
}
console.log('');
for (const remoteName of remotes) {
const config = await configService.getConfigForRemote(remoteName);
const authMethod = config.twentyCLIAccessToken
? 'oauth'
: config.apiKey
? 'api-key'
: 'none';
const isDefault = remoteName === defaultRemote;
const marker = isDefault ? '* ' : ' ';
const nameText = isDefault ? chalk.bold(remoteName) : remoteName;
console.log(
`${marker}${nameText} ${chalk.gray(config.apiUrl)} [${authMethod}]`,
);
}
console.log(
'\n',
chalk.gray("Use 'twenty remote switch <name>' to change default"),
);
});
remote
.command('switch [name]')
.description('Set the default remote')
.action(async (nameArg?: string) => {
const configService = new ConfigService();
const remoteName =
nameArg ??
(
await inquirer.prompt<{ remote: string }>([
{
type: 'list',
name: 'remote',
message: 'Select default remote:',
choices: await configService.getRemotes(),
},
])
).remote;
const remotes = await configService.getRemotes();
if (!remotes.includes(remoteName)) {
console.error(chalk.red(`Remote "${remoteName}" not found.`));
process.exit(1);
}
await configService.setDefaultRemote(remoteName);
console.log(chalk.green(`✓ Default remote set to "${remoteName}".`));
});
remote
.command('status')
.description('Show active remote and authentication status')
.action(async () => {
const configService = new ConfigService();
const apiService = new ApiService();
const activeRemote = ConfigService.getActiveRemote();
const config = await configService.getConfig();
const authMethod = config.twentyCLIAccessToken
? 'oauth'
: config.apiKey
? 'api-key'
: 'none';
console.log(` Remote: ${chalk.bold(activeRemote)}`);
console.log(` Server: ${config.apiUrl}`);
if (authMethod === 'none') {
console.log(` Auth: ${chalk.yellow('not configured')}`);
return;
}
const { authValid } = await apiService.validateAuth();
const statusText = authValid
? chalk.green(`${authMethod} (valid)`)
: chalk.red(`${authMethod} (invalid)`);
console.log(` Auth: ${statusText}`);
});
remote
.command('remove <name>')
.description('Remove a remote')
.action(async (name: string) => {
const configService = new ConfigService();
const remotes = await configService.getRemotes();
if (!remotes.includes(name)) {
console.error(chalk.red(`Remote "${name}" not found.`));
process.exit(1);
}
ConfigService.setActiveRemote(name);
await configService.clearConfig();
console.log(chalk.green(`✓ Remote "${name}" removed.`));
});
};
@@ -0,0 +1,365 @@
import { authLogin } from '@/cli/operations/login';
import { authLoginOAuth } from '@/cli/operations/login-oauth';
import { ApiService } from '@/cli/utilities/api/api-service';
import { ConfigService } from '@/cli/utilities/config/config-service';
import { getConfigPath } from '@/cli/utilities/config/get-config-path';
import { detectLocalServer } from '@/cli/utilities/server/detect-local-server';
import chalk from 'chalk';
import type { Command } from 'commander';
import inquirer from 'inquirer';
import { normalizeUrl } from 'twenty-shared/utils';
const deriveRemoteName = (url: string): string => {
try {
const hostname = new URL(url).hostname;
return hostname.replace(/\./g, '-');
} catch {
return 'remote';
}
};
type AuthMethod = 'OAuth' | 'API key';
const authenticate = async (
apiUrl: string,
apiKey?: string,
): Promise<AuthMethod> => {
if (apiKey) {
const result = await authLogin({ apiKey, apiUrl });
if (!result.success) {
console.error(chalk.red('✗ Authentication failed.'));
process.exit(1);
}
return 'API key';
}
return runOAuthWithApiKeyFallback(apiUrl);
};
const runOAuthWithApiKeyFallback = async (
apiUrl: string,
): Promise<AuthMethod> => {
console.log(chalk.gray('Opening browser for authentication...'));
const oauthResult = await authLoginOAuth({ apiUrl });
if (oauthResult.success) {
return 'OAuth';
}
console.log(chalk.yellow(oauthResult.error.message));
const keyAnswer = await inquirer.prompt([
{
type: 'password',
name: 'apiKey',
message: 'API Key:',
mask: '*',
validate: (input: string) => input.length > 0 || 'API key is required',
},
]);
const fallbackResult = await authLogin({
apiKey: keyAnswer.apiKey,
apiUrl,
});
if (!fallbackResult.success) {
console.error(chalk.red('✗ Authentication failed.'));
process.exit(1);
}
return 'API key';
};
const addAction = async (options: {
as?: string;
apiKey?: string;
url?: string;
apiUrl?: string;
local?: boolean;
test?: boolean;
}) => {
if (options.apiUrl) {
console.warn(
chalk.yellow('⚠ --api-url is deprecated. Use --url instead.'),
);
}
const configPath = options.test ? getConfigPath(true) : undefined;
const configService = new ConfigService(
configPath ? { configPath } : undefined,
);
const existingRemotes = await configService.getRemotes();
if (options.as !== undefined && existingRemotes.includes(options.as)) {
const config = await configService.getConfigForRemote(options.as);
ConfigService.setActiveRemote(options.as);
const method = await authenticate(config.apiUrl, options.apiKey);
console.log(
chalk.green(`✓ Re-authenticated "${options.as}" via ${method}.`),
);
await configService.setDefaultRemote(options.as);
console.log(chalk.green(`✓ Default remote set to "${options.as}".`));
return;
}
let serverUrl = options.url ?? options.apiUrl;
if (serverUrl) {
serverUrl = normalizeUrl(serverUrl);
} else {
const detectedUrl = await detectLocalServer();
if (options.local) {
if (!detectedUrl) {
console.error(
chalk.red(
'No local Twenty server found.\n' +
'Start one with: yarn twenty docker:start',
),
);
process.exit(1);
}
console.log(chalk.gray(`Found local server at ${detectedUrl}`));
serverUrl = detectedUrl;
} else {
serverUrl = normalizeUrl(
(
await inquirer.prompt<{ serverUrl: string }>([
{
type: 'input',
name: 'serverUrl',
message: 'Twenty server URL:',
validate: (input: string) => {
try {
new URL(input);
return true;
} catch {
return 'Please enter a valid URL';
}
},
},
])
).serverUrl,
);
}
}
const name = options.as ?? deriveRemoteName(serverUrl);
ConfigService.setActiveRemote(name);
const method = await authenticate(serverUrl, options.apiKey);
console.log(
chalk.green(`✓ Remote "${name}" added (${serverUrl}) via ${method}.`),
);
await configService.setDefaultRemote(name);
console.log(chalk.green(`✓ Default remote set to "${name}".`));
};
const listAction = async () => {
const configService = new ConfigService();
const remotes = await configService.getRemotes();
const defaultRemote = await configService.getDefaultRemote();
if (remotes.length === 0) {
console.log('No remotes configured.');
console.log("Use 'twenty remote:add' to add one.");
return;
}
console.log('');
for (const remoteName of remotes) {
const config = await configService.getConfigForRemote(remoteName);
const authMethod = config.twentyCLIAccessToken
? 'oauth'
: config.apiKey
? 'api-key'
: 'none';
const isDefault = remoteName === defaultRemote;
const marker = isDefault ? '* ' : ' ';
const nameText = isDefault ? chalk.bold(remoteName) : remoteName;
console.log(
`${marker}${nameText} ${chalk.gray(config.apiUrl)} [${authMethod}]`,
);
}
console.log(
'\n',
chalk.gray("Use 'twenty remote:use <name>' to change default"),
);
};
const useAction = async (nameArg?: string) => {
const configService = new ConfigService();
const remoteName =
nameArg ??
(
await inquirer.prompt<{ remote: string }>([
{
type: 'list',
name: 'remote',
message: 'Select default remote:',
choices: await configService.getRemotes(),
},
])
).remote;
const remotes = await configService.getRemotes();
if (!remotes.includes(remoteName)) {
console.error(chalk.red(`Remote "${remoteName}" not found.`));
process.exit(1);
}
await configService.setDefaultRemote(remoteName);
console.log(chalk.green(`✓ Default remote set to "${remoteName}".`));
};
const statusAction = async () => {
const configService = new ConfigService();
const apiService = new ApiService();
const activeRemote = ConfigService.getActiveRemote();
const config = await configService.getConfig();
const authMethod = config.twentyCLIAccessToken
? 'oauth'
: config.apiKey
? 'api-key'
: 'none';
console.log(` Remote: ${chalk.bold(activeRemote)}`);
console.log(` Server: ${config.apiUrl}`);
if (authMethod === 'none') {
console.log(` Auth: ${chalk.yellow('not configured')}`);
return;
}
const { authValid } = await apiService.validateAuth();
const statusText = authValid
? chalk.green(`${authMethod} (valid)`)
: chalk.red(`${authMethod} (invalid)`);
console.log(` Auth: ${statusText}`);
};
const removeAction = async (name: string) => {
const configService = new ConfigService();
const remotes = await configService.getRemotes();
if (!remotes.includes(name)) {
console.error(chalk.red(`Remote "${name}" not found.`));
process.exit(1);
}
ConfigService.setActiveRemote(name);
await configService.clearConfig();
console.log(chalk.green(`✓ Remote "${name}" removed.`));
};
export const registerRemoteCommands = (program: Command): void => {
program
.command('remote:add')
.description('Add or re-authenticate a remote')
.option('--as <name>', 'Name for this remote')
.option('--api-key <apiKey>', 'API key for non-interactive auth')
.option('--url <url>', 'Server URL')
.option('--api-url <apiUrl>', '[deprecated: use --url]')
.option('--local', 'Connect to a local Twenty server (auto-detect)')
.option('--test', 'Write to config.test.json (for integration tests)')
.action(addAction);
program
.command('remote:list')
.description('List all configured remotes')
.action(listAction);
program
.command('remote:use [name]')
.description('Set the default remote')
.action(useAction);
program
.command('remote:status')
.description('Show active remote auth status')
.action(statusAction);
program
.command('remote:remove <name>')
.description('Remove a remote')
.action(removeAction);
// Deprecated: `remote <subcommand>` forwarding to `remote:<subcommand>`
const remote = program
.command('remote', { hidden: true })
.description('Manage remote Twenty servers');
const deprecate = (oldCmd: string, newCmd: string) =>
console.warn(
chalk.yellow(
`\`twenty remote ${oldCmd}\` is deprecated. Use \`twenty ${newCmd}\` instead.`,
),
);
remote
.command('add')
.option('--as <name>', 'Name for this remote')
.option('--api-key <apiKey>', 'API key for non-interactive auth')
.option('--url <url>', 'Server URL')
.option('--api-url <apiUrl>', '[deprecated: use --url]')
.option('--local', 'Connect to a local Twenty server (auto-detect)')
.option('--test', 'Write to config.test.json (for integration tests)')
.action(
async (options: {
as?: string;
apiKey?: string;
url?: string;
apiUrl?: string;
local?: boolean;
test?: boolean;
}) => {
deprecate('add', 'remote:add');
await addAction(options);
},
);
remote.command('list').action(async () => {
deprecate('list', 'remote:list');
await listAction();
});
remote.command('switch [name]').action(async (name?: string) => {
deprecate('switch', 'remote:use');
await useAction(name);
});
remote.command('status').action(async () => {
deprecate('status', 'remote:status');
await statusAction();
});
remote.command('remove <name>').action(async (name: string) => {
deprecate('remove', 'remote:remove');
await removeAction(name);
});
};
@@ -1,215 +0,0 @@
import { serverStart } from '@/cli/operations/server-start';
import { serverUpgrade } from '@/cli/operations/server-upgrade';
import {
CONTAINER_NAME,
containerExists,
DEFAULT_PORT,
DEFAULT_TEST_PORT,
getContainerEnvVar,
getContainerPort,
isContainerRunning,
TEST_CONTAINER_NAME,
} from '@/cli/utilities/server/docker-container';
import { checkServerHealth } from '@/cli/utilities/server/detect-local-server';
import chalk from 'chalk';
import type { Command } from 'commander';
import { execSync, spawnSync } from 'node:child_process';
import { CatalogSyncCommand } from './catalog-sync';
export const registerServerCommands = (program: Command): void => {
const server = program
.command('server')
.description(
'Manage a Twenty server (local instance and server-side actions)',
);
server
.command('start')
.description('Start a local Twenty server')
.option('-p, --port <port>', 'HTTP port')
.option('--test', 'Start a separate test instance (port 2021)')
.action(async (options: { port?: string; test?: boolean }) => {
const defaultPort = options.test ? DEFAULT_TEST_PORT : DEFAULT_PORT;
const port = options.port ? parseInt(options.port, 10) : defaultPort;
if (isNaN(port) || port < 1 || port > 65535) {
console.error(chalk.red('Invalid port number.'));
process.exit(1);
}
const result = await serverStart({
port,
test: options.test,
onProgress: (message) => console.log(chalk.gray(message)),
});
if (!result.success) {
console.error(chalk.red(result.error.message));
process.exit(1);
}
});
server
.command('stop')
.description('Stop the local Twenty server')
.option('--test', 'Stop the test instance')
.action((options: { test?: boolean }) => {
const containerName = options.test ? TEST_CONTAINER_NAME : CONTAINER_NAME;
if (!containerExists(containerName)) {
console.log(chalk.yellow('No Twenty server container found.'));
return;
}
execSync(`docker stop ${containerName}`, { stdio: 'ignore' });
console.log(chalk.green('Twenty server stopped.'));
});
server
.command('logs')
.description('Stream Twenty server logs')
.option('-n, --lines <lines>', 'Number of lines to show', '50')
.option('--test', 'Show logs for the test instance')
.action((options: { lines: string; test?: boolean }) => {
const containerName = options.test ? TEST_CONTAINER_NAME : CONTAINER_NAME;
if (!containerExists(containerName)) {
console.log(chalk.yellow('No Twenty server container found.'));
return;
}
try {
spawnSync(
'docker',
['logs', '-f', '--tail', options.lines, containerName],
{ stdio: 'inherit' },
);
} catch {
// User hit Ctrl-C
}
});
server
.command('status')
.description('Show Twenty server status')
.option('--test', 'Show status of the test instance')
.action(async (options: { test?: boolean }) => {
const containerName = options.test ? TEST_CONTAINER_NAME : CONTAINER_NAME;
const defaultPort = options.test ? DEFAULT_TEST_PORT : DEFAULT_PORT;
if (!containerExists(containerName)) {
console.log(` Status: ${chalk.gray('not created')}`);
console.log(
chalk.gray(
` Run 'yarn twenty server start${options.test ? ' --test' : ''}' to create one.`,
),
);
return;
}
const running = isContainerRunning(containerName);
const port = running ? getContainerPort(containerName) : defaultPort;
const healthy = running ? await checkServerHealth(port) : false;
const statusText = healthy
? chalk.green('running (healthy)')
: running
? chalk.yellow('running (starting...)')
: chalk.gray('stopped');
const appVersion = getContainerEnvVar('APP_VERSION', containerName);
console.log(` Status: ${statusText}`);
console.log(` URL: http://localhost:${port}`);
if (appVersion) {
console.log(` Version: ${chalk.gray(appVersion)}`);
}
if (healthy) {
console.log(chalk.gray(' Login: tim@apple.dev / tim@apple.dev'));
}
});
server
.command('reset')
.description('Delete all data and start fresh')
.option('--test', 'Reset the test instance')
.action((options: { test?: boolean }) => {
const containerName = options.test ? TEST_CONTAINER_NAME : CONTAINER_NAME;
const volumeData = options.test
? 'twenty-app-dev-test-data'
: 'twenty-app-dev-data';
const volumeStorage = options.test
? 'twenty-app-dev-test-storage'
: 'twenty-app-dev-storage';
if (containerExists(containerName)) {
execSync(`docker rm -f ${containerName}`, { stdio: 'ignore' });
}
try {
execSync(`docker volume rm ${volumeData} ${volumeStorage}`, {
stdio: 'ignore',
});
} catch {
// Volumes may not exist
}
console.log(chalk.green('Twenty server data reset.'));
console.log(
chalk.gray(
`Run 'yarn twenty server start${options.test ? ' --test' : ''}' to start a fresh instance.`,
),
);
});
server
.command('upgrade [version]')
.description('Upgrade the twenty-app-dev Docker image')
.option('--test', 'Upgrade the test instance')
.action(
async (version: string | undefined, options: { test?: boolean }) => {
const result = await serverUpgrade({
version: version ?? 'latest',
test: options.test,
onProgress: (message) => console.log(chalk.gray(message)),
});
if (!result.success) {
console.error(chalk.red(result.error.message));
process.exit(1);
}
const { data } = result;
if (!data.imageUpdated) {
console.log(chalk.green(` Already up to date (${data.image}).`));
return;
}
console.log(chalk.green(` Upgraded to: ${data.image}`));
if (data.containerRecreated) {
console.log(
chalk.gray(
` Run 'yarn twenty server start${options.test ? ' --test' : ''}' to wait for the server to be ready.`,
),
);
}
},
);
server
.command('catalog-sync')
.description('Trigger a marketplace catalog sync on the server')
.option('-r, --remote <name>', 'Sync on a specific remote')
.action(async (options: { remote?: string }) => {
const command = new CatalogSyncCommand();
await command.execute({ remote: options.remote });
});
};
@@ -50,9 +50,9 @@ const innerAppDevOnce = async (
message:
'Cannot reach Twenty server.\n\n' +
' Start a local server:\n' +
' yarn twenty server start\n\n' +
' yarn twenty docker:start\n\n' +
' Check server status:\n' +
' yarn twenty server status',
' yarn twenty docker:status',
},
};
}
@@ -63,7 +63,7 @@ const innerAppDevOnce = async (
error: {
code: APP_ERROR_CODES.SYNC_FAILED,
message:
'Authentication failed. Run `yarn twenty remote add --local` to authenticate.',
'Authentication failed. Run `yarn twenty remote:add --local` to authenticate.',
},
};
}
@@ -151,8 +151,8 @@ const innerServerStart = async (
if (!checkDockerRunning()) {
const retryCommand = isTest
? 'yarn twenty server start --test'
: 'yarn twenty server start';
? 'yarn twenty docker:start --test'
: 'yarn twenty docker:start';
return {
success: false,
@@ -177,7 +177,7 @@ const innerServerStart = async (
code: SERVER_ERROR_CODES.HEALTH_TIMEOUT,
message:
'Twenty server did not become healthy in time.\n' +
"Check: 'yarn twenty server logs'",
"Check: 'yarn twenty docker:logs'",
},
};
}
@@ -202,7 +202,7 @@ const innerServerStart = async (
if (existingPort !== port) {
onProgress?.(
`Existing container uses port ${existingPort}. Run 'yarn twenty server reset${isTest ? ' --test' : ''}' first to change ports.`,
`Existing container uses port ${existingPort}. Run 'yarn twenty docker:reset${isTest ? ' --test' : ''}' first to change ports.`,
);
}
@@ -257,7 +257,7 @@ const innerServerStart = async (
code: SERVER_ERROR_CODES.HEALTH_TIMEOUT,
message:
'Twenty server did not become healthy in time.\n' +
"Check: 'yarn twenty server logs'",
"Check: 'yarn twenty docker:logs'",
},
};
}
@@ -32,7 +32,7 @@ const innerServerUpgrade = async (
if (!checkDockerRunning()) {
const retryCommand = [
'yarn twenty server upgrade',
'yarn twenty docker:upgrade',
version !== 'latest' ? version : null,
isTest ? '--test' : null,
]
@@ -51,7 +51,7 @@ export class ApiClient {
if (error.response?.status === 401) {
console.error(
chalk.red(
'Authentication failed. Run `yarn twenty remote add` to authenticate.',
'Authentication failed. Run `yarn twenty remote:add` to authenticate.',
),
);
} else if (error.response?.status === 403) {
@@ -8,7 +8,7 @@ import { getConfigPath } from '@/cli/utilities/config/get-config-path';
export type RemoteConfig = {
apiUrl: string;
apiKey?: string;
// CLI OAuth app credentials (from `yarn twenty remote add`)
// CLI OAuth app credentials (from `yarn twenty remote:add`)
twentyCLIRegistrationId?: string;
twentyCLIRegistrationClientId?: string;
twentyCLIAccessToken?: string;
@@ -53,9 +53,9 @@ export class CheckServerOrchestratorStep {
message:
'Cannot reach Twenty server.\n\n' +
' Start a local server:\n' +
' yarn twenty server start\n\n' +
' yarn twenty docker:start\n\n' +
' Check server status:\n' +
' yarn twenty server status\n\n' +
' yarn twenty docker:status\n\n' +
' Waiting for server...',
status: 'error',
},
@@ -73,7 +73,7 @@ export class CheckServerOrchestratorStep {
this.state.applyStepEvents([
{
message:
'Authentication failed. Run `yarn twenty remote add --local` to authenticate.',
'Authentication failed. Run `yarn twenty remote:add --local` to authenticate.',
status: 'error',
},
]);
@@ -22,7 +22,7 @@ const DEFINE_APPLICATION_PATTERN = /defineApplication\s*\(\s*\{/;
// Auto-appends OAuth client_id / client_secret entries to the dev's
// `defineApplication({ serverVariables: { ... } })` block so they don't
// have to remember the wiring after `twenty add connection-provider`.
// have to remember the wiring after `twenty dev:add connection-provider`.
//
// Returns one of:
// - { status: 'appended', file } — wrote new entries to an existing block
@@ -25,6 +25,6 @@ export const checkServerVersionCompatibility = async (
`⚠ Local Twenty server is v${info.localServerVersion} (${info.daysBehind} days behind v${info.latestServerVersion}).`,
),
);
console.warn(chalk.dim(' Update with: yarn twenty server upgrade'));
console.warn(chalk.dim(' Update with: yarn twenty docker:upgrade'));
console.warn('');
};