Skip to content

Commit c7338d5

Browse files
lmjclaude
andcommitted
feat: implement CLI Command Line Tool (Phase 3)
This commit adds a complete CLI module using Picocli framework for managing database migration tasks via command line. New features: - Main CLI application with Spring Boot integration - Task commands (create, list, show, delete, start, stop, pause, resume) - Status command for querying task progress and health - Config command for managing connector configurations - Output formatters supporting Table, JSON, and YAML formats - User configuration file support (~/.dbsyncer/config.yml) - CLI-specific application configuration Technical changes: - Added Picocli Spring Boot integration - Created service layer for CLI operations (CliTaskService, CliConfigService) - Implemented OutputFormatter with multiple output format support - Added Jackson JSR310 module for datetime serialization - Added @builder to TaskResponse DTO for easier testing - Enhanced TaskService with convenience pagination method - Enhanced ProgressTrackingService with getTableProgress method - Updated dependency management for Jackson modules - Created 11 unit tests for output formatting (all passing) - Updated PROJECT_STATUS.md to reflect Phase 1-2 completion CLI usage examples: dbsyncer task create --name my-task --source-type MYSQL ... dbsyncer task list --format json dbsyncer status my-task --progress --tables dbsyncer config generate my-task --type source 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 2675e4e commit c7338d5

18 files changed

Lines changed: 1873 additions & 49 deletions

File tree

cli/pom.xml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,22 @@
6060
<artifactId>logback-classic</artifactId>
6161
</dependency>
6262

