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:
@@ -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
-1
@@ -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 = {
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
},
|
||||
);
|
||||
};
|
||||
+34
-1
@@ -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 });
|
||||
});
|
||||
};
|
||||
+1
-1
@@ -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;
|
||||
|
||||
+3
-3
@@ -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('');
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user