diff --git a/AGENTS.md b/AGENTS.md index d6f570b..f191cf2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,6 +14,8 @@ The platform_web visual direction is a unified magical-girl crystal-moonlight ga Creating a server instance must require only the game plugin type and the server name. Do not require the owner to pick a deployment target, run endpoint, or runtime profile at creation time: the run executor does not exist yet at that point, so any such field can only be filled incorrectly. +This is a prerequisite rule, not a ban on the create wizard. The create wizard may collect plugin-declared deployment mode, game configuration, and startup fields before submit as long as deployment target, run endpoint, and runtime profile selection are not creation prerequisites. + The binding between a server instance and its run endpoint is established when the generated run registers itself, not by pre-selecting an existing endpoint in the creation form. Deployment target and runtime profile selection may exist only as optional actions on an already-created instance, never as creation prerequisites. ## Project Roots diff --git a/platform/api/routes.md b/platform/api/routes.md index 08f32e3..1bac050 100644 --- a/platform/api/routes.md +++ b/platform/api/routes.md @@ -137,7 +137,7 @@ Artifact bridge execution returns safe metadata and platform content routes only ## Implemented Server Lifecycle Actions -- `POST /api/v1/server-instances/workflows/create`: accepts `ServerLifecycleCreateRequest`. Creation requires only `pluginId` and `name`; it creates an unbound instance without dispatching an install job. Runtime binding and optional deployment settings are configured after creation. Legacy `runEndpointId` and `deploymentTargetId` inputs remain accepted for compatible programmatic flows, but are never prerequisites for creation and do not select a distribution builder. +- `POST /api/v1/server-instances/workflows/create`: accepts `ServerLifecycleCreateRequest`. Creation starts from `pluginId` and `name`, and may include the create-wizard deployment definition such as deployment mode, plugin create inputs, server root, or custom start command. The browser must not require a deployment target, run endpoint, or runtime profile at creation; those bindings remain post-creation/runtime-registration concerns. Legacy `runEndpointId` and `deploymentTargetId` inputs remain accepted for compatible programmatic flows, but are never prerequisites for creation and do not select a distribution builder. - `POST /api/v1/server-instances/{id}/start`: accept `ServerLifecycleCommandRequest`, validate state/config version/run capability, and queue a `process.start` job using `ServerLifecycleResponse`. - `POST /api/v1/server-instances/{id}/stop`: accept `ServerLifecycleCommandRequest`, validate state/config version/run capability, and queue a `process.stop` job using `ServerLifecycleResponse`. diff --git a/platform_web/api/contracts.md b/platform_web/api/contracts.md index 5c0e03e..b24d041 100644 --- a/platform_web/api/contracts.md +++ b/platform_web/api/contracts.md @@ -22,7 +22,7 @@ Normal browser login uses the platform's HttpOnly SameSite cookie and `credentia ## Server Management Workflows -- `createServerWorkflow` posts `ServerLifecycleCreateRequest` with a declared `profileKey` and initial logical `bindings` to `/server-instances/workflows/create`, and receives the accepted instance plus install job only after binding validation. +- `createServerWorkflow` posts `ServerLifecycleCreateRequest` with the create-wizard deployment definition to `/server-instances/workflows/create`, including deployment mode, plugin create inputs, and custom startup fields when provided. It does not require deployment target, run endpoint, or runtime profile selection during creation. - `getServerRuntimeBinding` reads `/server-instances/{id}/runtime-binding`; `updateServerRuntimeBinding` patches the selected profile and logical refs. Responses contain only profile metadata, logical key names, configured/secret-backed flags, missing keys, and safe reasons. They never contain stored refs or secret values. - `startServerInstance` and `stopServerInstance` post `ServerLifecycleCommandRequest` with the current config version and receive the lifecycle job response. - `listServerAdministratorCandidates`, `addServerAdministrator`, and `removeServerAdministrator` call server membership endpoints so server owners can invite or remove active non-platform-admin server administrators. diff --git a/platform_web/components/ServerDeploymentWorkflow.test.tsx b/platform_web/components/ServerDeploymentWorkflow.test.tsx index 0b4d5ff..9c0693e 100644 --- a/platform_web/components/ServerDeploymentWorkflow.test.tsx +++ b/platform_web/components/ServerDeploymentWorkflow.test.tsx @@ -6,7 +6,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import type { GamePluginResponse } from "../api/types"; import { defaultServerCreateForm } from "../contracts/serverManagement"; -import { minimalServerCreateRequestFromForm } from "../schemas/serverManagement"; +import { serverCreateRequestFromForm } from "../schemas/serverManagement"; import { ServerDeploymentWorkflow } from "./ServerDeploymentWorkflow"; const plugin: GamePluginResponse = { @@ -48,14 +48,14 @@ afterEach(async () => { }); describe("ServerDeploymentWorkflow", () => { - it("submits plugin type and server name as a minimal create request", async () => { + it("submits deployment mode and matching startup inputs from the create wizard", async () => { container = document.createElement("div"); document.body.append(container); root = createRoot(container); const initialForm = defaultServerCreateForm([plugin], []); - let submitted: ReturnType | undefined; + let submitted: ReturnType | undefined; const onSubmit = vi.fn(async (form: typeof initialForm) => { - submitted = minimalServerCreateRequestFromForm(form, 17); + submitted = serverCreateRequestFromForm(form, 17); }); await act(async () => { @@ -74,29 +74,54 @@ describe("ServerDeploymentWorkflow", () => { expect(container.querySelector('select[name="pluginId"]')).not.toBeNull(); expect(container.querySelector('input[name="name"]')).not.toBeNull(); - for (const field of ["deploymentTargetId", "runEndpointId", "profileKey", "serverRoot", "startCommand"]) { + for (const field of ["deploymentTargetId", "runEndpointId", "profileKey", "serverRoot", "startCommand", "deploymentMode"]) { expect(container.querySelector(`[name="${field}"]`)).toBeNull(); } expect(container.textContent).not.toContain("运行连接设置"); - expect(container.querySelector('select[name="deploymentMode"]')).toBeNull(); const nameInput = container.querySelector('input[name="name"]'); if (!nameInput) throw new Error("server name input not found"); await act(async () => { - setInputValue(nameInput, "Minimal Runtime Server"); + setInputValue(nameInput, "Custom Runtime Server"); }); await submitWorkflow(container); - expect(container.textContent).toContain("本次只创建服务器记录"); - expect(container.textContent).toContain("Minimal Runtime Server"); + expect(container.textContent).toContain("选择这台服务器的创建方式"); + expect(container.textContent).toContain("自定义启动方式"); + + await clickButtonByText(container, "自定义启动方式"); + await submitWorkflow(container); + expect(container.querySelector('input[name="startCommand"]')).not.toBeNull(); + expect(container.querySelector('[name="deploymentTargetId"]')).toBeNull(); + expect(container.querySelector('[name="runEndpointId"]')).toBeNull(); + expect(container.querySelector('[name="profileKey"]')).toBeNull(); + + const rootInput = container.querySelector('input[name="serverRoot"]'); + const startInput = container.querySelector('input[name="startCommand"]'); + if (!rootInput || !startInput) throw new Error("custom startup inputs not found"); + await act(async () => { + setInputValue(rootInput, "/srv/custom-runtime"); + setInputValue(startInput, "./start-runtime.sh"); + }); + + await submitWorkflow(container); + expect(container.textContent).toContain("本次保存创建向导配置"); + expect(container.textContent).toContain("自定义启动方式"); + expect(container.textContent).toContain("将替换"); await submitWorkflow(container); expect(onSubmit).toHaveBeenCalledTimes(1); - expect(submitted).toEqual({ - id: "server-minimal-runtime-server-17", + expect(submitted).toMatchObject({ + id: "server-custom-runtime-server-17", pluginId: "game.runtime", - name: "Minimal Runtime Server", - idempotencyKey: "web:create:server-minimal-runtime-server-17:17" + name: "Custom Runtime Server", + idempotencyKey: "web:create:server-custom-runtime-server-17:17", + runEndpointId: undefined, + deployment: { + mode: "custom-command", + serverRoot: "/srv/custom-runtime", + startCommand: "./start-runtime.sh" + } }); }); }); @@ -109,6 +134,14 @@ async function submitWorkflow(target: HTMLElement) { }); } +async function clickButtonByText(target: HTMLElement, text: string) { + const button = Array.from(target.querySelectorAll("button")).find((item) => item.textContent?.includes(text)); + if (!button) throw new Error(`button not found: ${text}`); + await act(async () => { + button.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); + }); +} + function setInputValue(input: HTMLInputElement, value: string) { const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set; setter?.call(input, value); diff --git a/platform_web/components/ServerDeploymentWorkflow.tsx b/platform_web/components/ServerDeploymentWorkflow.tsx index ceec615..015f1d7 100644 --- a/platform_web/components/ServerDeploymentWorkflow.tsx +++ b/platform_web/components/ServerDeploymentWorkflow.tsx @@ -32,13 +32,14 @@ export function ServerDeploymentWorkflow({ open, kind, plugins, endpoints, initi const needsTargetSelection = kind === "edit" && !initialForm.runEndpointId; const selectedTargetID = form.runEndpointId; const workflowSteps = kind === "create" - ? [{ label: "基本信息", icon: Compass }, { label: "确认", icon: Rocket }] + ? [{ label: "基本信息", icon: Compass }, { label: "部署方式", icon: ServerCog }, { label: "相关配置", icon: FolderCog }, { label: "确认", icon: Rocket }] : needsTargetSelection ? [{ label: "选择运行节点", icon: Compass }, { label: "相关配置", icon: FolderCog }, { label: "确认", icon: Rocket }] : [{ label: "相关配置", icon: FolderCog }, { label: "确认", icon: Rocket }]; const pluginStep = kind === "create" ? 0 : -1; const targetStep = needsTargetSelection ? 0 : -1; - const configurationStep = kind === "create" ? -1 : needsTargetSelection ? 1 : 0; + const modeStep = kind === "create" ? 1 : -1; + const configurationStep = kind === "create" ? 2 : needsTargetSelection ? 1 : 0; const reviewStep = workflowSteps.length - 1; useEffect(() => { @@ -70,6 +71,7 @@ export function ServerDeploymentWorkflow({ open, kind, plugins, endpoints, initi function canContinue() { if (step === pluginStep) return Boolean(form.pluginId) && Boolean(form.name.trim()); + if (step === modeStep) return Boolean(form.deploymentMode); if (step === targetStep) return Boolean(form.runEndpointId); if (step === configurationStep) { if (isScum && form.deploymentMode === "guided-install" && !form.serverRoot.trim() && !deployment?.serverRootConfigured) return false; @@ -110,13 +112,18 @@ export function ServerDeploymentWorkflow({ open, kind, plugins, endpoints, initi const protectedState = (nextValue: string, configured: boolean) => nextValue.trim() ? "将替换" : configured ? "保持已配置" : "未配置"; const actionLabel = kind === "create" ? "创建服务器" : "保存部署设置"; - return + return
void submit(event)} aria-label={kind === "create" ? "创建服务器部署向导" : "编辑服务器部署向导"}>
    {workflowSteps.map((item, index) => { const Icon = item.icon; return
  1. {index < step ? : }{index + 1}. {item.label}
  2. ; })}
{step === pluginStep &&
-
创建服务器记录插件类型和服务器名称是创建时仅有的必填信息。
按需补充配置创建后可在详情页选择运行预设、逻辑绑定和部署方式。
平台构建专属 Run平台在自有构建器中打包,不需要你先选择部署目标。
+
创建基础信息插件决定下一步显示哪些部署方式和游戏参数。
配置启动项新建安装、接管已有和自定义启动分别填写自己的字段。
平台构建专属 Run平台在自有构建器中打包,不需要你先选择部署目标。
} + {step === modeStep &&

选择这台服务器的创建方式;下一步只显示该方式需要的启动项。

{isScum &&
SCUM 受控链路Run 会按预检 → 安装或扫描 → 配置映射 → 健康验证执行;目录本身不代表安装完成。
}
+ setForm((current) => ({ ...current, deploymentMode: "guided-install" }))} /> + setForm((current) => ({ ...current, deploymentMode: "existing-server" }))} /> + setForm((current) => ({ ...current, deploymentMode: "custom-command" }))} /> +
} {step === targetStep &&
这个草稿尚未绑定运行节点只需在这里补选一次。已绑定服务器编辑时会直接进入相关配置,不会重复要求选择目标。
@@ -138,12 +145,14 @@ export function ServerDeploymentWorkflow({ open, kind, plugins, endpoints, initi {form.deploymentMode === "existing-server" && } {form.deploymentMode === "custom-command" &&
高级启动设置

只有自定义启动器需要这些设置。执行目录留空时,节点以服务器目录执行。

}
} - {step === reviewStep && (kind === "create" ?
插件类型{pluginLabel(selectedPlugin, form.pluginId)}
服务器名称{form.name.trim() || "未填写"}
本次只创建服务器记录创建后可在详情页按需补充运行配置和部署设置,再生成并启动专属 Run。
:
目标{endpointLabel(endpoints.find((endpoint) => endpoint.id === selectedTargetID), selectedTargetID)}
部署方式{form.deploymentMode === "guided-install" ? "新建并安装" : form.deploymentMode === "existing-server" ? "接管已有服务器" : "自定义启动方式"}
{form.deploymentMode === "guided-install" ? "安装目录" : form.deploymentMode === "existing-server" ? "已有服务器目录" : "服务器目录"}{protectedState(form.serverRoot, Boolean(deployment?.serverRootConfigured))}
{form.deploymentMode === "custom-command" && <>
启动命令{protectedState(form.startCommand, Boolean(deployment?.startCommandConfigured))}
执行目录{protectedState(form.workingDirectory, Boolean(deployment?.workingDirectoryConfigured))}
}{form.deploymentMode === "guided-install" &&
游戏配置{Object.keys(form.createInputs).length ? `${Object.keys(form.createInputs).length} 项已准备` : "使用插件默认值"}
}{isScum &&
完成条件安装/扫描、映射、验证全部通过
}
本次只保存部署设置{form.deploymentMode === "existing-server" ? "Run 将先预检现有目录;不会重装或覆盖已有游戏配置。" : "保存后由平台保留受保护部署设置;路径和命令仅在本次显式展示后可见。"}
)} + {step === reviewStep && (kind === "create" ?
插件类型{pluginLabel(selectedPlugin, form.pluginId)}
服务器名称{form.name.trim() || "未填写"}
部署方式{form.deploymentMode === "guided-install" ? "新建并安装" : form.deploymentMode === "existing-server" ? "接管已有服务器" : "自定义启动方式"}
{form.deploymentMode === "guided-install" ? "安装目录" : form.deploymentMode === "existing-server" ? "已有服务器目录" : "服务器目录"}{protectedState(form.serverRoot, false)}
{form.deploymentMode === "custom-command" && <>
启动命令{protectedState(form.startCommand, false)}
执行目录{protectedState(form.workingDirectory, false)}
}{form.deploymentMode === "guided-install" &&
游戏配置{Object.keys(form.createInputs).length ? `${Object.keys(form.createInputs).length} 项已准备` : "使用插件默认值"}
}{isScum &&
完成条件安装/扫描、映射、验证全部通过
}
本次保存创建向导配置保存后生成并启动专属 Run;部署执行会按这里选择的方式和启动项进行。
:
目标{endpointLabel(endpoints.find((endpoint) => endpoint.id === selectedTargetID), selectedTargetID)}
部署方式{form.deploymentMode === "guided-install" ? "新建并安装" : form.deploymentMode === "existing-server" ? "接管已有服务器" : "自定义启动方式"}
{form.deploymentMode === "guided-install" ? "安装目录" : form.deploymentMode === "existing-server" ? "已有服务器目录" : "服务器目录"}{protectedState(form.serverRoot, Boolean(deployment?.serverRootConfigured))}
{form.deploymentMode === "custom-command" && <>
启动命令{protectedState(form.startCommand, Boolean(deployment?.startCommandConfigured))}
执行目录{protectedState(form.workingDirectory, Boolean(deployment?.workingDirectoryConfigured))}
}{form.deploymentMode === "guided-install" &&
游戏配置{Object.keys(form.createInputs).length ? `${Object.keys(form.createInputs).length} 项已准备` : "使用插件默认值"}
}{isScum &&
完成条件安装/扫描、映射、验证全部通过
}
本次只保存部署设置{form.deploymentMode === "existing-server" ? "Run 将先预检现有目录;不会重装或覆盖已有游戏配置。" : "保存后由平台保留受保护部署设置;路径和命令仅在本次显式展示后可见。"}
)}
{step < reviewStep ? : }
; } +function ModeOption({ active, title, copy, onClick }: { active: boolean; title: string; copy: string; onClick: () => void }) { return ; } + function GuidedInstallPlan({ pluginName, isScum }: { pluginName: string; isScum: boolean }) { const steps = isScum ? [ { icon: ScanSearch, title: "预检目录与端口", copy: "确认安装目录可用、节点兼容且端口可绑定。" }, diff --git a/platform_web/pages/ConsolePages.test.tsx b/platform_web/pages/ConsolePages.test.tsx index da8a3c2..a7f40cf 100644 --- a/platform_web/pages/ConsolePages.test.tsx +++ b/platform_web/pages/ConsolePages.test.tsx @@ -168,7 +168,7 @@ describe("first-party console pages", () => { expect(serversPageSource).toContain("旧 run 会话已失效"); }); - it("separates minimal creation from post-create deployment editing without exposing protected inputs", () => { + it("keeps deployment startup inputs in the create wizard without exposing protected values", () => { expect(serversPageSource).toContain(" { expect(serverDeploymentWorkflowSource).toContain("当前平台尚未提供 SCUM 服务端的受控升级任务"); expect(serverDeploymentWorkflowSource).toContain("已绑定服务器编辑时会直接进入相关配置"); expect(serverDeploymentWorkflowSource).toContain("可在此调整部署方式;不会重复要求选择已绑定的运行节点"); - expect(serversPageSource).toContain('onNavigate("serverDetail", { serverId: result.instance.id })'); + expect(serversPageSource).toContain('onNavigate("serverDetail", { serverId: result.instance.id, routeKey: "run-builder" })'); expect(serverDetailPageSource).toContain("运行配置绑定"); expect(serverDetailPageSource).toContain('type={field.sensitive ? "password" : "text"}'); expect(serverDeploymentWorkflowSource).toContain("显示已保存配置"); expect(serverDeploymentWorkflowSource).toContain("revealSavedInputs"); expect(serversPageSource).toContain("revealServerDeployment"); expect(serverDetailPageSource).toContain("最近 Run 调度"); - expect(serversPageSource).toContain("minimalServerCreateRequestFromForm(nextForm)"); + expect(serversPageSource).toContain("serverCreateRequestFromForm(nextForm)"); expect(serverDeploymentWorkflowSource).not.toContain('name="id"'); expect(serverDeploymentWorkflowSource).not.toContain("实例 ID"); for (const forbidden of ["secret://", "/Users/", "/var/run/", "unix://", "tcp://"]) { @@ -207,7 +207,7 @@ describe("first-party console pages", () => { } }); - it("keeps create-only controls structurally minimal", () => { + it("keeps create target and profile controls out while preserving deployment steps", () => { expect(serverDeploymentWorkflowSource).not.toContain('name="deploymentTargetId"'); expect(serverDeploymentWorkflowSource).not.toContain("请选择部署目标"); expect(serverDeploymentWorkflowSource).not.toContain("saveAsDraft"); @@ -216,9 +216,12 @@ describe("first-party console pages", () => { expect(serverDeploymentWorkflowSource).toContain("await onSubmit(form)"); expect(serverDeploymentWorkflowSource).not.toContain('kind === "create" &&