diff --git a/openspec/changes/add-scum-file-config-workbench/.openspec.yaml b/openspec/changes/add-scum-file-config-workbench/.openspec.yaml new file mode 100644 index 0000000..e8209ff --- /dev/null +++ b/openspec/changes/add-scum-file-config-workbench/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-28 diff --git a/openspec/changes/add-scum-file-config-workbench/design.md b/openspec/changes/add-scum-file-config-workbench/design.md new file mode 100644 index 0000000..19345f8 --- /dev/null +++ b/openspec/changes/add-scum-file-config-workbench/design.md @@ -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. diff --git a/openspec/changes/add-scum-file-config-workbench/proposal.md b/openspec/changes/add-scum-file-config-workbench/proposal.md new file mode 100644 index 0000000..ad5aa0b --- /dev/null +++ b/openspec/changes/add-scum-file-config-workbench/proposal.md @@ -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. diff --git a/openspec/changes/add-scum-file-config-workbench/specs/config-write-and-file-dispatch/spec.md b/openspec/changes/add-scum-file-config-workbench/specs/config-write-and-file-dispatch/spec.md new file mode 100644 index 0000000..1fd3968 --- /dev/null +++ b/openspec/changes/add-scum-file-config-workbench/specs/config-write-and-file-dispatch/spec.md @@ -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 diff --git a/openspec/changes/add-scum-file-config-workbench/specs/scum-file-config-workbench/spec.md b/openspec/changes/add-scum-file-config-workbench/specs/scum-file-config-workbench/spec.md new file mode 100644 index 0000000..8d6baa8 --- /dev/null +++ b/openspec/changes/add-scum-file-config-workbench/specs/scum-file-config-workbench/spec.md @@ -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 diff --git a/openspec/changes/add-scum-file-config-workbench/specs/scum-operations/spec.md b/openspec/changes/add-scum-file-config-workbench/specs/scum-operations/spec.md new file mode 100644 index 0000000..4ffc0b2 --- /dev/null +++ b/openspec/changes/add-scum-file-config-workbench/specs/scum-operations/spec.md @@ -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 diff --git a/openspec/changes/add-scum-file-config-workbench/tasks.md b/openspec/changes/add-scum-file-config-workbench/tasks.md new file mode 100644 index 0000000..fd72a32 --- /dev/null +++ b/openspec/changes/add-scum-file-config-workbench/tasks.md @@ -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. diff --git a/platform/domain/resources.go b/platform/domain/resources.go index f413bd6..b1bd44b 100644 --- a/platform/domain/resources.go +++ b/platform/domain/resources.go @@ -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 diff --git a/platform/dto/resources.go b/platform/dto/resources.go index 4ab7167..f944916 100644 --- a/platform/dto/resources.go +++ b/platform/dto/resources.go @@ -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), diff --git a/platform/service/resources.go b/platform/service/resources.go index 8fb7a74..5ef2bbb 100644 --- a/platform/service/resources.go +++ b/platform/service/resources.go @@ -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, diff --git a/platform/validator/resources.go b/platform/validator/resources.go index ba68184..9d93d03 100644 --- a/platform/validator/resources.go +++ b/platform/validator/resources.go @@ -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 { diff --git a/platform/validator/resources_test.go b/platform/validator/resources_test.go index f07c3a0..729a787 100644 --- a/platform/validator/resources_test.go +++ b/platform/validator/resources_test.go @@ -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" diff --git a/platform_web/api/types.ts b/platform_web/api/types.ts index d446699..74b7144 100644 --- a/platform_web/api/types.ts +++ b/platform_web/api/types.ts @@ -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; bridgeActions: string[]; pages: GamePluginPageResponse[]; + fileWorkspace?: PluginFileWorkspaceResponse; tags: string[]; aiPurposes: string[]; productionLifecycle: PluginProductionLifecycleDeclaration; diff --git a/platform_web/components/ScumFileConfigWorkbench.tsx b/platform_web/components/ScumFileConfigWorkbench.tsx new file mode 100644 index 0000000..e60842d --- /dev/null +++ b/platform_web/components/ScumFileConfigWorkbench.tsx @@ -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; unknown: Array<[string, string]> }; + +export function ScumFileConfigWorkbench({ contract, workspace }: { contract: ScumOperationsPageContract; workspace: PluginFileWorkspaceResponse }) { + const [state, setState] = useState({ 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(null); + const [result, setResult] = useState(""); + const [logEntries, setLogEntries] = useState(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 ; + if (state.status === "error") return void load()} />; + + return
+

文件与配置

默认展开插件声明的配置目录;仅模型字段可编辑,保存前必须预览差异。

{workspace.directories.map((directory) => )}
{files.map((file) => )}
+ {selectedFile?.kind === "config" &&

配置字段 · {selectedFile.label}

{fields.length} 项已建模
{fields.map((field) => )}
未建模字段(只读){state.unknown.length ?
{state.unknown.map(([key, value]) =>
{key}{value}
)}
:

没有未建模字段。

}
{preview && }
{preview && ({ kind: line.kind === "context" ? "same" : line.kind, text: line.content }))} />}
} + {selectedFile?.kind === "log" &&

日志文件 · {selectedFile.label}

{logEntries === null ? : logEntries.length === 0 ? :
{logEntries.map((entry) => `${entry.timestamp} ${entry.line}`).join("\n")}
}
} + {result && } +
; +} + +function parseIni(content: string): Record { 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 { 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, "\\$&"); } diff --git a/platform_web/components/ScumOperationsPanel.test.tsx b/platform_web/components/ScumOperationsPanel.test.tsx index 2b643b1..73470fe 100644 --- a/platform_web/components/ScumOperationsPanel.test.tsx +++ b/platform_web/components/ScumOperationsPanel.test.tsx @@ -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"], diff --git a/platform_web/contracts/scumOperations.test.ts b/platform_web/contracts/scumOperations.test.ts index 1132985..75d4517 100644 --- a/platform_web/contracts/scumOperations.test.ts +++ b/platform_web/contracts/scumOperations.test.ts @@ -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"); + }); }); diff --git a/platform_web/contracts/scumOperations.ts b/platform_web/contracts/scumOperations.ts index 4ba5807..ce7eff6 100644 --- a/platform_web/contracts/scumOperations.ts +++ b/platform_web/contracts/scumOperations.ts @@ -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; +type ScumPluginProjection = Pick; + +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) { diff --git a/platform_web/pages/PluginPageHostPage.test.tsx b/platform_web/pages/PluginPageHostPage.test.tsx index eee043c..612f921 100644 --- a/platform_web/pages/PluginPageHostPage.test.tsx +++ b/platform_web/pages/PluginPageHostPage.test.tsx @@ -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(); - expect(html).toContain("SCUM 运维"); + expect(html).toContain("文件与配置"); expect(html).toContain("平台托管上下文"); expect(html).toContain("命令目录"); expect(html).toContain("快照目录"); diff --git a/platform_web/pages/PluginPageHostPage.tsx b/platform_web/pages/PluginPageHostPage.tsx index 4e835cb..1ae36e8 100644 --- a/platform_web/pages/PluginPageHostPage.tsx +++ b/platform_web/pages/PluginPageHostPage.tsx @@ -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(() => initialPlugin ? { status: "ready", plugin: initialPlugin } : { status: "loading" }); @@ -105,7 +105,7 @@ export function PluginPageHostPage({ params, onNavigate, initialPlugin }: Plugin /> )} - {scumResolution?.available && } + {scumResolution?.available && state.plugin.fileWorkspace && } ); } diff --git a/plugins/examples/scum-server-plugin/manifest.json b/plugins/examples/scum-server-plugin/manifest.json index eba06ef..7f959df 100644 --- a/plugins/examples/scum-server-plugin/manifest.json +++ b/plugins/examples/scum-server-plugin/manifest.json @@ -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", diff --git a/plugins/manifests/game-plugin.manifest.schema.json b/plugins/manifests/game-plugin.manifest.schema.json index 01c6945..59dda1f 100644 --- a/plugins/manifests/game-plugin.manifest.schema.json +++ b/plugins/manifests/game-plugin.manifest.schema.json @@ -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"], diff --git a/plugins/tests/manifest-validation.test.ts b/plugins/tests/manifest-validation.test.ts index 48d8edf..66dd0a5 100644 --- a/plugins/tests/manifest-validation.test.ts +++ b/plugins/tests/manifest-validation.test.ts @@ -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");