63+
<!-- YAML Support -->
64+
<dependency>
65+
<groupId>org.yaml</groupId>
66+
<artifactId>snakeyaml</artifactId>
67+
</dependency>
68+
69+
<!-- Jackson for JSON/YAML -->
70+
<dependency>
71+
<groupId>com.fasterxml.jackson.core</groupId>
72+
<artifactId>jackson-databind</artifactId>
73+
</dependency>
74+
<dependency>
75+
<groupId>com.fasterxml.jackson.datatype</groupId>
76+
<artifactId>jackson-datatype-jsr310</artifactId>
77+
</dependency>
78+
6379
<!-- Testing -->
6480
<dependency>
6581
<groupId>org.springframework.boot</groupId>
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
package com.dbsyncer.cli;
2+
3+
import com.dbsyncer.cli.command.DbSyncerCommand;
4+
import org.springframework.boot.CommandLineRunner;
5+
import org.springframework.boot.ExitCodeGenerator;
6+
import org.springframework.boot.SpringApplication;
7+
import org.springframework.boot.autoconfigure.SpringBootApplication;
8+
import org.springframework.boot.autoconfigure.domain.EntityScan;
9+
import org.springframework.context.annotation.ComponentScan;
10+
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
11+
import picocli.CommandLine;
12+
import picocli.CommandLine.IFactory;
13+
14+
@SpringBootApplication
15+
@ComponentScan(basePackages = {"com.dbsyncer.cli", "com.dbsyncer.metadata"})
16+
@EntityScan(basePackages = "com.dbsyncer.metadata.entity")
17+
@EnableJpaRepositories(basePackages = "com.dbsyncer.metadata.repository")
18+
public class DbSyncerCliApplication implements CommandLineRunner, ExitCodeGenerator {
19+
20+
private final IFactory factory;
21+
private final DbSyncerCommand dbSyncerCommand;
22+
private int exitCode;
23+
24+
public DbSyncerCliApplication(IFactory factory, DbSyncerCommand dbSyncerCommand) {
25+
this.factory = factory;
26+
this.dbSyncerCommand = dbSyncerCommand;
27+
}
28+
29+
public static void main(String[] args) {
30+
System.exit(SpringApplication.exit(SpringApplication.run(DbSyncerCliApplication.class, args)));
31+
}
32+
33+
@Override
34+
public void run(String... args) throws Exception {
35+
exitCode = new CommandLine(dbSyncerCommand, factory).execute(args);
36+
}
37+
38+
@Override
39+
public int getExitCode() {
40+
return exitCode;
41+
}
42+
}
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
package com.dbsyncer.cli.command;
2+
3+
import com.dbsyncer.cli.formatter.OutputFormatter;
4+
import com.dbsyncer.cli.service.CliConfigService;
5+
import com.dbsyncer.metadata.entity.ConnectorConfig;
6+
import org.springframework.stereotype.Component;
7+
import picocli.CommandLine.Command;
8+
import picocli.CommandLine.Option;
9+
import picocli.CommandLine.Parameters;
10+
11+
import java.util.HashMap;
12+
import java.util.List;
13+
import java.util.Map;
14+
15+
@Component
16+
@Command(
17+
name = "config",
18+
description = "Manage connector configurations",
19+
mixinStandardHelpOptions = true,
20+
subcommands = {
21+
ConfigCommand.ShowCommand.class,
22+
ConfigCommand.SetCommand.class,
23+
ConfigCommand.ListCommand.class,
24+
ConfigCommand.GenerateCommand.class
25+
}
26+
)
27+
public class ConfigCommand implements Runnable {
28+
29+
@Override
30+
public void run() {
31+
System.out.println("Configuration management commands. Use 'dbsyncer config --help' for available subcommands.");
32+
}
33+
34+
@Component
35+
@Command(name = "show", description = "Show connector configuration for a task")
36+
public static class ShowCommand implements Runnable {
37+
38+
private final CliConfigService configService;
39+
private final OutputFormatter formatter;
40+
41+
@Parameters(index = "0", description = "Task ID or name")
42+
private String taskIdentifier;
43+
44+
@Option(names = {"--type"}, description = "Connector type (source, sink)")
45+
private String connectorType;
46+
47+
@Option(names = {"-f", "--format"}, description = "Output format (table, json, yaml)", defaultValue = "json")
48+
private String outputFormat;
49+
50+
public ShowCommand(CliConfigService configService, OutputFormatter formatter) {
51+
this.configService = configService;
52+
this.formatter = formatter;
53+
}
54+
55+
@Override
56+
public void run() {
57+
try {
58+
List<ConnectorConfig> configs = configService.getConfigs(taskIdentifier);
59+
if (connectorType != null) {
60+
configs = configs.stream()
61+
.filter(c -> c.getConnectorType().name().equalsIgnoreCase(connectorType))
62+
.toList();
63+
}
64+
System.out.println(formatter.formatConnectorConfigs(configs, outputFormat));
65+
} catch (Exception e) {
66+
System.err.println("Error showing configuration: " + e.getMessage());
67+
System.exit(1);
68+
}
69+
}
70+
}
71+
72+
@Component
73+
@Command(name = "set", description = "Set a connector configuration property")
74+
public static class SetCommand implements Runnable {
75+
76+
private final CliConfigService configService;
77+
78+
@Parameters(index = "0", description = "Task ID or name")
79+
private String taskIdentifier;
80+
81+
@Option(names = {"--type"}, required = true, description = "Connector type (source, sink)")
82+
private String connectorType;
83+
84+
@Option(names = {"-p", "--property"}, required = true, description = "Property key=value pairs")
85+
private Map<String, String> properties = new HashMap<>();
86+
87+
public SetCommand(CliConfigService configService) {
88+
this.configService = configService;
89+
}
90+
91+
@Override
92+
public void run() {
93+
try {
94+
configService.updateConfig(taskIdentifier, connectorType, properties);
95+
System.out.println("Configuration updated successfully for task '" + taskIdentifier + "'");
96+
} catch (Exception e) {
97+
System.err.println("Error setting configuration: " + e.getMessage());
98+
System.exit(1);
99+
}
100+
}
101+
}
102+
103+
@Component
104+
@Command(name = "list", description = "List all connector configurations")
105+
public static class ListCommand implements Runnable {
106+
107+
private final CliConfigService configService;
108+
private final OutputFormatter formatter;
109+
110+
@Option(names = {"-f", "--format"}, description = "Output format (table, json, yaml)", defaultValue = "table")
111+
private String outputFormat;
112+
113+
public ListCommand(CliConfigService configService, OutputFormatter formatter) {
114+
this.configService = configService;
115+
this.formatter = formatter;
116+
}
117+
118+
@Override
119+
public void run() {
120+
try {
121+
List<ConnectorConfig> configs = configService.getAllConfigs();
122+
System.out.println(formatter.formatConnectorConfigList(configs, outputFormat));
123+
} catch (Exception e) {
124+
System.err.println("Error listing configurations: " + e.getMessage());
125+
System.exit(1);
126+
}
127+
}
128+
}
129+
130+
@Component
131+
@Command(name = "generate", description = "Generate connector configuration template")
132+
public static class GenerateCommand implements Runnable {
133+
134+
private final CliConfigService configService;
135+
136+
@Parameters(index = "0", description = "Task ID or name")
137+
private String taskIdentifier;
138+
139+
@Option(names = {"--type"}, required = true, description = "Connector type (source, sink)")
140+
private String connectorType;
141+
142+
@Option(names = {"-o", "--output"}, description = "Output file path")
143+
private String outputFile;
144+
145+
public GenerateCommand(CliConfigService configService) {
146+
this.configService = configService;
147+
}
148+
149+
@Override
150+
public void run() {
151+
try {
152+
String template = configService.generateConfigTemplate(taskIdentifier, connectorType);
153+
if (outputFile != null) {
154+
java.nio.file.Files.writeString(java.nio.file.Path.of(outputFile), template);
155+
System.out.println("Configuration template written to: " + outputFile);
156+
} else {
157+
System.out.println(template);
158+
}
159+
} catch (Exception e) {
160+
System.err.println("Error generating configuration: " + e.getMessage());
161+
System.exit(1);
162+
}
163+
}
164+
}
165+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
package com.dbsyncer.cli.command;
2+
3+
import org.springframework.stereotype.Component;
4+
import picocli.CommandLine.Command;
5+
import picocli.CommandLine.Option;
6+
7+
@Component
8+
@Command(
9+
name = "dbsyncer",
10+
description = "DB Syncer - Heterogeneous database migration tool based on Debezium CDC",
11+
mixinStandardHelpOptions = true,
12+
version = "1.0.0-SNAPSHOT",
13+
subcommands = {
14+
TaskCommand.class,
15+
StatusCommand.class,
16+
ConfigCommand.class
17+
}
18+
)
19+
public class DbSyncerCommand implements Runnable {
20+
21+
@Option(names = {"-v", "--verbose"}, description = "Enable verbose output")
22+
private boolean verbose;
23+
24+
@Option(names = {"--server"}, description = "Metadata service URL", defaultValue = "http://localhost:8080")
25+
private String serverUrl;
26+
27+
@Override
28+
public void run() {
29+
System.out.println("DB Syncer CLI - Use --help for available commands");
30+
System.out.println();
31+
System.out.println("Available commands:");
32+
System.out.println(" task Manage migration tasks");
33+
System.out.println(" status Query task status and progress");
34+
System.out.println(" config Manage connector configurations");
35+
System.out.println();
36+
System.out.println("Use 'dbsyncer <command> --help' for more information about a command.");
37+
}
38+
39+
public boolean isVerbose() {
40+
return verbose;
41+
}
42+
43+
public String getServerUrl() {
44+
return serverUrl;
45+
}
46+
}
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
package com.dbsyncer.cli.command;
2+
3+
import com.dbsyncer.cli.formatter.OutputFormatter;
4+
import com.dbsyncer.cli.service.CliTaskService;
5+
import com.dbsyncer.metadata.dto.TaskResponse;
6+
import com.dbsyncer.metadata.entity.TableProgress;
7+
import org.springframework.stereotype.Component;
8+
import picocli.CommandLine.Command;
9+
import picocli.CommandLine.Option;
10+
import picocli.CommandLine.Parameters;
11+
12+
import java.util.List;
13+
14+
@Component
15+
@Command(
16+
name = "status",
17+
description = "Query task status and progress",
18+
mixinStandardHelpOptions = true
19+
)
20+
public class StatusCommand implements Runnable {
21+
22+
private final CliTaskService taskService;
23+
private final OutputFormatter formatter;
24+
25+
@Parameters(index = "0", arity = "0..1", description = "Task ID or name (optional, shows all if not provided)")
26+
private String taskIdentifier;
27+
28+
@Option(names = {"--progress"}, description = "Show detailed progress information")
29+
private boolean showProgress;
30+
31+
@Option(names = {"--tables"}, description = "Show table-level progress")
32+
private boolean showTables;
33+
34+
@Option(names = {"-f", "--format"}, description = "Output format (table, json, yaml)", defaultValue = "table")
35+
private String outputFormat;
36+
37+
@Option(names = {"--watch"}, description = "Continuously watch status (refresh interval in seconds)")
38+
private Integer watchInterval;
39+
40+
public StatusCommand(CliTaskService taskService, OutputFormatter formatter) {
41+
this.taskService = taskService;
42+
this.formatter = formatter;
43+
}
44+
45+
@Override
46+
public void run() {
47+
try {
48+
if (watchInterval != null && watchInterval > 0) {
49+
watchStatus();
50+
} else {
51+
showStatus();
52+
}
53+
} catch (Exception e) {
54+
System.err.println("Error querying status: " + e.getMessage());
55+
System.exit(1);
56+
}
57+
}
58+
59+
private void showStatus() {
60+
if (taskIdentifier != null) {
61+
showTaskStatus();
62+
} else {
63+
showAllTasksStatus();
64+
}
65+
}
66+
67+
private void showTaskStatus() {
68+
TaskResponse task = taskService.getTask(taskIdentifier);
69+
System.out.println(formatter.formatTaskStatus(task, outputFormat));
70+
71+
if (showProgress) {
72+
System.out.println("\n--- Progress Details ---");
73+
System.out.println(formatter.formatTaskProgress(task, outputFormat));
74+
}
75+
76+
if (showTables) {
77+
System.out.println("\n--- Table Progress ---");
78+
List<TableProgress> tableProgress = taskService.getTableProgress(task.getId());
79+
System.out.println(formatter.formatTableProgress(tableProgress, outputFormat));
80+
}
81+
}
82+
83+
private void showAllTasksStatus() {
84+
List<TaskResponse> tasks = taskService.getAllTasks(0, 100);
85+
System.out.println(formatter.formatTaskStatusSummary(tasks, outputFormat));
86+
}
87+
88+
private void watchStatus() {
89+
System.out.println("Watching status (Ctrl+C to stop)...\n");
90+
try {
91+
while (true) {
92+
clearScreen();
93+
showStatus();
94+
Thread.sleep(watchInterval * 1000L);
95+
}
96+
} catch (InterruptedException e) {
97+
Thread.currentThread().interrupt();
98+
System.out.println("\nWatch stopped.");
99+
}
100+
}
101+
102+
private void clearScreen() {
103+
System.out.print("\033[H\033[2J");
104+
System.out.flush();
105+
}
106+
}

0 commit comments

Comments
 (0)