From e1908b4b7ad0f5b5409fe049cc4084d3e6abd339 Mon Sep 17 00:00:00 2001 From: jade Date: Wed, 9 Sep 2026 17:49:34 +0900 Subject: [PATCH] feat: add standalone pod module scaffolder --- README.md | 15 + .../presentation/ScaffoldingController.java | 35 +++ .../main/resources/static/admin/scaffold.html | 39 +++ .../ScaffoldingControllerToolDraftTest.java | 61 ++++ .../dat/lib/util/NewPodProjectScaffolder.java | 284 +++++++++++++++++ .../lib/util/NewPodProjectScaffolderTest.java | 62 ++++ ...-09-new-pod-module-workspace-scaffolder.md | 286 ++++++++++++++++++ ...-pod-module-workspace-scaffolder-design.md | 57 ++++ 8 files changed, 839 insertions(+) create mode 100644 dat-was-lib/src/main/java/io/shinhanlife/dat/lib/util/NewPodProjectScaffolder.java create mode 100644 dat-was-lib/src/test/java/io/shinhanlife/dat/lib/util/NewPodProjectScaffolderTest.java create mode 100644 docs/superpowers/plans/2026-09-09-new-pod-module-workspace-scaffolder.md create mode 100644 docs/superpowers/specs/2026-09-09-new-pod-module-workspace-scaffolder-design.md diff --git a/README.md b/README.md index aa00fd70..ea90a86a 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,21 @@ docker compose up -d --build Docker Compose로 실행한 경우 Gateway 화면은 포트 `8281`을 사용합니다. +### Pod Module New: 독립 프로젝트 생성 + +Scaffold 화면의 `Pod Module New` 탭은 기존 Pod Module 생성 흐름과 별도로 동작합니다. 새 모듈명과 포트, workspace 경로를 입력하면 해당 workspace 바로 아래에 독립 Tool Pod 프로젝트를 생성합니다. + +기본 workspace는 `C:\eGovFrameDev-4.3.1-64bit\workspace`이며, 예를 들어 모듈명으로 `dat-was-payment`를 입력하면 `C:\eGovFrameDev-4.3.1-64bit\workspace\dat-was-payment` 프로젝트가 만들어집니다. 모듈명은 `dat-was-`로 시작해야 하며, 이미 같은 폴더가 있으면 덮어쓰지 않고 생성 요청을 거절합니다. + +생성된 프로젝트는 같은 workspace의 `dat-lib-datmt`를 Gradle composite build로 참조합니다. 생성 직후 프로젝트 폴더에서 다음 명령으로 컴파일할 수 있습니다. + +```powershell +cd C:\eGovFrameDev-4.3.1-64bit\workspace\dat-was-payment +.\gradlew.bat compileJava +``` + +이 기능은 기존 AX HUB 멀티 모듈 Pod나 기존 `Pod Module` 탭의 구성·소스를 수정하지 않습니다. + ### Tool Pod Test Console 공통 정적 화면인 `tool-test-console.html`은 Tool Pod의 `/tool-manifest`를 읽어, 해당 Pod에 등록된 Tool과 `inputSchema`를 기준으로 요청 JSON을 만들어 직접 실행합니다. diff --git a/dat-gateway/src/main/java/io/shinhanlife/dat/mcg/presentation/ScaffoldingController.java b/dat-gateway/src/main/java/io/shinhanlife/dat/mcg/presentation/ScaffoldingController.java index 6004028e..5949ee28 100644 --- a/dat-gateway/src/main/java/io/shinhanlife/dat/mcg/presentation/ScaffoldingController.java +++ b/dat-gateway/src/main/java/io/shinhanlife/dat/mcg/presentation/ScaffoldingController.java @@ -15,6 +15,7 @@ package io.shinhanlife.dat.mcg.presentation; * * */ +import io.shinhanlife.dat.lib.util.NewPodProjectScaffolder; import io.shinhanlife.dat.lib.util.PodScaffolder; import io.shinhanlife.dat.lib.util.MciResponseScaffolder; import io.shinhanlife.dat.lib.util.ToolScaffolder; @@ -22,6 +23,8 @@ import io.shinhanlife.dat.lib.util.ToolSourceUpdater; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import java.io.File; +import java.io.IOException; +import java.nio.file.Path; import java.time.LocalDate; import java.time.format.DateTimeFormatter; import java.util.Arrays; @@ -51,6 +54,7 @@ public class ScaffoldingController { "cohere/north-mini-code:free"); static final String DEFAULT_WORKSPACE = "C:\\eGovFrameDev-4.3.1-64bit\\workspace-egov\\dat-was-dasmt"; + static final String DEFAULT_NEW_POD_WORKSPACE = "C:\\eGovFrameDev-4.3.1-64bit\\workspace"; private final ChatClient.Builder chatClientBuilder; private final ObjectMapper objectMapper; @@ -95,6 +99,34 @@ public class ScaffoldingController { } } + /** + * Creates an independent Pod project below the shared workspace. + * This deliberately does not use PodScaffolder, which changes the legacy AX Hub multi-module project. + */ + @PostMapping("/pod-new") + public ResponseEntity scaffoldNewPod(@RequestBody NewPodProjectRequest request) { + try { + if (request == null || request.moduleName() == null || request.moduleName().isBlank()) { + return ResponseEntity.badRequest().body(Map.of("error", "Pod Module New 이름을 입력해주세요.")); + } + if (request.port() == null) { + return ResponseEntity.badRequest().body(Map.of("error", "서비스 포트를 입력해주세요.")); + } + String workspacePath = request.workspacePath() == null || request.workspacePath().isBlank() + ? DEFAULT_NEW_POD_WORKSPACE : request.workspacePath().trim(); + String author = request.author() == null || request.author().isBlank() + ? System.getProperty("user.name") : request.author().trim(); + String date = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy.MM.dd")); + String result = NewPodProjectScaffolder.scaffold( + Path.of(workspacePath), request.moduleName().trim(), request.port(), author, date); + return ResponseEntity.ok(result); + } catch (IllegalArgumentException | IllegalStateException e) { + return ResponseEntity.badRequest().body(Map.of("error", safeMessage(e))); + } catch (IOException e) { + return ResponseEntity.internalServerError().body(Map.of("error", safeMessage(e))); + } + } + @PostMapping("/pod-draft") public ResponseEntity generatePodManifestDraft(@RequestBody Map req) { String description = req.getOrDefault("description", "").trim(); @@ -775,4 +807,7 @@ public class ScaffoldingController { private record ToolGroupRequest(String useCaseName, String moduleName, String author, String date, String workspacePath, List tools) { } + + private record NewPodProjectRequest(String moduleName, Integer port, String author, String workspacePath) { + } } diff --git a/dat-gateway/src/main/resources/static/admin/scaffold.html b/dat-gateway/src/main/resources/static/admin/scaffold.html index d4814e94..9e221377 100644 --- a/dat-gateway/src/main/resources/static/admin/scaffold.html +++ b/dat-gateway/src/main/resources/static/admin/scaffold.html @@ -784,6 +784,9 @@ + @@ -876,6 +879,41 @@ + +
+
+
+ + +
입력한 workspace 바로 아래에 새 프로젝트 폴더가 생성됩니다.
+
+
+ + +
`dat-was-`로 시작하는 영문 소문자, 숫자, 하이픈 이름을 입력하세요.
+
+
+ + +
+
+ + +
+ +
+ +
+
+
+
@@ -1781,6 +1819,7 @@ }); handleFormSubmit('podForm', '/api/v1/scaffold/pod'); + handleFormSubmit('newPodForm', '/api/v1/scaffold/pod-new'); function currentTargetModules() { return Array.from(document.querySelectorAll('#targetModuleSelect option')) diff --git a/dat-gateway/src/test/java/io/shinhanlife/dat/mcg/presentation/ScaffoldingControllerToolDraftTest.java b/dat-gateway/src/test/java/io/shinhanlife/dat/mcg/presentation/ScaffoldingControllerToolDraftTest.java index f28b2cc9..f048bb23 100644 --- a/dat-gateway/src/test/java/io/shinhanlife/dat/mcg/presentation/ScaffoldingControllerToolDraftTest.java +++ b/dat-gateway/src/test/java/io/shinhanlife/dat/mcg/presentation/ScaffoldingControllerToolDraftTest.java @@ -12,6 +12,7 @@ import org.junit.jupiter.api.io.TempDir; import java.nio.file.Path; import java.nio.file.Files; +import java.nio.charset.StandardCharsets; import java.util.List; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -32,6 +33,66 @@ class ScaffoldingControllerToolDraftTest { @TempDir Path root; + @Test + void createsStandalonePodProjectWithoutChangingExistingPodScaffolder() throws Exception { + createLibraryProject(); + MockMvc mockMvc = MockMvcBuilders.standaloneSetup( + new ScaffoldingController(mock(ChatClient.Builder.class), new ObjectMapper())) + .setMessageConverters(new MappingJackson2HttpMessageConverter()) + .build(); + String workspacePath = root.toString().replace("\\", "\\\\"); + + mockMvc.perform(post("/api/v1/scaffold/pod-new") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"moduleName":"dat-was-payment","port":8099, + "author":"tester","workspacePath":"%s"} + """.formatted(workspacePath))) + .andExpect(status().isOk()) + .andExpect(content().string(org.hamcrest.Matchers.containsString("dat-was-payment"))); + + assertTrue(Files.exists(root.resolve("dat-was-payment/settings.gradle"))); + } + + private void createLibraryProject() throws Exception { + Path library = root.resolve("dat-lib-datmt"); + Files.createDirectories(library.resolve("gradle/wrapper")); + Files.writeString(library.resolve("gradlew"), "#!/bin/sh\n"); + Files.writeString(library.resolve("gradlew.bat"), "@echo off\n"); + Files.writeString(library.resolve("gradle/wrapper/gradle-wrapper.jar"), "wrapper"); + Files.writeString(library.resolve("gradle/wrapper/gradle-wrapper.properties"), "distributionUrl=test\n"); + } + + @Test + void rejectsNewPodProjectWhenTheTargetModuleAlreadyExists() throws Exception { + Files.createDirectories(root.resolve("dat-was-payment")); + MockMvc mockMvc = MockMvcBuilders.standaloneSetup( + new ScaffoldingController(mock(ChatClient.Builder.class), new ObjectMapper())) + .setMessageConverters(new MappingJackson2HttpMessageConverter()) + .build(); + String workspacePath = root.toString().replace("\\", "\\\\"); + + mockMvc.perform(post("/api/v1/scaffold/pod-new") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"moduleName":"dat-was-payment","port":8099, + "workspacePath":"%s"} + """.formatted(workspacePath))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").value(org.hamcrest.Matchers.containsString("already exists"))); + } + + @Test + void scaffoldPageKeepsTheNewPodFormSeparateFromTheLegacyPodForm() throws Exception { + try (var pageStream = getClass().getResourceAsStream("/static/admin/scaffold.html")) { + String page = new String(java.util.Objects.requireNonNull(pageStream).readAllBytes(), StandardCharsets.UTF_8); + assertTrue(page.contains("id=\"podForm\"")); + assertTrue(page.contains("id=\"newPodForm\"")); + assertTrue(page.contains("/api/v1/scaffold/pod-new")); + assertTrue(page.contains("Pod Module New")); + } + } + @Test void usesRenamedDasmtWorkspaceByDefault() { assertTrue(ScaffoldingController.DEFAULT_WORKSPACE.endsWith("dat-was-dasmt")); diff --git a/dat-was-lib/src/main/java/io/shinhanlife/dat/lib/util/NewPodProjectScaffolder.java b/dat-was-lib/src/main/java/io/shinhanlife/dat/lib/util/NewPodProjectScaffolder.java new file mode 100644 index 00000000..9ac6a293 --- /dev/null +++ b/dat-was-lib/src/main/java/io/shinhanlife/dat/lib/util/NewPodProjectScaffolder.java @@ -0,0 +1,284 @@ +package io.shinhanlife.dat.lib.util; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.Comparator; +import java.util.regex.Pattern; + +/** Creates a standalone Tool Pod repository without changing the legacy PodScaffolder flow. */ +public final class NewPodProjectScaffolder { + + private static final Pattern MODULE_NAME = Pattern.compile("^dat-was-[a-z0-9]+(?:-[a-z0-9]+)*$"); + + private NewPodProjectScaffolder() { + } + + public static String scaffold(Path workspaceRoot, String moduleName, int port, String author, String createdDate) + throws IOException { + validateModuleName(moduleName); + validatePort(port); + if (workspaceRoot == null) { + throw new IllegalArgumentException("Workspace directory does not exist: null"); + } + Path normalizedWorkspace = workspaceRoot.toAbsolutePath().normalize(); + if (!Files.isDirectory(normalizedWorkspace)) { + throw new IllegalArgumentException("Workspace directory does not exist: " + workspaceRoot); + } + Path target = normalizedWorkspace.resolve(moduleName).normalize(); + if (!target.getParent().equals(normalizedWorkspace)) { + throw new IllegalArgumentException("Module path must be directly below the workspace"); + } + if (Files.exists(target)) { + throw new IllegalStateException("Project already exists: " + moduleName); + } + Path libraryProject = normalizedWorkspace.resolve("dat-lib-datmt"); + if (!Files.isDirectory(libraryProject)) { + throw new IllegalArgumentException("dat-lib-datmt project does not exist below the workspace: " + libraryProject); + } + + Path temporary = Files.createTempDirectory(normalizedWorkspace, ".new-pod-"); + try { + writeProject(temporary, moduleName, port, blankToDefault(author, System.getProperty("user.name")), + blankToDefault(createdDate, "unknown"), libraryProject); + moveIntoPlace(temporary, target); + return "독립 Pod 프로젝트 생성 완료: " + target; + } catch (IOException | RuntimeException error) { + deleteRecursively(temporary); + throw error; + } + } + + public static void validateModuleName(String moduleName) { + if (moduleName == null || !MODULE_NAME.matcher(moduleName).matches() || "dat-was-lib".equals(moduleName)) { + throw new IllegalArgumentException("Module name must match dat-was-: " + moduleName); + } + } + + public static void validatePort(int port) { + if (port < 1 || port > 65535) { + throw new IllegalArgumentException("Port must be between 1 and 65535: " + port); + } + } + + private static void writeProject(Path root, String moduleName, int port, String author, String createdDate, + Path libraryProject) + throws IOException { + String shortName = moduleName.substring("dat-was-".length()); + String packageName = shortName.replace("-", ""); + String className = "DatWas" + pascalCase(shortName) + "Application"; + String serviceName = "was-" + shortName; + write(root.resolve("settings.gradle"), """ + rootProject.name = '%s' + + includeBuild('../dat-lib-datmt') { + dependencySubstitution { + substitute module('io.shinhanlife:dat-lib-datmt') using project(':dat-was-lib') + } + } + """.formatted(moduleName)); + write(root.resolve("build.gradle"), """ + plugins { + id 'java' + id 'org.springframework.boot' version '3.5.11' + id 'io.spring.dependency-management' version '1.1.7' + } + + group = 'io.shinhanlife' + version = '0.0.1-SNAPSHOT' + + java { + toolchain { languageVersion = JavaLanguageVersion.of(21) } + } + + repositories { mavenCentral() } + + dependencies { + implementation 'io.shinhanlife:dat-lib-datmt:0.0.1-SNAPSHOT' + } + + tasks.withType(Test).configureEach { useJUnitPlatform() } + """); + copyGradleWrapper(libraryProject, root); + write(root.resolve("src/main/java/io/shinhanlife/dat/mcc/" + packageName + "/" + className + ".java"), """ + package io.shinhanlife.dat.mcc.%s; + + import org.springframework.boot.SpringApplication; + import org.springframework.boot.autoconfigure.SpringBootApplication; + import org.springframework.boot.context.properties.ConfigurationPropertiesScan; + + /** Generated by Pod Module New for %s on %s. */ + @SpringBootApplication(scanBasePackages = {"io.shinhanlife.dat.mcc", "io.shinhanlife.dat.lib"}) + @ConfigurationPropertiesScan(basePackages = {"io.shinhanlife.dat.mcc", "io.shinhanlife.dat.lib"}) + public class %s { + public static void main(String[] args) { + SpringApplication.run(%s.class, args); + } + } + """.formatted(packageName, author, createdDate, className, className)); + write(root.resolve("src/main/resources/application.yml"), """ + server: + port: ${PORT:%d} + spring: + application: + name: %s + config: + import: optional:classpath:tool-service-manifest.yml + profiles: + active: local + mcp: + namespace: %s + manifest: + bundle-id: %s + name-prefix: "" + security: + api-key: ${TOOL_SERVER_API_KEY:tool-server-key} + tenant-domains: + TESTER-DEV: ALL + """.formatted(port, moduleName, shortName, serviceName)); + for (String profile : new String[] {"local", "dev", "test", "prod"}) { + write(root.resolve("src/main/resources/application-" + profile + ".yml"), """ + spring: + config: + activate: + on-profile: %s + """.formatted(profile)); + } + write(root.resolve("src/main/resources/tool-service-manifest.yml"), """ + mcp: + manifest: + routing-functions: + - name: route_to_%s + server-id: %s + category-key: %s + product-boundary: "업무 범위를 입력하세요." + business-domain: "업무 도메인을 입력하세요." + business-outcome: "업무 결과를 입력하세요." + primary-entities: [] + capabilities: [] + select-if: "이 Pod의 업무 요청인 경우" + reject-if: "다른 업무 Pod 요청인 경우" + confusable-servers: [] + decision-policy: "업무 도메인을 기준으로 선택합니다." + """.formatted(moduleName, moduleName, shortName)); + write(root.resolve("Dockerfile"), """ + FROM eclipse-temurin:21-jre-alpine + WORKDIR /app + COPY build/libs/*-SNAPSHOT.jar app.jar + EXPOSE %d + ENTRYPOINT ["java", "-jar", "app.jar"] + """.formatted(port)); + write(root.resolve("docker-compose.yml"), """ + services: + %s: + build: . + ports: + - "${HOST_PORT:%d}:${PORT:%d}" + environment: + - PORT=${PORT:%d} + - AXHUB_TOOL_URL=http://%s:${PORT:%d} + - SPRING_PROFILES_ACTIVE=${ACTIVE_PROFILE:-local} + """.formatted(serviceName, port, port, port, serviceName, port)); + write(root.resolve("k8s/base/deployment.yaml"), """ + apiVersion: apps/v1 + kind: Deployment + metadata: + name: %s + spec: + replicas: 1 + selector: + matchLabels: { app: %s } + template: + metadata: + labels: { app: %s } + spec: + containers: + - name: %s + image: %s:latest + ports: + - containerPort: %d + """.formatted(serviceName, serviceName, serviceName, serviceName, serviceName, port)); + write(root.resolve("k8s/base/service.yaml"), """ + apiVersion: v1 + kind: Service + metadata: + name: %s + spec: + selector: { app: %s } + ports: + - port: %d + targetPort: %d + """.formatted(serviceName, serviceName, port, port)); + write(root.resolve("README.md"), """ + # %s + + Pod Module New로 생성된 독립 DATMT Tool Pod입니다. + + ## 공통 라이브러리 + + 상위 workspace의 `dat-lib-datmt`를 Gradle composite build로 참조합니다. + + ## 빌드 + + ```powershell + .\\gradlew.bat compileJava + ``` + + 기본 포트는 %d이며, 실행 시 `PORT` 환경변수로 변경할 수 있습니다. + """.formatted(moduleName, port)); + } + + private static void copyGradleWrapper(Path libraryProject, Path targetProject) throws IOException { + for (String relativePath : new String[] { + "gradlew", "gradlew.bat", "gradle/wrapper/gradle-wrapper.jar", "gradle/wrapper/gradle-wrapper.properties"}) { + Path source = libraryProject.resolve(relativePath); + if (!Files.isRegularFile(source)) { + throw new IllegalArgumentException("dat-lib-datmt Gradle wrapper file is missing: " + source); + } + Path target = targetProject.resolve(relativePath); + Files.createDirectories(target.getParent()); + Files.copy(source, target, StandardCopyOption.COPY_ATTRIBUTES); + } + } + + private static void write(Path path, String content) throws IOException { + Files.createDirectories(path.getParent()); + Files.writeString(path, content, StandardCharsets.UTF_8); + } + + private static String pascalCase(String value) { + StringBuilder result = new StringBuilder(); + for (String part : value.split("-")) { + result.append(Character.toUpperCase(part.charAt(0))).append(part.substring(1)); + } + return result.toString(); + } + + private static String blankToDefault(String value, String fallback) { + return value == null || value.isBlank() ? fallback : value; + } + + private static void moveIntoPlace(Path source, Path target) throws IOException { + try { + Files.move(source, target, StandardCopyOption.ATOMIC_MOVE); + } catch (AtomicMoveNotSupportedException ignored) { + Files.move(source, target); + } + } + + private static void deleteRecursively(Path root) throws IOException { + if (!Files.exists(root)) return; + try (var paths = Files.walk(root)) { + paths.sorted(Comparator.reverseOrder()).forEach(path -> { + try { + Files.deleteIfExists(path); + } catch (IOException error) { + throw new IllegalStateException(error); + } + }); + } + } +} diff --git a/dat-was-lib/src/test/java/io/shinhanlife/dat/lib/util/NewPodProjectScaffolderTest.java b/dat-was-lib/src/test/java/io/shinhanlife/dat/lib/util/NewPodProjectScaffolderTest.java new file mode 100644 index 00000000..cf447f64 --- /dev/null +++ b/dat-was-lib/src/test/java/io/shinhanlife/dat/lib/util/NewPodProjectScaffolderTest.java @@ -0,0 +1,62 @@ +package io.shinhanlife.dat.lib.util; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.file.Files; +import java.nio.file.Path; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class NewPodProjectScaffolderTest { + + @TempDir + Path workspace; + + @Test + void createsStandaloneProjectWithCompositeBuild() throws Exception { + createLibraryProject(); + String result = NewPodProjectScaffolder.scaffold(workspace, "dat-was-payment", 8099, + "tester", "2026.09.09"); + + Path project = workspace.resolve("dat-was-payment"); + assertTrue(Files.exists(project.resolve("settings.gradle"))); + assertTrue(Files.exists(project.resolve("build.gradle"))); + assertTrue(Files.exists(project.resolve("src/main/resources/application.yml"))); + assertTrue(Files.exists(project.resolve("docker-compose.yml"))); + assertTrue(Files.exists(project.resolve("k8s/base/deployment.yaml"))); + assertTrue(Files.exists(project.resolve("gradlew.bat"))); + assertTrue(Files.exists(project.resolve("gradle/wrapper/gradle-wrapper.jar"))); + assertTrue(Files.readString(project.resolve("settings.gradle")) + .contains("includeBuild('../dat-lib-datmt')")); + assertTrue(Files.readString(project.resolve("build.gradle")) + .contains("io.shinhanlife:dat-lib-datmt:0.0.1-SNAPSHOT")); + assertTrue(Files.readString(project.resolve("src/main/resources/application.yml")) + .contains("port: ${PORT:8099}")); + assertTrue(Files.readString(project.resolve("src/main/resources/application.yml")) + .contains("import: optional:classpath:tool-service-manifest.yml")); + assertTrue(result.contains("dat-was-payment")); + } + + private void createLibraryProject() throws Exception { + Path library = workspace.resolve("dat-lib-datmt"); + Files.createDirectories(library.resolve("gradle/wrapper")); + Files.writeString(library.resolve("gradlew"), "#!/bin/sh\n"); + Files.writeString(library.resolve("gradlew.bat"), "@echo off\n"); + Files.writeString(library.resolve("gradle/wrapper/gradle-wrapper.jar"), "wrapper"); + Files.writeString(library.resolve("gradle/wrapper/gradle-wrapper.properties"), "distributionUrl=test\n"); + } + + @Test + void rejectsInvalidNamesPortsAndExistingProjects() throws Exception { + assertThrows(IllegalArgumentException.class, + () -> NewPodProjectScaffolder.scaffold(workspace, "payment", 8099, "tester", "2026.09.09")); + assertThrows(IllegalArgumentException.class, + () -> NewPodProjectScaffolder.scaffold(workspace, "dat-was-lib", 8099, "tester", "2026.09.09")); + assertThrows(IllegalArgumentException.class, + () -> NewPodProjectScaffolder.scaffold(workspace, "dat-was-payment", 0, "tester", "2026.09.09")); + Files.createDirectories(workspace.resolve("dat-was-payment")); + assertThrows(IllegalStateException.class, + () -> NewPodProjectScaffolder.scaffold(workspace, "dat-was-payment", 8099, "tester", "2026.09.09")); + } +} diff --git a/docs/superpowers/plans/2026-09-09-new-pod-module-workspace-scaffolder.md b/docs/superpowers/plans/2026-09-09-new-pod-module-workspace-scaffolder.md new file mode 100644 index 00000000..aa3c87d9 --- /dev/null +++ b/docs/superpowers/plans/2026-09-09-new-pod-module-workspace-scaffolder.md @@ -0,0 +1,286 @@ +# Pod Module New Workspace Scaffolder Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 기존 Pod Scaffold를 변경하지 않고, Scaffold 화면에서 독립 Tool Pod 프로젝트를 `C:\eGovFrameDev-4.3.1-64bit\workspace`에 생성한다. + +**Architecture:** 기존 `PodScaffolder`와 `/api/v1/scaffold/pod`는 유지한다. `NewPodProjectScaffolder`가 독립 Gradle 프로젝트 파일을 생성하고, 별도 `/api/v1/scaffold/pod-new` endpoint와 `Pod Module New` UI가 이를 호출한다. 생성 프로젝트는 `../dat-lib-datmt` composite build로 공통 라이브러리를 해석한다. + +**Tech Stack:** Java 21, Spring Boot 3.5.11, Gradle composite build, JUnit 5, MockMvc, static HTML/JavaScript. + +**Spec:** `docs/superpowers/specs/2026-09-09-new-pod-module-workspace-scaffolder-design.md` + +## Global Constraints + +- `PodScaffolder` 및 기존 `/api/v1/scaffold/pod`의 코드와 동작을 변경하지 않는다. +- 기존 Pod 모듈 `dat-was-cus`, `dat-was-pro`, `dat-was-sal`, `dat-was-sys`를 수정하지 않는다. +- 신규 생성 대상은 사용자 입력 workspace의 `/`이며, 기본 workspace는 `C:\eGovFrameDev-4.3.1-64bit\workspace`이다. +- moduleName은 `dat-was-` 접두사가 붙은 소문자·숫자·하이픈 식별자만 허용하고 `dat-was-lib`는 거부한다. +- port는 1부터 65535까지의 정수만 허용한다. +- 생성 프로젝트는 `../dat-lib-datmt`를 포함하고 `io.shinhanlife:dat-lib-datmt:0.0.1-SNAPSHOT`를 `:dat-was-lib`로 치환한다. +- 사용자 요청이 없는 한 commit 또는 push하지 않는다. + +--- + +### Task 1: 독립 Pod 프로젝트 생성기 + +**Files:** +- Create: `dat-was-lib/src/main/java/io/shinhanlife/dat/lib/util/NewPodProjectScaffolder.java` +- Create: `dat-was-lib/src/test/java/io/shinhanlife/dat/lib/util/NewPodProjectScaffolderTest.java` + +**Interfaces:** +- Produces: `NewPodProjectScaffolder.scaffold(Path workspaceRoot, String moduleName, int port, String author, String createdDate)` returning a human-readable generation summary. +- Produces: `NewPodProjectScaffolder.validateModuleName(String moduleName)` and `validatePort(int port)` throwing `IllegalArgumentException` for invalid input. +- Consumes: a writable workspace root; creates no files outside it. + +- [ ] **Step 1: Write the failing generator test** + +```java +@Test +void createsStandaloneProjectWithCompositeBuild() throws Exception { + String result = NewPodProjectScaffolder.scaffold(workspace, "dat-was-payment", 8099, + "tester", "2026.09.09"); + + Path project = workspace.resolve("dat-was-payment"); + assertTrue(Files.exists(project.resolve("settings.gradle"))); + assertTrue(Files.exists(project.resolve("build.gradle"))); + assertTrue(Files.exists(project.resolve("src/main/resources/application.yml"))); + assertTrue(Files.exists(project.resolve("docker-compose.yml"))); + assertTrue(Files.exists(project.resolve("k8s/base/deployment.yaml"))); + assertTrue(Files.readString(project.resolve("settings.gradle")) + .contains("includeBuild('../dat-lib-datmt')")); + assertTrue(Files.readString(project.resolve("build.gradle")) + .contains("io.shinhanlife:dat-lib-datmt:0.0.1-SNAPSHOT")); + assertTrue(Files.readString(project.resolve("src/main/resources/application.yml")) + .contains("port: ${PORT:8099}")); + assertTrue(result.contains("dat-was-payment")); +} +``` + +- [ ] **Step 2: Run the new test and verify the expected failure** + +Run: `.\gradlew.bat :dat-was-lib:test --tests "io.shinhanlife.dat.lib.util.NewPodProjectScaffolderTest.createsStandaloneProjectWithCompositeBuild" --no-daemon` + +Expected: compilation failure because `NewPodProjectScaffolder` does not exist. + +- [ ] **Step 3: Write failing validation and non-overwrite tests** + +```java +@Test +void rejectsInvalidNamesPortsAndExistingProjects() throws Exception { + assertThrows(IllegalArgumentException.class, + () -> NewPodProjectScaffolder.scaffold(workspace, "payment", 8099, "tester", "2026.09.09")); + assertThrows(IllegalArgumentException.class, + () -> NewPodProjectScaffolder.scaffold(workspace, "dat-was-lib", 8099, "tester", "2026.09.09")); + assertThrows(IllegalArgumentException.class, + () -> NewPodProjectScaffolder.scaffold(workspace, "dat-was-payment", 0, "tester", "2026.09.09")); + Files.createDirectories(workspace.resolve("dat-was-payment")); + assertThrows(IllegalStateException.class, + () -> NewPodProjectScaffolder.scaffold(workspace, "dat-was-payment", 8099, "tester", "2026.09.09")); +} +``` + +- [ ] **Step 4: Run validation test and verify the expected failure** + +Run: `.\gradlew.bat :dat-was-lib:test --tests "io.shinhanlife.dat.lib.util.NewPodProjectScaffolderTest.rejectsInvalidNamesPortsAndExistingProjects" --no-daemon` + +Expected: compilation failure because the new generator is not implemented. + +- [ ] **Step 5: Implement the generator** + +Create only the requested standalone project root. Generate these files: + +```text +//settings.gradle +//build.gradle +//README.md +//Dockerfile +//docker-compose.yml +//k8s/base/deployment.yaml +//k8s/base/service.yaml +//src/main/java/io/shinhanlife/dat/mcc//DatWasApplication.java +//src/main/resources/application.yml +//src/main/resources/application-local.yml +//src/main/resources/application-dev.yml +//src/main/resources/application-test.yml +//src/main/resources/application-prod.yml +//src/main/resources/tool-service-manifest.yml +``` + +Use this generated `settings.gradle` content: + +```gradle +rootProject.name = '' + +includeBuild('../dat-lib-datmt') { + dependencySubstitution { + substitute module('io.shinhanlife:dat-lib-datmt') using project(':dat-was-lib') + } +} +``` + +Use this generated dependency declaration: + +```gradle +dependencies { + implementation 'io.shinhanlife:dat-lib-datmt:0.0.1-SNAPSHOT' +} +``` + +Make all writes UTF-8 and use `Files.createDirectories` only after validation. Build a temporary project directory next to the final target and move it into place atomically after all writes succeed; remove that temporary directory if any write fails. + +- [ ] **Step 6: Run generator tests and verify they pass** + +Run: `.\gradlew.bat :dat-was-lib:test --tests "io.shinhanlife.dat.lib.util.NewPodProjectScaffolderTest" --no-daemon` + +Expected: all new generator tests pass. + +### Task 2: 신규 Scaffold API + +**Files:** +- Modify: `dat-gateway/src/main/java/io/shinhanlife/dat/mcg/presentation/ScaffoldingController.java` +- Modify: `dat-gateway/src/test/java/io/shinhanlife/dat/mcg/presentation/ScaffoldingControllerToolDraftTest.java` + +**Interfaces:** +- Consumes: JSON `{"moduleName":"dat-was-payment","port":8099,"author":"tester","workspacePath":"C:\\temp\\workspace"}`. +- Produces: `POST /api/v1/scaffold/pod-new` response with HTTP 200 and generated-project summary, or HTTP 400 with a validation message. +- Consumes: `NewPodProjectScaffolder.scaffold(Path, String, int, String, String)` from Task 1. + +- [ ] **Step 1: Write the failing MockMvc success test** + +```java +@Test +void createsStandalonePodProjectWithoutCallingExistingPodScaffolder() throws Exception { + mockMvc.perform(post("/api/v1/scaffold/pod-new") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"moduleName":"dat-was-payment","port":8099, + "author":"tester","workspacePath":"%s"} + """.formatted(root.toString().replace("\\", "\\\\")))) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("dat-was-payment"))); + + assertTrue(Files.exists(root.resolve("dat-was-payment/settings.gradle"))); +} +``` + +- [ ] **Step 2: Run the API test and verify the expected failure** + +Run: `.\gradlew.bat :dat-gateway:test --tests "io.shinhanlife.dat.mcg.presentation.ScaffoldingControllerToolDraftTest.createsStandalonePodProjectWithoutCallingExistingPodScaffolder" --no-daemon` + +Expected: HTTP 404 because `/api/v1/scaffold/pod-new` does not exist. + +- [ ] **Step 3: Add the isolated endpoint and request validation** + +Add `@PostMapping("/pod-new")` without altering `scaffoldPod`. Define a `NewPodProjectRequest` record in `ScaffoldingController` with `moduleName`, `Integer port`, `author`, and `workspacePath`. Default a missing or blank workspace path only for this new endpoint to `C:\eGovFrameDev-4.3.1-64bit\workspace`. Return `ResponseEntity.badRequest()` for invalid names, missing ports, invalid ports, or a pre-existing target path. Do not set `AXHUB_SOURCE_DIR` in this endpoint. + +- [ ] **Step 4: Write and run the duplicate-target API test** + +```java +@Test +void rejectsExistingStandalonePodProject() throws Exception { + Files.createDirectories(root.resolve("dat-was-payment")); + + mockMvc.perform(post("/api/v1/scaffold/pod-new") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"moduleName":"dat-was-payment","port":8099, + "workspacePath":"%s"} + """.formatted(root.toString().replace("\\", "\\\\")))) + .andExpect(status().isBadRequest()); +} +``` + +Run: `.\gradlew.bat :dat-gateway:test --tests "io.shinhanlife.dat.mcg.presentation.ScaffoldingControllerToolDraftTest" --no-daemon` + +Expected: all controller scaffolding tests pass, including existing `/pod` behavior. + +### Task 3: Pod Module New 화면 + +**Files:** +- Modify: `dat-gateway/src/main/resources/static/admin/scaffold.html` +- Test: `dat-gateway/src/test/java/io/shinhanlife/dat/mcg/presentation/ScaffoldingControllerToolDraftTest.java` + +**Interfaces:** +- Consumes: `/api/v1/scaffold/pod-new` from Task 2. +- Produces: a separate `newPodForm`; no existing `podForm` markup, `handleFormSubmit('podForm', ...)`, or existing manifest draft request changes. + +- [ ] **Step 1: Write a failing static-page assertion** + +```java +@Test +void scaffoldPageContainsSeparateNewPodForm() throws Exception { + String page = Files.readString(Path.of("src/main/resources/static/admin/scaffold.html")); + assertTrue(page.contains("Pod Module New")); + assertTrue(page.contains("id=\"newPodForm\"")); + assertTrue(page.contains("/api/v1/scaffold/pod-new")); + assertTrue(page.contains("C:\\eGovFrameDev-4.3.1-64bit\\workspace")); +} +``` + +- [ ] **Step 2: Run the assertion and verify the expected failure** + +Run: `.\gradlew.bat :dat-gateway:test --tests "io.shinhanlife.dat.mcg.presentation.ScaffoldingControllerToolDraftTest.scaffoldPageContainsSeparateNewPodForm" --no-daemon` + +Expected: assertion failure because the new tab and form do not exist. + +- [ ] **Step 3: Add a separate tab and form** + +Add a new `Pod Module New` tab after the existing Pod Module tab. Its `newPodForm` must have only workspace path, module name, service port, author, and date inputs. Pre-fill only this new form's workspace path with `C:\eGovFrameDev-4.3.1-64bit\workspace`. Bind it with `handleFormSubmit('newPodForm', '/api/v1/scaffold/pod-new')`. Keep `podForm`, its workspace default, and its `/pod` handler unchanged. + +- [ ] **Step 4: Run the static-page assertion and controller tests** + +Run: `.\gradlew.bat :dat-gateway:test --tests "io.shinhanlife.dat.mcg.presentation.ScaffoldingControllerToolDraftTest" --no-daemon` + +Expected: all scaffold controller tests pass. + +### Task 4: End-to-end generated project verification + +**Files:** +- Modify: `README.md` + +**Interfaces:** +- Documents: `Pod Module New` creates an independent repository under the selected workspace, while existing Pod Module continues to create modules under its selected root. + +- [ ] **Step 1: Add a Korean README section** + +Document the two Scaffold paths with this behavior table: + +```markdown +| 기능 | 생성 위치 | 기존 Pod 영향 | +| --- | --- | --- | +| Pod Module | 선택한 기존 프로젝트 root | 기존 동작 유지 | +| Pod Module New | `C:\eGovFrameDev-4.3.1-64bit\workspace\` | 기존 Pod/Scaffolder 미수정 | +``` + +State that `Pod Module New` requires `../dat-lib-datmt` to exist beside the generated project and that the generated port comes from the form input, overridable by `PORT` at runtime. + +- [ ] **Step 2: Create one temporary project through the new endpoint** + +Use a unique throwaway module name such as `dat-was-scaffoldverify` and workspace `C:\eGovFrameDev-4.3.1-64bit\workspace`. Confirm the generated directory contains the settings file, composite dependency substitution, application source, and Docker/Kubernetes files. + +- [ ] **Step 3: Compile the generated project** + +Run from the generated project: + +```powershell +.\gradlew.bat compileJava --no-daemon +``` + +Expected: Gradle resolves `../dat-lib-datmt/:dat-was-lib` and exits successfully. + +- [ ] **Step 4: Remove only the verified throwaway project** + +Verify the absolute path is exactly `C:\eGovFrameDev-4.3.1-64bit\workspace\dat-was-scaffoldverify`, then remove that one generated verification project. Do not alter any user-created project under `workspace`. + +- [ ] **Step 5: Run final targeted verification** + +Run: + +```powershell +.\gradlew.bat :dat-was-lib:test --tests "io.shinhanlife.dat.lib.util.NewPodProjectScaffolderTest" --no-daemon +.\gradlew.bat :dat-gateway:test --tests "io.shinhanlife.dat.mcg.presentation.ScaffoldingControllerToolDraftTest" --no-daemon +git diff --check +``` + +Expected: both focused test classes pass and `git diff --check` reports no whitespace errors. diff --git a/docs/superpowers/specs/2026-09-09-new-pod-module-workspace-scaffolder-design.md b/docs/superpowers/specs/2026-09-09-new-pod-module-workspace-scaffolder-design.md new file mode 100644 index 00000000..27f2de68 --- /dev/null +++ b/docs/superpowers/specs/2026-09-09-new-pod-module-workspace-scaffolder-design.md @@ -0,0 +1,57 @@ +# Pod Module New Workspace Scaffolder 설계 + +## 목표 + +기존 Pod Module Scaffold(`PodScaffolder`, `/api/v1/scaffold/pod`, 기존 화면)을 변경하지 않는다. +Scaffold 화면에 별도 `Pod Module New` 기능을 추가하여, 사용자가 입력한 Pod 모듈명을 기준으로 독립 Tool Pod 프로젝트를 `C:\eGovFrameDev-4.3.1-64bit\workspace` 아래에 생성한다. + +## 생성 구조 + +`dat-was-payment` 입력 시 다음 독립 프로젝트를 생성한다. + +```text +C:\eGovFrameDev-4.3.1-64bit\workspace\dat-was-payment\ + settings.gradle + build.gradle + README.md + Dockerfile + docker-compose.yml + k8s\ + src\main\java\... + src\main\resources\... +``` + +입력 모듈명은 저장소 루트명, Gradle 루트 프로젝트명, Spring application name에 동일하게 사용한다. +생성 프로젝트는 `../dat-lib-datmt`를 Gradle composite build로 포함하고, `io.shinhanlife:dat-lib-datmt:0.0.1-SNAPSHOT` 좌표를 `:dat-was-lib`로 치환한다. + +## 새 생성 경로 + +- 신규 `NewPodProjectScaffolder`가 독립 프로젝트 파일을 생성한다. +- 신규 Controller endpoint가 module name과 사용자가 입력한 서비스 포트를 전달한다. +- Scaffold 화면에는 기존 Pod Module 탭과 별개인 `Pod Module New` 영역을 둔다. +- workspace 경로는 사용자 입력값을 사용하며 기본값은 `C:\eGovFrameDev-4.3.1-64bit\workspace`이다. +- 존재하는 프로젝트 경로에는 생성하지 않고 명확한 오류를 반환한다. + +## 기존 기능 보호 + +다음 기존 파일과 동작은 변경하지 않는다. + +- `PodScaffolder` +- `/api/v1/scaffold/pod` +- 기존 Pod Module 화면과 기존 Pod 모듈 + +## 생성 파일의 주요 설정 + +- Spring Boot 3.5.11, Java 21 +- 공통 라이브러리 composite build 연결 +- `server.port`는 사용자가 입력한 서비스 포트를 기본값으로 사용하고, 환경변수 `PORT`로 덮어쓸 수 있다. +- Docker Compose 서비스명은 모듈명의 `dat-was-` 접두사를 `was-`로 치환한다. +- Tool manifest는 새 Pod의 라우팅 함수만 포함한다. +- README는 공통 라이브러리 위치, 실행 방법, 입력 포트 설정을 안내한다. + +## 검증 + +1. 신규 Scaffolder 단위 테스트로 생성 파일, composite build 설정, 기존 경로 거부를 검증한다. +2. Controller 테스트로 신규 endpoint의 정상 생성과 중복 경로 오류를 검증한다. +3. 임시 workspace에 생성한 Pod의 `compileJava`를 실행해 `../dat-lib-datmt` 의존성 해석을 확인한다. +4. 기존 `PodScaffolder` 관련 테스트를 실행해 기존 생성 경로가 바뀌지 않았음을 확인한다.