feat(scum): add file config workbench

This commit is contained in:
npc0-hue
2026-07-28 14:43:24 +08:00
parent e739dd9c79
commit e4ef4024a8
22 changed files with 397 additions and 109 deletions
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-07-28
@@ -0,0 +1,43 @@
## Context
The SCUM plugin already declares runtime config mappings and log sources, while Platform exposes safe configuration read/diff/approval APIs and a scoped file-operation dispatcher. Its current plugin page is an operations overview with semantic logs; this does not match the operator's configuration-first workflow.
## Goals / Non-Goals
**Goals:**
- Make plugin-owned logical file declarations the single catalog for SCUM configuration and log file scopes.
- Model safe configuration fields separately from the raw INI content, then compose changes into the existing Platform diff-preview and approval flow.
- Present declared log files and their Platform log-stream contents in the same workbench.
- Redirect historical SCUM page keys to the default `files-config` page without breaking shared plugin routing.
**Non-Goals:**
- Arbitrary filesystem browsing, path entry, terminals, FTP/rsync controls, raw secrets, or host/socket exposure.
- Raw text as the default configuration editor, write access for unknown fields, or a new run-side protocol.
- Changing global server-list behavior or non-SCUM plugin pages.
## Decisions
1. Add `fileWorkspace` to the plugin manifest/runtime projection. It contains safe logical directories/files and modeled fields, rather than host paths or unbounded schemas. This makes a page contract auditable and keeps ownership with the plugin.
2. Give every modeled field an owning logical file key, Chinese operational metadata, and constrained control details. The frontend uses only these fields to compose proposed INI content; unmapped lines remain visible as read-only field records.
3. Reuse `GET /config`, config diff preview, and config approval for the declared primary config file. Log-file selection maps declared logical files to existing Platform log streams; no new direct file-read API is introduced.
4. Move SCUM from `operations` to `files-config`; the route resolver redirects old `overview`, `config`, `logs`, and `operations` keys only for `game.scum`. Other plugins keep their declared page keys unchanged.
5. Build page content from existing shared console form/list/diff classes. No page-owned surface system or global decoration is added.
## Risks / Trade-offs
- [A config document may contain extra keys] → preserve them in the proposed content and show them as read-only, unmodeled rows.
- [Existing installations still link old route keys] → normalize those keys in the common resolver before page lookup.
- [A declared log stream is absent] → show an explicit unavailable state and never fall back to a filesystem path.
- [INI parsing has formatting limits] → patch only declared simple key/value fields and rely on Platform preview before approval.
## Migration Plan
1. Ship manifest and Platform declaration validation/projection with the SCUM `files-config` page.
2. Deploy the frontend route normalization and workbench; old SCUM links resolve to `files-config`.
3. Rollback by restoring the previous manifest page declaration; no persisted migration or write protocol needs reversal.
## Open Questions
None. The first field catalog uses existing SCUM deployment mappings and can be expanded through manifest declarations later.
@@ -0,0 +1,25 @@
## Why
SCUM operators currently reach separate overview, configuration, and semantic-log surfaces, even though their routine operational work begins with declared files and safe, modeled settings. The default SCUM work surface needs to make configuration actionable without revealing host paths or turning raw text editing into the normal workflow.
## What Changes
- Replace the SCUM overview-first experience with a unified `文件与配置` workbench whose default scope is the plugin-declared configuration directory.
- Add plugin-declared logical file directories/files and a modeled configuration-field catalog, including Chinese labels, help, control metadata, constraints, defaults, restart impact, and owning file.
- Reuse Platform-mediated file requests and the existing configuration diff-preview/approval/write flow so modeled changes are previewed before dispatch.
- Expose logs as declared log files inside the same workbench, with separate safe log-file scope; retain unknown configuration fields as read-only information.
- Safely migrate legacy SCUM overview/config/log routes to the new workbench.
## Capabilities
### New Capabilities
- `scum-file-config-workbench`: Declared SCUM file scopes, modeled configuration editing, log-file viewing, and safe route migration.
### Modified Capabilities
- `config-write-and-file-dispatch`: File dispatch and config approval now consume declared logical directories/files and field ownership metadata.
- `scum-operations`: SCUM's primary operator surface changes from an overview to the file-and-configuration workbench.
## Impact
- Affects the SCUM plugin manifest/declarations, Platform plugin validation and safe file/config DTO handling, and SCUM frontend contracts/routes/components.
- Reuses `files.request`, existing server configuration diff/approval/write APIs, and the SCUM runtime profile without expanding filesystem or remote-access authority.
@@ -0,0 +1,8 @@
## ADDED Requirements
### Requirement: Declared field ownership constrains config editing
Platform-projected plugin declarations SHALL associate each modeled configuration field with a declared logical file key so the frontend can invoke existing diff and approval APIs without accepting arbitrary file targets.
#### Scenario: Preview uses an owning declared config file
- **WHEN** a modeled field change is previewed
- **THEN** the frontend MUST use the field's declared logical file key and MUST NOT accept a host path or user-supplied target key
@@ -0,0 +1,37 @@
## ADDED Requirements
### Requirement: SCUM declares safe file workspace catalog
The SCUM plugin SHALL declare logical configuration and log directories/files and a bounded modeled configuration-field catalog without raw host paths, secrets, credentials, sockets, or arbitrary schemas.
#### Scenario: Default configuration scope is declared
- **WHEN** an authorized operator opens the SCUM file-and-configuration page
- **THEN** the page MUST select the plugin-declared configuration directory and list only its declared logical files
#### Scenario: Unsafe declaration is rejected
- **WHEN** a plugin manifest includes an absolute path, traversal key, secret-like value, or unsupported field control
- **THEN** Platform MUST reject the manifest before it is registered
### Requirement: Modeled SCUM fields are configuration-first
The SCUM workbench SHALL render modeled fields with Chinese label, explanation, input type, constraint, default, restart impact, and owning file, and SHALL keep unmodeled fields read-only.
#### Scenario: Operator changes a modeled field
- **WHEN** an operator changes a declared editable field
- **THEN** the workbench MUST compose only that field into the owning configuration file and require a diff preview before approval
#### Scenario: Unknown configuration is encountered
- **WHEN** the loaded configuration includes a key outside the declared field catalog
- **THEN** the workbench MUST show it as read-only and MUST NOT present it as a safe editable control
### Requirement: Logs are file scope in the same workbench
The SCUM workbench SHALL present plugin-declared log files as a log-file scope and display their Platform-mediated stream content.
#### Scenario: Operator opens a declared log file
- **WHEN** an operator selects a declared SCUM log file
- **THEN** the page MUST query only its declared Platform log stream and show readable file content without host-path disclosure
### Requirement: Legacy SCUM pages migrate safely
The frontend SHALL migrate legacy SCUM overview, config, logs, and operations page keys to the `files-config` page while leaving non-SCUM routing unchanged.
#### Scenario: Legacy SCUM operations link is opened
- **WHEN** a user opens a SCUM plugin URL with the legacy `operations` key
- **THEN** the frontend MUST resolve it to the declared `files-config` workbench for the same server context
@@ -0,0 +1,8 @@
## ADDED Requirements
### Requirement: SCUM primary surface is file and configuration workbench
The SCUM plugin SHALL expose `files-config` as its primary operator page rather than an overview-first or semantic-log-dashboard page.
#### Scenario: SCUM plugin page opens
- **WHEN** a user opens the declared primary SCUM page for a server instance
- **THEN** the page MUST prioritize declared configuration files and modeled configuration controls before log-file browsing
@@ -0,0 +1,16 @@
## 1. Plugin and Platform declaration contract
- [x] 1.1 Add safe logical file-directory/file and modeled-field contracts to plugin manifests, Platform domain/DTO projections, copying, and validation.
- [x] 1.2 Declare SCUM configuration/log files and bounded Chinese configuration field metadata, then remove legacy SCUM overview page declarations.
- [x] 1.3 Add focused Platform validation/projection tests for safe declaration and field ownership behavior.
## 2. SCUM file and configuration workbench
- [x] 2.1 Add frontend declaration contracts and resolver for the SCUM `files-config` default workbench and legacy route migration.
- [x] 2.2 Build the shared-theme file list, modeled configuration form, read-only unknown-field list, preview diff, and approval experience.
- [x] 2.3 Add declared log-file scope and Platform log stream content reading without path exposure.
## 3. Verification
- [x] 3.1 Add/update focused backend, plugin, route, contract, and component tests.
- [x] 3.2 Run strict OpenSpec validation, relevant backend/plugin/frontend checks, and scripts/check-structure.sh.
+30
View File
@@ -316,6 +316,25 @@ type GamePluginPage struct {
BridgeActions []string
}
// PluginFileWorkspace is a bounded, logical catalog for a plugin-owned files
// workbench. Keys are logical identifiers, never host paths.
type PluginFileWorkspace struct {
DefaultDirectoryKey string
Directories []PluginLogicalDirectory
Files []PluginLogicalFile
ConfigFields []PluginConfigField
}
type PluginLogicalDirectory struct{ Key, Label, Scope string }
type PluginLogicalFile struct {
Key, DirectoryKey, Label, Kind, StreamKey string
Editable bool
}
type PluginConfigField struct {
Key, FileKey, ConfigKey, Label, Description, Control, DefaultValue, RestartImpact string
Minimum, Maximum int
}
type GamePluginBridge struct {
Actions []string
}
@@ -595,6 +614,7 @@ type GamePluginManifest struct {
Permissions []string
Actions PluginLifecycleActions
Pages []GamePluginPage
FileWorkspace PluginFileWorkspace
AI GamePluginManifestAI
ProductionLifecycle GamePluginProductionLifecycle
RemoteAccess GamePluginRemoteAccess
@@ -624,6 +644,7 @@ type GamePlugin struct {
LifecycleActions PluginLifecycleActions
BridgeActions []string
Pages []GamePluginPage
FileWorkspace PluginFileWorkspace
Tags []string
AIPurposes []string
ProductionLifecycle GamePluginProductionLifecycle
@@ -1611,6 +1632,7 @@ func CopyGamePlugin(plugin GamePlugin) GamePlugin {
plugin.CreateFields = CopyPluginCreateFields(plugin.CreateFields)
plugin.BridgeActions = CopyStringSlice(plugin.BridgeActions)
plugin.Pages = CopyGamePluginPageSlice(plugin.Pages)
plugin.FileWorkspace = CopyPluginFileWorkspace(plugin.FileWorkspace)
plugin.Tags = CopyStringSlice(plugin.Tags)
plugin.AIPurposes = CopyStringSlice(plugin.AIPurposes)
plugin.ProductionLifecycle = CopyGamePluginProductionLifecycle(plugin.ProductionLifecycle)
@@ -1662,6 +1684,7 @@ func CopyGamePluginManifest(manifest GamePluginManifest) GamePluginManifest {
manifest.Capabilities = CopyStringSlice(manifest.Capabilities)
manifest.Permissions = CopyStringSlice(manifest.Permissions)
manifest.Pages = CopyGamePluginPageSlice(manifest.Pages)
manifest.FileWorkspace = CopyPluginFileWorkspace(manifest.FileWorkspace)
manifest.AI.Purposes = CopyStringSlice(manifest.AI.Purposes)
manifest.ProductionLifecycle = CopyGamePluginProductionLifecycle(manifest.ProductionLifecycle)
manifest.RemoteAccess = CopyGamePluginRemoteAccess(manifest.RemoteAccess)
@@ -1670,6 +1693,13 @@ func CopyGamePluginManifest(manifest GamePluginManifest) GamePluginManifest {
return manifest
}
func CopyPluginFileWorkspace(workspace PluginFileWorkspace) PluginFileWorkspace {
workspace.Directories = append([]PluginLogicalDirectory(nil), workspace.Directories...)
workspace.Files = append([]PluginLogicalFile(nil), workspace.Files...)
workspace.ConfigFields = append([]PluginConfigField(nil), workspace.ConfigFields...)
return workspace
}
func CopyPluginCreateFields(fields []PluginCreateField) []PluginCreateField {
if fields == nil {
return nil
+66
View File
@@ -183,6 +183,38 @@ type GamePluginPageBody struct {
BridgeActions []string `json:"bridgeActions,omitempty"`
}
type PluginLogicalDirectoryBody struct {
Key string `json:"key"`
Label string `json:"label"`
Scope string `json:"scope"`
}
type PluginLogicalFileBody struct {
Key string `json:"key"`
DirectoryKey string `json:"directoryKey"`
Label string `json:"label"`
Kind string `json:"kind"`
StreamKey string `json:"streamKey,omitempty"`
Editable bool `json:"editable,omitempty"`
}
type PluginConfigFieldBody struct {
Key string `json:"key"`
FileKey string `json:"fileKey"`
ConfigKey string `json:"configKey"`
Label string `json:"label"`
Description string `json:"description"`
Control string `json:"control"`
Minimum int `json:"minimum,omitempty"`
Maximum int `json:"maximum,omitempty"`
DefaultValue string `json:"defaultValue,omitempty"`
RestartImpact string `json:"restartImpact"`
}
type PluginFileWorkspaceBody struct {
DefaultDirectoryKey string `json:"defaultDirectoryKey"`
Directories []PluginLogicalDirectoryBody `json:"directories"`
Files []PluginLogicalFileBody `json:"files"`
ConfigFields []PluginConfigFieldBody `json:"configFields"`
}
type GamePluginBridgeBody struct {
Actions []string `json:"actions"`
}
@@ -303,6 +335,7 @@ type GamePluginManifestBody struct {
Permissions []string `json:"permissions"`
Actions PluginLifecycleActionsBody `json:"actions"`
Pages []GamePluginPageBody `json:"pages,omitempty"`
FileWorkspace PluginFileWorkspaceBody `json:"fileWorkspace,omitempty"`
AI GamePluginManifestAIBody `json:"ai,omitempty"`
ProductionLifecycle GamePluginProductionLifecycleBody `json:"productionLifecycle"`
RemoteAccess GamePluginRemoteAccessBody `json:"remoteAccess,omitempty"`
@@ -332,6 +365,7 @@ type GamePluginCreateRequest struct {
LifecycleActions PluginLifecycleActionsBody `json:"lifecycleActions,omitempty"`
BridgeActions []string `json:"bridgeActions,omitempty"`
Pages []GamePluginPageBody `json:"pages,omitempty"`
FileWorkspace PluginFileWorkspaceBody `json:"fileWorkspace,omitempty"`
Tags []string `json:"tags,omitempty"`
AIPurposes []string `json:"aiPurposes,omitempty"`
ProductionLifecycle GamePluginProductionLifecycleBody `json:"productionLifecycle,omitempty"`
@@ -358,6 +392,7 @@ type GamePluginResponse struct {
LifecycleActions PluginLifecycleActionsBody `json:"lifecycleActions"`
BridgeActions []string `json:"bridgeActions"`
Pages []GamePluginPageBody `json:"pages"`
FileWorkspace PluginFileWorkspaceBody `json:"fileWorkspace,omitempty"`
Tags []string `json:"tags"`
AIPurposes []string `json:"aiPurposes"`
ProductionLifecycle GamePluginProductionLifecycleBody `json:"productionLifecycle"`
@@ -933,6 +968,7 @@ func (request GamePluginManifestRegistrationRequest) ToDomain() domain.GamePlugi
Permissions: domain.CopyStringSlice(request.Manifest.Permissions),
Actions: request.Manifest.Actions.ToDomain(),
Pages: pagesToDomain(request.Manifest.Pages),
FileWorkspace: fileWorkspaceToDomain(request.Manifest.FileWorkspace),
AI: request.Manifest.AI.ToDomain(),
ProductionLifecycle: request.Manifest.ProductionLifecycle.ToDomain(),
RemoteAccess: request.Manifest.RemoteAccess.ToDomain(),
@@ -942,6 +978,34 @@ func (request GamePluginManifestRegistrationRequest) ToDomain() domain.GamePlugi
}
}
func fileWorkspaceToDomain(body PluginFileWorkspaceBody) domain.PluginFileWorkspace {
workspace := domain.PluginFileWorkspace{DefaultDirectoryKey: body.DefaultDirectoryKey}
for _, item := range body.Directories {
workspace.Directories = append(workspace.Directories, domain.PluginLogicalDirectory{Key: item.Key, Label: item.Label, Scope: item.Scope})
}
for _, item := range body.Files {
workspace.Files = append(workspace.Files, domain.PluginLogicalFile{Key: item.Key, DirectoryKey: item.DirectoryKey, Label: item.Label, Kind: item.Kind, StreamKey: item.StreamKey, Editable: item.Editable})
}
for _, item := range body.ConfigFields {
workspace.ConfigFields = append(workspace.ConfigFields, domain.PluginConfigField{Key: item.Key, FileKey: item.FileKey, ConfigKey: item.ConfigKey, Label: item.Label, Description: item.Description, Control: item.Control, Minimum: item.Minimum, Maximum: item.Maximum, DefaultValue: item.DefaultValue, RestartImpact: item.RestartImpact})
}
return workspace
}
func fileWorkspaceFromDomain(workspace domain.PluginFileWorkspace) PluginFileWorkspaceBody {
workspace = domain.CopyPluginFileWorkspace(workspace)
body := PluginFileWorkspaceBody{DefaultDirectoryKey: workspace.DefaultDirectoryKey}
for _, item := range workspace.Directories {
body.Directories = append(body.Directories, PluginLogicalDirectoryBody{Key: item.Key, Label: item.Label, Scope: item.Scope})
}
for _, item := range workspace.Files {
body.Files = append(body.Files, PluginLogicalFileBody{Key: item.Key, DirectoryKey: item.DirectoryKey, Label: item.Label, Kind: item.Kind, StreamKey: item.StreamKey, Editable: item.Editable})
}
for _, item := range workspace.ConfigFields {
body.ConfigFields = append(body.ConfigFields, PluginConfigFieldBody{Key: item.Key, FileKey: item.FileKey, ConfigKey: item.ConfigKey, Label: item.Label, Description: item.Description, Control: item.Control, Minimum: item.Minimum, Maximum: item.Maximum, DefaultValue: item.DefaultValue, RestartImpact: item.RestartImpact})
}
return body
}
func (bridge GamePluginBridgeBody) ToDomain() domain.GamePluginBridge {
return domain.GamePluginBridge{Actions: domain.CopyStringSlice(bridge.Actions)}
}
@@ -1048,6 +1112,7 @@ func (request GamePluginCreateRequest) ToDomain() domain.GamePlugin {
LifecycleActions: request.LifecycleActions.ToDomain(),
BridgeActions: domain.CopyStringSlice(request.BridgeActions),
Pages: pagesToDomain(request.Pages),
FileWorkspace: fileWorkspaceToDomain(request.FileWorkspace),
Tags: domain.CopyStringSlice(request.Tags),
AIPurposes: domain.CopyStringSlice(request.AIPurposes),
ProductionLifecycle: request.ProductionLifecycle.ToDomain(),
@@ -1295,6 +1360,7 @@ func GamePluginFromDomain(plugin domain.GamePlugin) GamePluginResponse {
LifecycleActions: lifecycleActionsFromDomain(plugin.LifecycleActions),
BridgeActions: plugin.BridgeActions,
Pages: pagesFromDomain(plugin.Pages),
FileWorkspace: fileWorkspaceFromDomain(plugin.FileWorkspace),
Tags: plugin.Tags,
AIPurposes: plugin.AIPurposes,
ProductionLifecycle: productionLifecycleFromDomain(plugin.ProductionLifecycle),
+1
View File
@@ -704,6 +704,7 @@ func gamePluginFromManifestRegistration(registration domain.GamePluginManifestRe
LifecycleActions: manifest.Actions,
BridgeActions: manifest.Bridge.Actions,
Pages: manifest.Pages,
FileWorkspace: manifest.FileWorkspace,
Tags: manifest.Tags,
AIPurposes: manifest.AI.Purposes,
ProductionLifecycle: manifest.ProductionLifecycle,
+42
View File
@@ -146,6 +146,7 @@ func ValidateGamePlugin(plugin domain.GamePlugin) error {
violations = append(violations, validateDeclaredPluginPermissions(plugin.DeclaredPermissions)...)
violations = append(violations, validateBridgeActions("bridgeActions", plugin.BridgeActions)...)
violations = append(violations, validatePluginPages(plugin.Pages)...)
violations = append(violations, validatePluginFileWorkspace("fileWorkspace", plugin.FileWorkspace)...)
violations = append(violations, duplicateViolations("tags", plugin.Tags)...)
violations = append(violations, validateAIPurposes(plugin.AIPurposes)...)
violations = append(violations, validateProductionLifecycle("productionLifecycle", plugin.ProductionLifecycle, true)...)
@@ -207,6 +208,7 @@ func ValidateGamePluginManifestRegistration(registration domain.GamePluginManife
violations = append(violations, validateBridgeActions("manifest.bridge.actions", manifest.Bridge.Actions)...)
violations = append(violations, validateLifecycleActions(manifest.Actions)...)
violations = append(violations, validatePluginPages(manifest.Pages)...)
violations = append(violations, validatePluginFileWorkspace("manifest.fileWorkspace", manifest.FileWorkspace)...)
violations = append(violations, duplicateViolations("manifest.tags", manifest.Tags)...)
violations = append(violations, validateAIPurposes(manifest.AI.Purposes)...)
violations = append(violations, validateProductionLifecycle("manifest.productionLifecycle", manifest.ProductionLifecycle, true)...)
@@ -1400,6 +1402,46 @@ func validatePluginPages(pages []domain.GamePluginPage) []string {
return violations
}
func validatePluginFileWorkspace(prefix string, workspace domain.PluginFileWorkspace) []string {
if workspace.DefaultDirectoryKey == "" && len(workspace.Directories) == 0 && len(workspace.Files) == 0 && len(workspace.ConfigFields) == 0 {
return nil
}
var violations []string
directories := map[string]bool{}
files := map[string]domain.PluginLogicalFile{}
for i, item := range workspace.Directories {
field := fmt.Sprintf("%s.directories[%d]", prefix, i)
if !validDistributionLogicalKey(item.Key) || item.Label == "" || !oneOf(item.Scope, "config", "logs") {
violations = append(violations, field+" is invalid")
}
if directories[item.Key] {
violations = append(violations, field+".key duplicates another directory")
}
directories[item.Key] = true
}
if !directories[workspace.DefaultDirectoryKey] {
violations = append(violations, prefix+".defaultDirectoryKey must reference a declared directory")
}
for i, item := range workspace.Files {
field := fmt.Sprintf("%s.files[%d]", prefix, i)
if !validDistributionLogicalKey(item.Key) || !directories[item.DirectoryKey] || item.Label == "" || !oneOf(item.Kind, "config", "log") || (item.Kind == "log" && item.StreamKey == "") {
violations = append(violations, field+" is invalid")
}
if _, exists := files[item.Key]; exists {
violations = append(violations, field+".key duplicates another file")
}
files[item.Key] = item
}
for i, item := range workspace.ConfigFields {
field := fmt.Sprintf("%s.configFields[%d]", prefix, i)
file, exists := files[item.FileKey]
if !validDistributionLogicalKey(item.Key) || !exists || file.Kind != "config" || !file.Editable || item.ConfigKey == "" || item.Label == "" || item.Description == "" || !oneOf(item.Control, "text", "number", "boolean", "port") || !oneOf(item.RestartImpact, "none", "restart-required") || (item.Minimum > 0 && item.Maximum > 0 && item.Minimum > item.Maximum) {
violations = append(violations, field+" is invalid")
}
}
return violations
}
func validateBridgeActions(field string, actions []string) []string {
var violations []string
for i, action := range actions {
+22
View File
@@ -35,6 +35,28 @@ func TestValidateGamePluginManifestRegistration(t *testing.T) {
}
}
func TestValidateGamePluginManifestRegistrationValidatesLogicalFileWorkspace(t *testing.T) {
registration := validGamePluginManifestRegistration()
registration.Manifest.FileWorkspace = domain.PluginFileWorkspace{
DefaultDirectoryKey: "config",
Directories: []domain.PluginLogicalDirectory{{Key: "config", Label: "配置", Scope: "config"}},
Files: []domain.PluginLogicalFile{{Key: "settings", DirectoryKey: "config", Label: "Settings.ini", Kind: "config", Editable: true}},
ConfigFields: []domain.PluginConfigField{{Key: "max-players", FileKey: "settings", ConfigKey: "MaxPlayers", Label: "最大玩家数", Description: "玩家上限", Control: "number", Minimum: 1, Maximum: 128, RestartImpact: "restart-required"}},
}
if err := ValidateGamePluginManifestRegistration(registration); err != nil {
t.Fatalf("expected safe logical file workspace: %v", err)
}
registration.Manifest.FileWorkspace.Files[0].DirectoryKey = "../host"
if err := ValidateGamePluginManifestRegistration(registration); err == nil {
t.Fatal("expected unsafe logical directory reference to fail")
}
registration.Manifest.FileWorkspace.Files[0].DirectoryKey = "config"
registration.Manifest.FileWorkspace.Files[0].Editable = false
if err := ValidateGamePluginManifestRegistration(registration); err == nil {
t.Fatal("expected a configuration field without an editable owning file to fail")
}
}
func TestValidateGamePluginManifestRegistrationRejectsUnsafeRequests(t *testing.T) {
registration := validGamePluginManifestRegistration()
registration.Manifest.Description = "requires direct run socket and raw AI key material"
+6
View File
@@ -282,6 +282,11 @@ export interface RuntimeLogSourceResponse {
retentionDays?: number;
}
export interface PluginLogicalDirectoryResponse { key: string; label: string; scope: "config" | "logs"; }
export interface PluginLogicalFileResponse { key: string; directoryKey: string; label: string; kind: "config" | "log"; streamKey?: string; editable?: boolean; }
export interface PluginConfigFieldResponse { key: string; fileKey: string; configKey: string; label: string; description: string; control: "text" | "number" | "boolean" | "port"; minimum?: number; maximum?: number; defaultValue?: string; restartImpact: "none" | "restart-required"; }
export interface PluginFileWorkspaceResponse { defaultDirectoryKey: string; directories: PluginLogicalDirectoryResponse[]; files: PluginLogicalFileResponse[]; configFields: PluginConfigFieldResponse[]; }
export interface RuntimeLogEventResponse {
key: string;
title: string;
@@ -364,6 +369,7 @@ export interface GamePluginResponse {
lifecycleActions: Record<string, string>;
bridgeActions: string[];
pages: GamePluginPageResponse[];
fileWorkspace?: PluginFileWorkspaceResponse;
tags: string[];
aiPurposes: string[];
productionLifecycle: PluginProductionLifecycleDeclaration;
@@ -0,0 +1,46 @@
import { FileCode2, FileText, RefreshCw, Save, ScrollText } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { platformApiClient } from "../api/client";
import type { LogEntryBody, PluginFileWorkspaceResponse, ServerConfigDiffPreviewResponse, ServerConfigResponse } from "../api/types";
import type { ScumOperationsPageContract } from "../contracts/scumOperations";
import { DiffView } from "./OperationControls";
import { EmptyState, ErrorState, LoadingState, ResultBadge } from "./StateViews";
type State = { status: "loading" } | { status: "error"; reason: string } | { status: "ready"; config: ServerConfigResponse; values: Record<string, string>; unknown: Array<[string, string]> };
export function ScumFileConfigWorkbench({ contract, workspace }: { contract: ScumOperationsPageContract; workspace: PluginFileWorkspaceResponse }) {
const [state, setState] = useState<State>({ status: "loading" });
const [directoryKey, setDirectoryKey] = useState(workspace.defaultDirectoryKey);
const [selectedFileKey, setSelectedFileKey] = useState(workspace.files.find((file) => file.directoryKey === workspace.defaultDirectoryKey)?.key ?? "");
const [preview, setPreview] = useState<ServerConfigDiffPreviewResponse | null>(null);
const [result, setResult] = useState<string>("");
const [logEntries, setLogEntries] = useState<LogEntryBody[] | null>(null);
const files = useMemo(() => workspace.files.filter((file) => file.directoryKey === directoryKey), [directoryKey, workspace.files]);
const selectedFile = workspace.files.find((file) => file.key === selectedFileKey);
const fields = workspace.configFields.filter((field) => field.fileKey === selectedFileKey);
const load = useCallback(async () => {
setState({ status: "loading" }); setPreview(null); setResult("");
try { const config = await platformApiClient.getServerConfig(contract.serverInstanceId); const parsed = parseIni(config.content); const values = Object.fromEntries(workspace.configFields.map((field) => [field.key, parsed[field.configKey] ?? field.defaultValue ?? ""])); const known = new Set(workspace.configFields.map((field) => field.configKey)); setState({ status: "ready", config, values, unknown: Object.entries(parsed).filter(([key]) => !known.has(key)) }); } catch (error) { setState({ status: "error", reason: error instanceof Error ? error.message : "无法读取平台配置投影。" }); }
}, [contract.serverInstanceId, workspace.configFields]);
useEffect(() => { void load(); }, [load]);
useEffect(() => { setSelectedFileKey(workspace.files.find((file) => file.directoryKey === directoryKey)?.key ?? ""); setLogEntries(null); setPreview(null); }, [directoryKey, workspace.files]);
async function openLog() { if (!selectedFile?.streamKey) return; setLogEntries(null); try { const streams = await platformApiClient.listLogStreams(); const stream = streams.items.find((item) => item.serverInstanceId === contract.serverInstanceId && item.streamKey === selectedFile.streamKey); if (!stream) { setLogEntries([]); return; } const data = await platformApiClient.queryLogStream({ logStreamId: stream.id, afterSeq: Math.max(0, stream.latestSeq - 200), limit: 200 }); setLogEntries(data.entries); } catch (error) { setResult(error instanceof Error ? error.message : "日志文件内容读取失败。" ); setLogEntries([]); } }
async function previewChanges() { if (state.status !== "ready") return; const proposed = patchIni(state.config.content, fields, state.values); try { setPreview(await platformApiClient.previewServerConfigDiff(contract.serverInstanceId, { expectedConfigVersion: state.config.configVersion, expectedChecksum: state.config.checksum, key: state.config.key ?? selectedFileKey, proposedContent: proposed })); } catch (error) { setResult(error instanceof Error ? error.message : "配置差异预览失败。" ); } }
async function approve() { if (!preview) return; try { const response = await platformApiClient.approveServerConfigWrite(contract.serverInstanceId, { expectedConfigVersion: preview.configVersion, expectedChecksum: preview.checksum, key: preview.key, proposedContent: preview.proposedContent, idempotencyKey: `scum-files-config-${Date.now()}` }); setResult(`已派发配置写入任务 ${response.job.id}`); setPreview(null); await load(); } catch (error) { setResult(error instanceof Error ? error.message : "配置写入审批失败。" ); } }
if (state.status === "loading") return <LoadingState label="正在加载 SCUM 文件与配置工作台…" />;
if (state.status === "error") return <ErrorState title="SCUM 文件与配置不可用" reason={state.reason} onRetry={() => void load()} />;
return <div className="console-page" aria-label="SCUM 文件与配置工作台">
<section className="console-panel"><div className="panel-header"><div><h2><FileCode2 size={16} /> </h2><p className="provider-id"></p></div><button type="button" className="icon-command" onClick={() => void load()}><RefreshCw size={14} /><span></span></button></div><div className="action-strip">{workspace.directories.map((directory) => <button key={directory.key} type="button" className="icon-command" aria-pressed={directoryKey === directory.key} onClick={() => setDirectoryKey(directory.key)}><span>{directory.label}</span></button>)}</div><div className="console-record-list">{files.map((file) => <button key={file.key} type="button" className="console-record" aria-pressed={selectedFileKey === file.key} onClick={() => { setSelectedFileKey(file.key); setPreview(null); setLogEntries(null); }}><span>{file.kind === "log" ? <ScrollText size={15} /> : <FileText size={15} />}</span><strong>{file.label}</strong><small>{file.kind === "log" ? "日志文件" : file.editable ? "已建模配置" : "只读配置"}</small></button>)}</div></section>
{selectedFile?.kind === "config" && <section className="console-panel"><div className="panel-header"><h2> · {selectedFile.label}</h2><span className="page-status">{fields.length} </span></div><div className="provider-form">{fields.map((field) => <label key={field.key}>{field.label}<small>{field.description} · {field.defaultValue || "--"} · {field.restartImpact === "restart-required" ? "修改后需重启" : "无需重启"} · {selectedFile.label}</small>{field.control === "boolean" ? <select value={state.values[field.key]} onChange={(event) => setState((current) => current.status === "ready" ? { ...current, values: { ...current.values, [field.key]: event.target.value } } : current)}><option value="true"></option><option value="false"></option></select> : <input type={field.control === "text" ? "text" : "number"} min={field.minimum} max={field.maximum} value={state.values[field.key]} onChange={(event) => setState((current) => current.status === "ready" ? { ...current, values: { ...current.values, [field.key]: event.target.value } } : current)} />}</label>)}</div><div className="console-module"><strong></strong>{state.unknown.length ? <div className="console-row-list">{state.unknown.map(([key, value]) => <div className="console-row" key={key}><span>{key}</span><code>{value}</code></div>)}</div> : <p className="page-status"></p>}</div><div className="action-strip"><button type="button" className="icon-command" onClick={() => void previewChanges()}><Save size={14} /><span></span></button>{preview && <button type="button" className="icon-command" onClick={() => void approve()}><span></span></button>}</div>{preview && <DiffView lines={preview.diff.map((line) => ({ kind: line.kind === "context" ? "same" : line.kind, text: line.content }))} />}</section>}
{selectedFile?.kind === "log" && <section className="console-panel"><div className="panel-header"><h2> · {selectedFile.label}</h2><button type="button" className="icon-command" onClick={() => void openLog()}><ScrollText size={14} /><span></span></button></div>{logEntries === null ? <EmptyState title="尚未读取日志文件" description="只读取该插件声明的日志流,不显示主机路径。" /> : logEntries.length === 0 ? <EmptyState title="暂无日志内容" description="当前声明的日志流没有可读取记录。" /> : <pre className="log-view">{logEntries.map((entry) => `${entry.timestamp} ${entry.line}`).join("\n")}</pre>}</section>}
{result && <ResultBadge status={result.startsWith("已派发") ? "succeeded" : "failed"} label={result} />}
</div>;
}
function parseIni(content: string): Record<string, string> { return Object.fromEntries(content.split(/\r?\n/).flatMap((line) => { const match = line.match(/^\s*([^=;#\s]+)\s*=\s*(.*?)\s*$/); return match ? [[match[1], match[2]]] : []; })); }
function patchIni(content: string, fields: PluginFileWorkspaceResponse["configFields"], values: Record<string, string>): string { let next = content; for (const field of fields) { const pattern = new RegExp(`(^\\s*${escapeRegExp(field.configKey)}\\s*=\\s*).*?$`, "m"); next = pattern.test(next) ? next.replace(pattern, `$1${values[field.key]}`) : `${next.replace(/\s*$/, "")}\n${field.configKey}=${values[field.key]}`; } return next; }
function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); }
@@ -10,7 +10,7 @@ const now = "2026-07-20T08:00:00Z";
const contract: ScumOperationsPageContract = {
pluginId: "game.scum",
routeKey: "operations",
routeKey: "files-config",
serverInstanceId: "server-1",
title: "SCUM 运维",
permissions: ["server.read", "server.logs.read", "server.game-client.read", "server.game-client.command", "server.game-client.maintenance"],
+12 -6
View File
@@ -1,14 +1,14 @@
import { describe, expect, it } from "vitest";
import type { GamePluginResponse } from "../api/types";
import { resolveScumOperationsPageContract } from "./scumOperations";
import { normalizeScumRouteKey, resolveScumOperationsPageContract } from "./scumOperations";
const plugin = {
id: "game.scum",
pages: [{
key: "operations",
title: "SCUM 运维",
path: "/operations",
key: "files-config",
title: "文件与配置",
path: "/files-config",
permissions: ["server.game-client.read", "server.game-client.command", "server.remote.access", "unknown.permission"],
bridgeActions: ["server.instances.read", "logs.query", "remote.access.request", "unknown.action"]
}],
@@ -26,7 +26,7 @@ const plugin = {
],
commandRetentionSeconds: 86400,
maxCommands: 1000,
pages: [{ pageKey: "operations", commandTypes: ["announcement.send"], snapshotTypes: ["companion.health"], queryTemplateKeys: ["scum.player.search"] }]
pages: [{ pageKey: "files-config", commandTypes: ["announcement.send"], snapshotTypes: ["companion.health"], queryTemplateKeys: ["scum.player.search"] }]
},
runtimeProfiles: {
logSources: [{ key: "scum-chat-events", kind: "file.tail", streamKey: "scum.chat", retentionDays: 30 }],
@@ -42,7 +42,7 @@ describe("SCUM operations page contract", () => {
available: true,
contract: {
pluginId: "game.scum",
routeKey: "operations",
routeKey: "files-config",
serverInstanceId: "server-1",
permissions: ["server.game-client.read", "server.game-client.command", "server.remote.access"],
bridgeActions: ["server.instances.read", "logs.query", "remote.access.request"],
@@ -61,4 +61,10 @@ describe("SCUM operations page contract", () => {
expect(resolveScumOperationsPageContract(plugin, "")).toMatchObject({ available: false, reason: "缺少服务器实例上下文。" });
expect(resolveScumOperationsPageContract({ ...plugin, gameClientBridge: undefined }, "server-1")).toMatchObject({ available: false });
});
it("migrates only legacy SCUM page keys to the files-and-config workbench", () => {
expect(normalizeScumRouteKey("game.scum", "overview")).toBe("files-config");
expect(normalizeScumRouteKey("game.scum", "logs")).toBe("files-config");
expect(normalizeScumRouteKey("game.other", "logs")).toBe("logs");
});
});
+4 -2
View File
@@ -15,7 +15,7 @@ import {
} from "./pluginBridge";
export const scumOperationsPluginId = "game.scum";
export const scumOperationsRouteKey = "operations";
export const scumOperationsRouteKey = "files-config";
export interface ScumOperationsPageContract {
pluginId: typeof scumOperationsPluginId;
@@ -102,7 +102,9 @@ export type ScumOperationsPageResolution =
| { available: true; contract: ScumOperationsPageContract }
| { available: false; reason: string };
type ScumPluginProjection = Pick<GamePluginResponse, "id" | "pages" | "gameClientBridge" | "runtimeProfiles" | "productionLifecycle">;
type ScumPluginProjection = Pick<GamePluginResponse, "id" | "pages" | "gameClientBridge" | "runtimeProfiles" | "productionLifecycle" | "fileWorkspace">;
export function normalizeScumRouteKey(pluginId: string, routeKey: string): string { return pluginId === scumOperationsPluginId && ["overview", "operations", "config", "logs"].includes(routeKey) ? scumOperationsRouteKey : routeKey; }
export function resolveScumOperationsPageContract(plugin: ScumPluginProjection, serverInstanceId: string): ScumOperationsPageResolution {
if (plugin.id !== scumOperationsPluginId) {
@@ -30,9 +30,9 @@ const plugin: GamePluginResponse = {
lifecycleActions: {},
bridgeActions: ["server.instances.read", "logs.query", "remote.access.request"],
pages: [{
key: "operations",
title: "SCUM 运维",
path: "/operations",
key: "files-config",
title: "文件与配置",
path: "/files-config",
permissions: ["server.game-client.read", "server.game-client.command", "server.logs.read", "server.remote.access"],
bridgeActions: ["server.instances.read", "logs.query", "remote.access.request"]
}],
@@ -45,7 +45,7 @@ const plugin: GamePluginResponse = {
queryTemplates: [{ key: "scum.player.search", title: "Search player", permission: "server.game-client.read", engine: "sqlite", transportKey: "sqlite-db", targetKey: "db/sqlite", parameterSchemaRef: "schemas/bridge/player-search.parameters.json", resultSchemaRef: "schemas/bridge/player-search.result.json", maxRows: 50, timeoutSeconds: 10 }],
commandRetentionSeconds: 86400,
maxCommands: 1000,
pages: [{ pageKey: "operations", commandTypes: ["announcement.send"], snapshotTypes: ["companion.health"], queryTemplateKeys: ["scum.player.search"] }]
pages: [{ pageKey: "files-config", commandTypes: ["announcement.send"], snapshotTypes: ["companion.health"], queryTemplateKeys: ["scum.player.search"] }]
},
status: "installed"
};
@@ -62,7 +62,7 @@ function props(serverId = "server-1"): PageComponentProps {
};
return {
session,
params: { pluginId: "game.scum", routeKey: "operations", serverId },
params: { pluginId: "game.scum", routeKey: "files-config", serverId },
operations,
onNavigate: () => undefined,
onLogout: async () => undefined,
@@ -74,7 +74,7 @@ function props(serverId = "server-1"): PageComponentProps {
describe("PluginPageHostPage", () => {
it("renders SCUM operations from manifest-owned declarations", () => {
const html = renderToStaticMarkup(<PluginPageHostPage {...props()} initialPlugin={plugin} />);
expect(html).toContain("SCUM 运维");
expect(html).toContain("文件与配置");
expect(html).toContain("平台托管上下文");
expect(html).toContain("命令目录");
expect(html).toContain("快照目录");
+4 -4
View File
@@ -4,11 +4,11 @@ import { useCallback, useEffect, useState } from "react";
import { platformApiClient } from "../api/client";
import type { GamePluginResponse } from "../api/types";
import { PageFrame } from "../components/PageFrame";
import { ScumOperationsPanel } from "../components/ScumOperationsPanel";
import { ScumFileConfigWorkbench } from "../components/ScumFileConfigWorkbench";
import { EmptyState, ErrorState, LoadingState } from "../components/StateViews";
import type { PageComponentProps } from "../contracts/page";
import { pluginBridgeManifestContractFromResponse } from "../contracts/pluginBridge";
import { resolveScumOperationsPageContract, scumOperationsPluginId, scumOperationsRouteKey } from "../contracts/scumOperations";
import { normalizeScumRouteKey, resolveScumOperationsPageContract, scumOperationsPluginId, scumOperationsRouteKey } from "../contracts/scumOperations";
import { createPluginBridgeHostContext } from "../utils/pluginBridgeHost";
type PluginPageState =
@@ -22,7 +22,7 @@ interface PluginPageHostPageProps extends PageComponentProps {
export function PluginPageHostPage({ params, onNavigate, initialPlugin }: PluginPageHostPageProps) {
const pluginId = params.pluginId ?? "";
const routeKey = params.routeKey ?? "";
const routeKey = normalizeScumRouteKey(pluginId, params.routeKey ?? "");
const serverId = params.serverId ?? "";
const [state, setState] = useState<PluginPageState>(() => initialPlugin ? { status: "ready", plugin: initialPlugin } : { status: "loading" });
@@ -105,7 +105,7 @@ export function PluginPageHostPage({ params, onNavigate, initialPlugin }: Plugin
/>
)}
</section>
{scumResolution?.available && <ScumOperationsPanel contract={scumResolution.contract} />}
{scumResolution?.available && state.plugin.fileWorkspace && <ScumFileConfigWorkbench contract={scumResolution.contract} workspace={state.plugin.fileWorkspace} />}
</div>
);
}
@@ -281,7 +281,7 @@
"maxCommands": 1000,
"pages": [
{
"pageKey": "operations",
"pageKey": "files-config",
"commandTypes": [
"announcement.send",
"companion.diagnostics",
@@ -347,89 +347,13 @@
"dependencyPolicy": "required",
"approvalRequired": ["disable", "rollback", "retire"]
},
"pages": [
{
"key": "overview",
"title": "SCUM 概览",
"path": "/overview",
"permissions": [
"server.read",
"server.lifecycle"
],
"bridgeActions": [
"server.instances.read",
"jobs.dispatch"
]
},
{
"key": "operations",
"title": "SCUM 运维",
"path": "/operations",
"permissions": [
"server.read",
"server.game-client.read",
"server.game-client.command",
"server.game-client.maintenance",
"server.client-manager.manage",
"server.logs.read",
"server.remote.access"
],
"bridgeActions": [
"server.instances.read",
"jobs.dispatch",
"client-manager.request",
"logs.query",
"remote.access.request"
]
},
{
"key": "config",
"title": "SCUM 配置",
"path": "/config",
"permissions": [
"server.files.read",
"server.files.write",
"ai.invoke"
],
"bridgeActions": [
"files.request",
"ai.invoke"
]
},
{
"key": "logs",
"title": "SCUM 日志",
"path": "/logs",
"permissions": [
"server.logs.read",
"server.artifacts.read",
"server.remote.access",
"ai.invoke"
],
"bridgeActions": [
"logs.query",
"artifacts.open",
"remote.access.request",
"ai.invoke"
]
},
{
"key": "remote",
"title": "SCUM 远程",
"path": "/remote",
"permissions": [
"server.remote.access",
"server.files.read",
"server.files.write",
"server.lifecycle"
],
"bridgeActions": [
"remote.access.request",
"files.request",
"jobs.dispatch"
]
}
],
"pages": [{ "key": "files-config", "title": "文件与配置", "path": "/files-config", "permissions": ["server.read", "server.files.read", "server.files.write", "server.logs.read", "server.game-client.read", "server.remote.access", "ai.invoke"], "bridgeActions": ["server.instances.read", "files.request", "logs.query", "remote.access.request", "ai.invoke"] }],
"fileWorkspace": {
"defaultDirectoryKey": "scum-config",
"directories": [{ "key": "scum-config", "label": "服务器配置", "scope": "config" }, { "key": "scum-logs", "label": "日志文件", "scope": "logs" }],
"files": [{ "key": "scum-server-settings", "directoryKey": "scum-config", "label": "ServerSettings.ini", "kind": "config", "editable": true }, { "key": "scum-server-log", "directoryKey": "scum-logs", "label": "SCUM Server.log", "kind": "log", "streamKey": "scum.server" }, { "key": "scum-chat-log", "directoryKey": "scum-logs", "label": "SCUM Chat.log", "kind": "log", "streamKey": "scum.chat" }],
"configFields": [{ "key": "server-name", "fileKey": "scum-server-settings", "configKey": "ServerName", "label": "服务器名称", "description": "显示在服务器浏览器与玩家连接界面。", "control": "text", "defaultValue": "SCUM Server", "restartImpact": "restart-required" }, { "key": "game-port", "fileKey": "scum-server-settings", "configKey": "GamePort", "label": "游戏端口", "description": "玩家连接所使用的游戏端口。", "control": "port", "minimum": 1, "maximum": 65535, "defaultValue": "7777", "restartImpact": "restart-required" }, { "key": "query-port", "fileKey": "scum-server-settings", "configKey": "QueryPort", "label": "查询和状态发现端口。", "description": "服务器查询和状态发现所使用的端口。", "control": "port", "minimum": 1, "maximum": 65535, "defaultValue": "27015", "restartImpact": "restart-required" }, { "key": "max-players", "fileKey": "scum-server-settings", "configKey": "MaxPlayers", "label": "最大玩家数", "description": "允许同时进入服务器的玩家上限。", "control": "number", "minimum": 1, "maximum": 128, "defaultValue": "64", "restartImpact": "restart-required" }]
},
"ai": {
"purposes": [
"config.suggest",
@@ -190,6 +190,7 @@
}
}
},
"fileWorkspace": { "type": "object", "required": ["defaultDirectoryKey", "directories", "files", "configFields"], "additionalProperties": false, "properties": { "defaultDirectoryKey": { "$ref": "#/$defs/logicalKey" }, "directories": { "type": "array", "items": { "$ref": "#/$defs/pluginLogicalDirectory" } }, "files": { "type": "array", "items": { "$ref": "#/$defs/pluginLogicalFile" } }, "configFields": { "type": "array", "items": { "$ref": "#/$defs/pluginConfigField" } } } },
"ai": {
"type": "object",
"required": ["mediation", "configWritePolicy"],
@@ -202,6 +203,9 @@
}
},
"$defs": {
"pluginLogicalDirectory": { "type": "object", "required": ["key", "label", "scope"], "additionalProperties": false, "properties": { "key": { "$ref": "#/$defs/logicalKey" }, "label": { "type": "string", "minLength": 1, "maxLength": 60 }, "scope": { "enum": ["config", "logs"] } } },
"pluginLogicalFile": { "type": "object", "required": ["key", "directoryKey", "label", "kind"], "additionalProperties": false, "properties": { "key": { "$ref": "#/$defs/logicalKey" }, "directoryKey": { "$ref": "#/$defs/logicalKey" }, "label": { "type": "string", "minLength": 1, "maxLength": 80 }, "kind": { "enum": ["config", "log"] }, "streamKey": { "$ref": "#/$defs/logicalKey" }, "editable": { "type": "boolean" } } },
"pluginConfigField": { "type": "object", "required": ["key", "fileKey", "configKey", "label", "description", "control", "restartImpact"], "additionalProperties": false, "properties": { "key": { "$ref": "#/$defs/logicalKey" }, "fileKey": { "$ref": "#/$defs/logicalKey" }, "configKey": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9_.-]*$", "maxLength": 120 }, "label": { "type": "string", "minLength": 1, "maxLength": 80 }, "description": { "type": "string", "minLength": 1, "maxLength": 240 }, "control": { "enum": ["text", "number", "boolean", "port"] }, "minimum": { "type": "integer", "minimum": 0, "maximum": 65535 }, "maximum": { "type": "integer", "minimum": 0, "maximum": 65535 }, "defaultValue": { "type": "string", "maxLength": 120 }, "restartImpact": { "enum": ["none", "restart-required"] } } },
"pluginCreateField": {
"type": "object",
"required": ["key", "label", "type"],
+6 -6
View File
@@ -470,8 +470,8 @@ describe("plugin manifest validation", () => {
"maintenance.prepare"
]));
expect(manifest.gameClientBridge.snapshots.map((snapshot) => snapshot.type)).toEqual(expect.arrayContaining(["companion.health", "online.sessions", "players", "squads", "vehicles", "flags"]));
expect(manifest.gameClientBridge.pages.map((page) => page.pageKey)).toContain("operations");
expect(manifest.gameClientBridge.pages.find((page) => page.pageKey === "operations")?.commandTypes).toEqual(expect.arrayContaining([
expect(manifest.gameClientBridge.pages.map((page) => page.pageKey)).toContain("files-config");
expect(manifest.gameClientBridge.pages.find((page) => page.pageKey === "files-config")?.commandTypes).toEqual(expect.arrayContaining([
"announcement.send",
"companion.diagnostics",
"player.lookup",
@@ -480,7 +480,7 @@ describe("plugin manifest validation", () => {
"restart.prepare",
"maintenance.prepare"
]));
expect(manifest.pages.map((page) => page.key)).toContain("operations");
expect(manifest.pages.map((page) => page.key)).toContain("files-config");
expect(manifest.runtimeProfiles?.lifecycleProfiles?.find((profile) => profile.key === "scum-client")?.capabilities).not.toContain("remote.run.rcon.command");
expect(manifest.runtimeProfiles?.logSources?.map((source) => source.key)).toEqual(expect.arrayContaining(["scum-chat-events", "scum-server-events", "scum-client-events"]));
});
@@ -587,7 +587,7 @@ describe("plugin manifest validation", () => {
}
}
const operationsPage = manifest.gameClientBridge.pages.find((page) => page.pageKey === "operations");
const operationsPage = manifest.gameClientBridge.pages.find((page) => page.pageKey === "files-config");
expect(operationsPage?.snapshotTypes).toEqual(expect.arrayContaining(expectedTypes));
});
@@ -663,8 +663,8 @@ describe("plugin manifest validation", () => {
}
}
const operationsPage = manifest.gameClientBridge.pages.find((page) => page.pageKey === "operations");
const operationsPluginPage = manifest.pages.find((page) => page.key === "operations");
const operationsPage = manifest.gameClientBridge.pages.find((page) => page.pageKey === "files-config");
const operationsPluginPage = manifest.pages.find((page) => page.key === "files-config");
expect(operationsPage?.queryTemplateKeys).toEqual(expect.arrayContaining(expectedKeys));
expect(operationsPluginPage?.permissions).toEqual(expect.arrayContaining(["server.game-client.read", "server.remote.access"]));
expect(operationsPluginPage?.bridgeActions).toContain("remote.access.request");