chore: tidy local debug scripts

This commit is contained in:
npc0-hue
2026-07-23 16:09:12 +08:00
parent c07994c417
commit 8cc741ba19
15 changed files with 86 additions and 42 deletions
+770
View File
@@ -0,0 +1,770 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
# shellcheck source=scripts/local-debug/env.sh
source "$ROOT_DIR/scripts/local-debug/env.sh"
PLATFORM_URL="$(local_debug_platform_url)"
API_URL="$PLATFORM_URL/api/v1"
WORK_DIR="$LOCAL_DEBUG_ROOT/smoke"
mkdir -p "$WORK_DIR"
cat >"$WORK_DIR/run-build-config.env" <<EOF
RUN_SOURCE_DIR=$RUN_SOURCE_DIR
RUN_REPO_DIR=$RUN_REPO_DIR
RUN_BUILD_BUCKET_ROOT=$RUN_BUILD_BUCKET_ROOT
RUN_BUILD_SOURCE_ROOT=$RUN_BUILD_SOURCE_ROOT
RUN_BOOTSTRAP_BIN=$RUN_BOOTSTRAP_BIN
RUN_WORKSPACE_ROOT=$RUN_WORKSPACE_ROOT
RUN_SPOOL_ROOT=$RUN_SPOOL_ROOT
RUN_MAX_JOBS=$RUN_MAX_JOBS
EOF
prepare_run_workspace() {
local_debug_prepare_run_lifecycle_templates
local_debug_seed_server_lifecycle_workspace server-local-debug game.example run-local
local_debug_seed_server_lifecycle_workspace scum-alpha game.scum run-local
local_debug_seed_server_lifecycle_workspace scum-beta game.scum run-local
local_debug_seed_server_lifecycle_workspace scum-dynamic game.scum run-local
}
prepare_run_workspace
SELF_STARTED_PIDS=()
cleanup_self_started() {
local pid
for pid in "${SELF_STARTED_PIDS[@]:-}"; do
if [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null; then
kill "$pid" 2>/dev/null || true
fi
done
}
wait_for_url() {
local name="$1"
local url="$2"
local attempts="${3:-30}"
printf 'waiting for %s at %s\n' "$name" "$url"
for _ in $(seq 1 "$attempts"); do
if curl -fsS "$url" >/dev/null 2>&1; then
printf '%s is ready\n' "$name"
return 0
fi
sleep 1
done
printf '%s did not become ready at %s\n' "$name" "$url" >&2
return 1
}
start_self_hosted_stack() {
mkdir -p "$LOCAL_DEBUG_LOG_DIR" "$LOCAL_DEBUG_PID_DIR" "$PLATFORM_DATA_DIR" "$PLATFORM_LOG_DIR" "$PLATFORM_ARTIFACT_DIR" "$RUN_WORKSPACE_ROOT" "$RUN_SPOOL_ROOT" "$RUN_BUILD_BUCKET_ROOT" "$GOCACHE"
trap cleanup_self_started EXIT
printf 'self-starting platform for local debug smoke\n'
(
cd "$ROOT_DIR/platform"
exec env \
GOCACHE="$GOCACHE" \
PLATFORM_ADDR="$PLATFORM_ADDR" \
PLATFORM_STORAGE_BACKEND="$PLATFORM_STORAGE_BACKEND" \
PLATFORM_DATA_DIR="$PLATFORM_DATA_DIR" \
PLATFORM_METADATA_PATH="$PLATFORM_METADATA_PATH" \
PLATFORM_LOG_BODY_BACKEND="$PLATFORM_LOG_BODY_BACKEND" \
PLATFORM_LOG_DIR="$PLATFORM_LOG_DIR" \
PLATFORM_ARTIFACT_DIR="$PLATFORM_ARTIFACT_DIR" \
PLATFORM_BOOTSTRAP_ADMIN_EMAIL="$PLATFORM_BOOTSTRAP_ADMIN_EMAIL" \
PLATFORM_BOOTSTRAP_ADMIN_PASSWORD="$PLATFORM_BOOTSTRAP_ADMIN_PASSWORD" \
PLATFORM_SECRET_ENVELOPE_KEY="$PLATFORM_SECRET_ENVELOPE_KEY" \
go run ./cmd/platform
) >"$LOCAL_DEBUG_LOG_DIR/platform.log" 2>&1 &
SELF_STARTED_PIDS+=("$!")
printf '%s' "$!" >"$LOCAL_DEBUG_PID_DIR/platform.pid"
wait_for_url platform "$PLATFORM_URL/healthz" 45
printf 'self-starting run worker for local debug smoke\n'
local_debug_build_bootstrap_run
(
cd "$(dirname "$RUN_BOOTSTRAP_BIN")"
exec env \
GOCACHE="$GOCACHE" \
RUN_MODE="$RUN_MODE" \
RUN_PLATFORM_URL="$RUN_PLATFORM_URL" \
RUN_ENDPOINT_ID="$RUN_ENDPOINT_ID" \
RUN_DISPLAY_NAME="$RUN_DISPLAY_NAME" \
RUN_VERSION="$RUN_VERSION" \
RUN_REGISTRATION_TOKEN="$RUN_REGISTRATION_TOKEN" \
RUN_WORKSPACE_ROOT="$RUN_WORKSPACE_ROOT" \
RUN_BUILD_SOURCE_ROOT="$RUN_BUILD_SOURCE_ROOT" \
RUN_SPOOL_ROOT="$RUN_SPOOL_ROOT" \
RUN_MAX_JOBS="$RUN_MAX_JOBS" \
RUN_HEARTBEAT_INTERVAL_MS="$RUN_HEARTBEAT_INTERVAL_MS" \
RUN_POLL_INTERVAL_MS="$RUN_POLL_INTERVAL_MS" \
RUN_RETRY_BACKOFF_MS="$RUN_RETRY_BACKOFF_MS" \
"$RUN_BOOTSTRAP_BIN"
) >"$LOCAL_DEBUG_LOG_DIR/run.log" 2>&1 &
SELF_STARTED_PIDS+=("$!")
printf '%s' "$!" >"$LOCAL_DEBUG_PID_DIR/run.pid"
printf 'self-starting platform_web for local debug smoke\n'
(
cd "$ROOT_DIR"
exec env \
PLATFORM_API_PROXY="$PLATFORM_API_PROXY" \
VITE_PLATFORM_API_BASE_URL="$VITE_PLATFORM_API_BASE_URL" \
VITE_ENABLE_LOCAL_AUTH_FALLBACK="$VITE_ENABLE_LOCAL_AUTH_FALLBACK" \
npm --prefix platform_web run dev -- --port "$LOCAL_DEBUG_WEB_PORT"
) >"$LOCAL_DEBUG_LOG_DIR/platform_web.log" 2>&1 &
SELF_STARTED_PIDS+=("$!")
printf '%s' "$!" >"$LOCAL_DEBUG_PID_DIR/platform_web.pid"
wait_for_url platform_web "$(local_debug_web_url)" 45
}
if [[ "${LOCAL_DEBUG_SELF_START:-false}" == "true" ]]; then
start_self_hosted_stack
fi
json_post() {
local url="$1"
local body_file="$2"
local output_file="$3"
shift 3
local status
status="$(curl -sS -H 'Content-Type: application/json' "$@" --data-binary "@$body_file" "$url" -o "$output_file" -w '%{http_code}')"
case "$status" in
2*)
return 0
;;
*)
if [[ ! -s "$output_file" ]] || ! response_code_is_duplicate "$output_file"; then
printf 'POST %s failed with HTTP %s\n' "$url" "$status" >&2
fi
return 1
;;
esac
}
json_get() {
local url="$1"
local output_file="$2"
shift 2
curl -fsS "$@" "$url" >"$output_file"
}
response_code_is_duplicate() {
local file="$1"
node -e 'const fs=require("fs"); const data=JSON.parse(fs.readFileSync(process.argv[1],"utf8")); process.exit(data.code === "duplicate" || data.code === "duplicate_resource" ? 0 : 1);' "$file" 2>/dev/null
}
register_plugin_manifest() {
local label="$1"
local plugin_id="$2"
local request_file="$3"
local response_file="$4"
if json_post "$API_URL/game-plugins/register-manifest" "$request_file" "$response_file" "${AUTH_HEADER[@]}"; then
return 0
fi
if [[ -s "$response_file" ]] && response_code_is_duplicate "$response_file"; then
json_get "$API_URL/game-plugins/$plugin_id" "$response_file" "${AUTH_HEADER[@]}"
return 0
fi
printf '%s manifest registration failed; platform response:\n' "$label" >&2
sed -n '1,160p' "$response_file" >&2
exit 1
}
create_server_workflow() {
local label="$1"
local server_id="$2"
local request_file="$3"
local response_file="$4"
if json_post "$API_URL/server-instances/workflows/create" "$request_file" "$response_file" "${AUTH_HEADER[@]}"; then
return 0
fi
if [[ -s "$response_file" ]] && response_code_is_duplicate "$response_file"; then
json_get "$API_URL/server-instances/$server_id" "$response_file" "${AUTH_HEADER[@]}"
return 0
fi
printf '%s server workflow creation failed; platform response:\n' "$label" >&2
sed -n '1,160p' "$response_file" >&2
exit 1
}
require_file_contains() {
local file="$1"
local pattern="$2"
if ! grep -Eq "$pattern" "$file"; then
printf 'expected %s to contain pattern: %s\n' "$file" "$pattern" >&2
sed -n '1,120p' "$file" >&2
exit 1
fi
}
reject_forbidden_fragments() {
local file="$1"
local pattern
pattern="$(local_debug_forbidden_pattern)"
if grep -Eq "$pattern" "$file"; then
printf 'forbidden local-debug fragment found in %s\n' "$file" >&2
grep -En "$pattern" "$file" >&2
exit 1
fi
}
session_token_from_login() {
node -e 'const fs=require("fs"); const data=JSON.parse(fs.readFileSync(process.argv[1],"utf8")); if (!data.sessionId) process.exit(2); process.stdout.write(data.sessionId);' "$1"
}
json_id() {
node -e 'const fs=require("fs"); const data=JSON.parse(fs.readFileSync(process.argv[1],"utf8")); const id=data.instance?.id || data.serverInstance?.id || data.id || ((data.items||[])[0]||{}).id; if (!id) process.exit(2); process.stdout.write(id);' "$1"
}
require_scum_manifest_contract() {
local file="$1"
node - "$file" <<'NODE'
const fs = require("fs");
const file = process.argv[2];
const plugin = JSON.parse(fs.readFileSync(file, "utf8"));
const missing = [];
const declaredPermissions = Array.isArray(plugin.declaredPermissions) ? plugin.declaredPermissions : [];
const bridgeActions = Array.isArray(plugin.bridgeActions) ? plugin.bridgeActions : [];
const lifecycleProfiles = plugin.runtimeProfiles?.lifecycleProfiles ?? [];
if (!declaredPermissions.includes("server.run.distribution")) {
missing.push("server.run.distribution permission");
}
if (!bridgeActions.includes("run.distribution.request")) {
missing.push("run.distribution.request bridge action");
}
if (!lifecycleProfiles.some((profile) => profile.key === "run-local")) {
missing.push("run-local runtime profile");
}
if (!plugin.gameClientBridge?.commands?.length) {
missing.push("game client bridge declarations");
}
if (missing.length > 0) {
console.error(`SCUM plugin registration is stale or incomplete: missing ${missing.join(", ")}`);
console.error("Run scripts/dev-reset.sh, restart the local debug stack, then rerun scripts/dev-smoke.sh.");
process.exit(1);
}
NODE
}
wait_for_distribution_build() {
local distribution_file="$1"
local job_file="$2"
local artifact_file="$3"
local job_id
local artifact_id
job_id="$(node -e 'const fs=require("fs"); const data=JSON.parse(fs.readFileSync(process.argv[1],"utf8")); if (!data.buildJobId) process.exit(2); process.stdout.write(data.buildJobId);' "$distribution_file")"
artifact_id="$(node -e 'const fs=require("fs"); const data=JSON.parse(fs.readFileSync(process.argv[1],"utf8")); if (!data.artifactId) process.exit(2); process.stdout.write(data.artifactId);' "$distribution_file")"
rm -f "$job_file" "$artifact_file"
for _ in $(seq 1 60); do
json_get "$API_URL/jobs/$job_id" "$job_file" "${AUTH_HEADER[@]}" || true
if [[ ! -s "$job_file" ]]; then
sleep 1
continue
fi
if node - "$job_file" <<'NODE'; then
const fs = require("fs");
const job = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
process.exit(job.state === "succeeded" && job.resultRef ? 0 : 1);
NODE
break
fi
if node - "$job_file" <<'NODE'; then
const fs = require("fs");
const job = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
process.exit(job.state === "failed" || job.state === "cancelled" ? 0 : 1);
NODE
printf 'distribution build job reached terminal failure\n' >&2
sed -n '1,120p' "$job_file" >&2
exit 1
fi
sleep 1
done
json_get "$API_URL/jobs/$job_id" "$job_file" "${AUTH_HEADER[@]}"
json_get "$API_URL/artifacts/$artifact_id" "$artifact_file" "${AUTH_HEADER[@]}"
node - "$job_file" "$artifact_file" "$artifact_id" <<'NODE'
const fs = require("fs");
const job = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
const artifact = JSON.parse(fs.readFileSync(process.argv[3], "utf8"));
const artifactId = process.argv[4];
if (job.state !== "succeeded" || job.resultRef !== `artifact://${artifactId}`) {
console.error("expected distribution build job to succeed with artifact result");
console.error(JSON.stringify(job, null, 2));
process.exit(1);
}
if (artifact.id !== artifactId || artifact.state !== "available" || !/^sha256:/.test(artifact.checksum || "")) {
console.error("expected available distribution artifact with sha256 checksum");
console.error(JSON.stringify(artifact, null, 2));
process.exit(1);
}
NODE
}
read_latest_run_download_content() {
local server_id="$1"
local download_file="$2"
local payload_file="$3"
local chunk_dir="$4"
local request_file="$download_file.request.json"
mkdir -p "$chunk_dir"
printf '{}\n' >"$request_file"
json_post "$API_URL/server-instances/$server_id/run/download" "$request_file" "$download_file" "${AUTH_HEADER[@]}"
reject_forbidden_fragments "$download_file"
local artifact_id
local download_url
local total_size
local checksum
local chunk_size
artifact_id="$(node -e 'const fs=require("fs"); const data=JSON.parse(fs.readFileSync(process.argv[1],"utf8")); if (!data.artifactId) process.exit(2); process.stdout.write(data.artifactId);' "$download_file")"
download_url="$(node -e 'const fs=require("fs"); const data=JSON.parse(fs.readFileSync(process.argv[1],"utf8")); if (!data.downloadUrl) process.exit(2); process.stdout.write(data.downloadUrl);' "$download_file")"
total_size="$(node -e 'const fs=require("fs"); const data=JSON.parse(fs.readFileSync(process.argv[1],"utf8")); if (!Number.isSafeInteger(data.sizeBytes) || data.sizeBytes <= 0) process.exit(2); process.stdout.write(String(data.sizeBytes));' "$download_file")"
checksum="$(node -e 'const fs=require("fs"); const data=JSON.parse(fs.readFileSync(process.argv[1],"utf8")); if (!/^sha256:/.test(data.checksum || "")) process.exit(2); process.stdout.write(data.checksum);' "$download_file")"
chunk_size="$(node -e 'const fs=require("fs"); const data=JSON.parse(fs.readFileSync(process.argv[1],"utf8")); const size=Number(data.chunkSizeBytes || 1048576); if (!Number.isSafeInteger(size) || size <= 0) process.exit(2); process.stdout.write(String(size));' "$download_file")"
: >"$payload_file"
local offset=0
local index=0
while (( offset < total_size )); do
local limit="$chunk_size"
local remaining=$((total_size - offset))
if (( remaining < limit )); then
limit="$remaining"
fi
local headers_file="$chunk_dir/chunk-$index.headers"
local chunk_file="$chunk_dir/chunk-$index.bin"
curl -fsS -D "$headers_file" -H "Authorization: Bearer $SESSION_ID" "$PLATFORM_URL$download_url?offset=$offset&limit=$limit" -o "$chunk_file"
reject_forbidden_fragments "$headers_file"
cat "$chunk_file" >>"$payload_file"
node - "$download_file" "$headers_file" "$chunk_file" "$offset" "$limit" <<'NODE'
const crypto = require("crypto");
const fs = require("fs");
const reference = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
const headerLines = fs.readFileSync(process.argv[3], "utf8").split(/\r?\n/);
const body = fs.readFileSync(process.argv[4]);
const offset = Number(process.argv[5]);
const limit = Number(process.argv[6]);
const headers = new Map();
for (const line of headerLines) {
const index = line.indexOf(":");
if (index > 0) {
headers.set(line.slice(0, index).trim().toLowerCase(), line.slice(index + 1).trim());
}
}
const bodyChecksum = `sha256:${crypto.createHash("sha256").update(body).digest("hex")}`;
const fail = (message) => {
console.error(message);
console.error({ reference, offset, limit, headers: Object.fromEntries(headers), bodyBytes: body.length });
process.exit(1);
};
if (headers.get("x-artifact-id") !== reference.artifactId) {
fail("artifact content route returned the wrong artifact id");
}
if (headers.get("x-artifact-checksum") !== reference.checksum) {
fail("artifact content route returned the wrong full checksum");
}
if (headers.get("x-artifact-content-checksum") !== bodyChecksum) {
fail("artifact content route returned the wrong chunk checksum");
}
if (Number(headers.get("content-length")) !== body.length || body.length !== limit) {
fail("artifact content chunk size did not match request");
}
if (offset > 0 || body.length !== reference.sizeBytes) {
const range = headers.get("content-range") || "";
if (!range.includes(`/${reference.sizeBytes}`)) {
fail("partial artifact response did not include the expected total size");
}
}
NODE
offset=$((offset + limit))
index=$((index + 1))
done
node - "$download_file" "$payload_file" "$index" "$artifact_id" "$checksum" <<'NODE'
const crypto = require("crypto");
const fs = require("fs");
const reference = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
const payload = fs.readFileSync(process.argv[3]);
const chunkCount = Number(process.argv[4]);
const artifactId = process.argv[5];
const checksum = process.argv[6];
const actualChecksum = `sha256:${crypto.createHash("sha256").update(payload).digest("hex")}`;
if (reference.artifactId !== artifactId || reference.checksum !== checksum || payload.length !== reference.sizeBytes || actualChecksum !== reference.checksum || chunkCount < 1) {
console.error("downloaded artifact content did not match reference metadata");
console.error({ reference, payloadBytes: payload.length, actualChecksum, chunkCount });
process.exit(1);
}
NODE
}
wait_for_lifecycle_install_success() {
local server_id="$1"
local output_file="$2"
local state
rm -f "$output_file"
for _ in $(seq 1 30); do
json_get "$API_URL/jobs?serverInstanceId=$server_id" "$output_file" "${AUTH_HEADER[@]}" || true
if [[ ! -s "$output_file" ]]; then
sleep 1
continue
fi
state="$(node - "$output_file" <<'NODE'
const fs = require("fs");
const response = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
const job = (response.items || []).find((candidate) => candidate.capability === "process.install");
if (!job) {
process.stdout.write("missing");
} else {
process.stdout.write(job.state || "unknown");
}
NODE
)"
case "$state" in
succeeded)
return 0
;;
failed | cancelled)
printf 'lifecycle install job for %s reached terminal failure\n' "$server_id" >&2
sed -n '1,160p' "$output_file" >&2
exit 1
;;
esac
sleep 1
done
printf 'lifecycle install job for %s did not succeed before timeout\n' "$server_id" >&2
sed -n '1,160p' "$output_file" >&2
exit 1
}
printf 'checking platform health at %s\n' "$PLATFORM_URL"
json_get "$PLATFORM_URL/healthz" "$WORK_DIR/health.json"
require_file_contains "$WORK_DIR/health.json" '"status"[[:space:]]*:[[:space:]]*"ok"'
cat >"$WORK_DIR/login.request.json" <<'JSON'
{"account":"operator.local@example.test","password":"operator-local"}
JSON
json_post "$API_URL/auth/login" "$WORK_DIR/login.request.json" "$WORK_DIR/login.response.json" -H 'X-Auth-Token-Response: bearer'
SESSION_ID="$(session_token_from_login "$WORK_DIR/login.response.json")"
AUTH_HEADER=(-H "Authorization: Bearer $SESSION_ID")
require_file_contains "$WORK_DIR/login.response.json" '"status"[[:space:]]*:[[:space:]]*"active"'
printf 'validating plugin manifests\n'
(cd "$ROOT_DIR/plugins" && npm run validate:manifest)
node - "$ROOT_DIR/plugins/examples/dev-game-plugin/manifest.json" "$WORK_DIR/register-plugin.request.json" <<'NODE'
const fs = require("fs");
const manifestPath = process.argv[2];
const outputPath = process.argv[3];
const source = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
const manifest = {
id: source.id,
name: source.name,
description: "Development plugin",
version: source.version,
kind: "game-plugin",
tags: ["example", "development"],
server: {
type: source.server.type,
displayName: source.server.displayName,
supportedOs: ["linux", "darwin"],
createFormSchema: source.server.createFormSchema
},
capabilities: ["process.install", "process.start", "process.stop", "config.write"],
permissions: ["server.read", "server.lifecycle", "server.logs.read", "server.artifacts.read", "ai.invoke"],
actions: {
install: source.actions.install,
start: source.actions.start,
stop: source.actions.stop,
restart: source.actions.restart
},
pages: [
{
key: "logs",
title: "Logs",
path: "/logs",
permissions: ["server.read", "server.lifecycle", "server.logs.read", "server.artifacts.read", "ai.invoke"],
bridgeActions: ["server.instances.read", "jobs.dispatch", "logs.query", "artifacts.open", "plugin-lifecycle.request", "ai.invoke"]
}
],
bridge: { actions: ["server.instances.read", "jobs.dispatch", "logs.query", "artifacts.open", "plugin-lifecycle.request", "ai.invoke"] },
ai: source.ai,
productionLifecycle: source.productionLifecycle,
runtimeProfiles: {
lifecycleProfiles: [{
key: "run-local",
mode: "local-process",
capabilities: ["process.install", "process.start", "process.stop", "config.write"],
actionRefs: {
install: "actions/install.json",
start: "actions/start.json",
stop: "actions/stop.json"
}
}]
}
};
fs.writeFileSync(outputPath, JSON.stringify({
manifestRef: "artifact://manifests/game.example/0.1.0",
manifest
}, null, 2));
NODE
printf 'registering dev plugin through platform API\n'
register_plugin_manifest "dev plugin" "game.example" "$WORK_DIR/register-plugin.request.json" "$WORK_DIR/register-plugin.response.json"
reject_forbidden_fragments "$WORK_DIR/register-plugin.response.json"
node - "$ROOT_DIR/plugins/examples/scum-server-plugin/manifest.json" "$WORK_DIR/register-scum-plugin.request.json" <<'NODE'
const fs = require("fs");
const manifestPath = process.argv[2];
const outputPath = process.argv[3];
const source = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
const localRunCapabilities = [
"process.install",
"process.start",
"process.stop",
"logs.read",
"run.self-update",
"dependencies.check",
"dependencies.install",
"logs.backfill",
...source.capabilities.filter((capability) => capability.startsWith("remote."))
];
const localRuntimeProfiles = {
lifecycleProfiles: [{
key: "run-local",
mode: "local-process",
capabilities: ["process.install", "process.start", "process.stop"],
actionRefs: {
install: "actions/install.json",
start: "actions/start.json",
stop: "actions/stop.json"
}
}],
transportProfiles: source.runtimeProfiles?.transportProfiles ?? [],
clientManagers: []
};
const localGameClientBridge = JSON.parse(JSON.stringify(source.gameClientBridge ?? {}));
delete localGameClientBridge.companion;
const manifest = {
id: source.id,
name: source.name,
description: source.description,
version: source.version,
kind: source.kind,
tags: source.tags,
server: {
type: source.server.type,
displayName: source.server.displayName,
supportedOs: source.server.supportedOS || source.server.supportedOs || [],
createFormSchema: source.server.createFormSchema
},
capabilities: localRunCapabilities,
permissions: source.permissions,
actions: source.actions,
pages: source.pages,
bridge: source.bridge,
ai: source.ai,
productionLifecycle: source.productionLifecycle,
remoteAccess: source.remoteAccess,
runtimeProfiles: localRuntimeProfiles,
gameClientBridge: localGameClientBridge
};
fs.writeFileSync(outputPath, JSON.stringify({
manifestRef: "artifact://manifests/game.scum/0.1.0",
manifest
}, null, 2));
NODE
printf 'registering SCUM plugin through platform API\n'
register_plugin_manifest "SCUM plugin" "game.scum" "$WORK_DIR/register-scum-plugin.request.json" "$WORK_DIR/register-scum-plugin.response.json"
reject_forbidden_fragments "$WORK_DIR/register-scum-plugin.response.json"
require_scum_manifest_contract "$WORK_DIR/register-scum-plugin.response.json"
printf 'waiting for run endpoint heartbeat\n'
for _ in $(seq 1 20); do
if json_get "$API_URL/run/endpoints?status=online" "$WORK_DIR/run-endpoints.response.json" "${AUTH_HEADER[@]}" && grep -q "$RUN_ENDPOINT_ID" "$WORK_DIR/run-endpoints.response.json"; then
break
fi
sleep 1
done
require_file_contains "$WORK_DIR/run-endpoints.response.json" "$RUN_ENDPOINT_ID"
reject_forbidden_fragments "$WORK_DIR/run-endpoints.response.json"
cat >"$WORK_DIR/create-server.request.json" <<JSON
{
"id": "server-local-debug",
"pluginId": "game.example",
"runEndpointId": "$RUN_ENDPOINT_ID",
"name": "Local Debug Example Server",
"idempotencyKey": "local-debug-create",
"profileKey": "run-local",
"bindings": {}
}
JSON
cat >"$WORK_DIR/create-scum-alpha.request.json" <<JSON
{
"id": "scum-alpha",
"pluginId": "game.scum",
"runEndpointId": "$RUN_ENDPOINT_ID",
"name": "SCUM Alpha",
"idempotencyKey": "local-debug-scum-alpha-create",
"profileKey": "run-local",
"bindings": {}
}
JSON
cat >"$WORK_DIR/create-scum-beta.request.json" <<JSON
{
"id": "scum-beta",
"pluginId": "game.scum",
"runEndpointId": "$RUN_ENDPOINT_ID",
"name": "SCUM Beta",
"idempotencyKey": "local-debug-scum-beta-create",
"profileKey": "run-local",
"bindings": {}
}
JSON
cat >"$WORK_DIR/create-scum-dynamic.request.json" <<JSON
{
"id": "scum-dynamic",
"pluginId": "game.scum",
"runEndpointId": "$RUN_ENDPOINT_ID",
"name": "SCUM Dynamic",
"idempotencyKey": "local-debug-scum-dynamic-create",
"profileKey": "run-local",
"bindings": {}
}
JSON
cat >"$WORK_DIR/scum-alpha-run-generate.request.json" <<JSON
{
"targetOs": "windows",
"targetArch": "amd64",
"idempotencyKey": "local-debug-scum-alpha-run-generate"
}
JSON
printf 'creating server lifecycle workflow through platform API\n'
create_server_workflow "dev" "server-local-debug" "$WORK_DIR/create-server.request.json" "$WORK_DIR/create-server.response.json"
reject_forbidden_fragments "$WORK_DIR/create-server.response.json"
printf 'creating SCUM server lifecycle workflows through platform API\n'
create_server_workflow "SCUM alpha" "scum-alpha" "$WORK_DIR/create-scum-alpha.request.json" "$WORK_DIR/create-scum-alpha.response.json"
create_server_workflow "SCUM beta" "scum-beta" "$WORK_DIR/create-scum-beta.request.json" "$WORK_DIR/create-scum-beta.response.json"
create_server_workflow "SCUM dynamic" "scum-dynamic" "$WORK_DIR/create-scum-dynamic.request.json" "$WORK_DIR/create-scum-dynamic.response.json"
reject_forbidden_fragments "$WORK_DIR/create-scum-alpha.response.json"
reject_forbidden_fragments "$WORK_DIR/create-scum-beta.response.json"
reject_forbidden_fragments "$WORK_DIR/create-scum-dynamic.response.json"
require_file_contains "$WORK_DIR/create-scum-alpha.response.json" '"pluginId"[[:space:]]*:[[:space:]]*"game.scum"'
require_file_contains "$WORK_DIR/create-scum-beta.response.json" '"pluginId"[[:space:]]*:[[:space:]]*"game.scum"'
require_file_contains "$WORK_DIR/create-scum-dynamic.response.json" '"pluginId"[[:space:]]*:[[:space:]]*"game.scum"'
SERVER_ID="$(json_id "$WORK_DIR/create-server.response.json")"
SCUM_ALPHA_ID="$(json_id "$WORK_DIR/create-scum-alpha.response.json")"
SCUM_BETA_ID="$(json_id "$WORK_DIR/create-scum-beta.response.json")"
SCUM_DYNAMIC_ID="$(json_id "$WORK_DIR/create-scum-dynamic.response.json")"
wait_for_lifecycle_install_success "$SCUM_DYNAMIC_ID" "$WORK_DIR/scum-dynamic-jobs.response.json"
printf 'checking SCUM runtime distribution action\n'
json_get "$API_URL/server-instances/$SCUM_ALPHA_ID/runtime/actions" "$WORK_DIR/scum-alpha-runtime-actions.response.json" "${AUTH_HEADER[@]}"
reject_forbidden_fragments "$WORK_DIR/scum-alpha-runtime-actions.response.json"
require_file_contains "$WORK_DIR/scum-alpha-runtime-actions.response.json" '"key"[[:space:]]*:[[:space:]]*"generate-run"'
SCUM_BUILD_AVAILABLE="$(node - "$WORK_DIR/scum-alpha-runtime-actions.response.json" <<'NODE'
const fs = require("fs");
const response = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
const action = (response.actions || []).find((candidate) => candidate.key === "generate-run");
if (!action || (action.available !== true && action.reason !== "run endpoint cannot build distributions")) {
console.error("expected SCUM generate-run action to match the Run capability report");
console.error(JSON.stringify(response, null, 2));
process.exit(1);
}
process.stdout.write(action.available === true ? "true" : "false");
NODE
)"
if [[ "$SCUM_BUILD_AVAILABLE" == "true" ]]; then
printf 'generating SCUM run package through platform API\n'
curl -fsS -H 'Content-Type: application/json' "${AUTH_HEADER[@]}" --data-binary "@$WORK_DIR/scum-alpha-run-generate.request.json" "$API_URL/server-instances/$SCUM_ALPHA_ID/run/generate" >"$WORK_DIR/scum-alpha-run-generate.response.json"
reject_forbidden_fragments "$WORK_DIR/scum-alpha-run-generate.response.json"
require_file_contains "$WORK_DIR/scum-alpha-run-generate.response.json" '"serverInstanceId"[[:space:]]*:[[:space:]]*"scum-alpha"'
require_file_contains "$WORK_DIR/scum-alpha-run-generate.response.json" '"artifactId"[[:space:]]*:[[:space:]]*"artifact-run-dist-scum-alpha'
require_file_contains "$WORK_DIR/scum-alpha-run-generate.response.json" '"buildJobId"[[:space:]]*:[[:space:]]*"job-distribution-build'
node - "$WORK_DIR/scum-alpha-run-generate.response.json" <<'NODE'
const fs = require("fs");
const distribution = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
if (distribution.status !== "building" && distribution.status !== "available") {
console.error("expected run distribution to be building or available");
console.error(JSON.stringify(distribution, null, 2));
process.exit(1);
}
NODE
wait_for_distribution_build "$WORK_DIR/scum-alpha-run-generate.response.json" "$WORK_DIR/scum-alpha-run-build-job.response.json" "$WORK_DIR/scum-alpha-run-build-artifact.response.json"
reject_forbidden_fragments "$WORK_DIR/scum-alpha-run-build-job.response.json"
reject_forbidden_fragments "$WORK_DIR/scum-alpha-run-build-artifact.response.json"
read_latest_run_download_content "$SCUM_ALPHA_ID" "$WORK_DIR/scum-alpha-run-download.response.json" "$WORK_DIR/scum-alpha-run-download-content.bin" "$WORK_DIR/scum-alpha-run-download-chunks"
reject_forbidden_fragments "$WORK_DIR/scum-alpha-run-download.response.json"
else
printf 'SCUM run package build is unavailable because Run did not advertise distribution.build; verified without fake success\n'
fi
printf 'checking jobs, logs, artifacts, and marketplace refs\n'
json_get "$API_URL/server-instances" "$WORK_DIR/server-instances.response.json" "${AUTH_HEADER[@]}"
json_get "$API_URL/jobs?serverInstanceId=$SERVER_ID" "$WORK_DIR/jobs.response.json" "${AUTH_HEADER[@]}"
json_get "$API_URL/jobs?serverInstanceId=$SCUM_ALPHA_ID" "$WORK_DIR/scum-alpha-jobs.response.json" "${AUTH_HEADER[@]}"
json_get "$API_URL/jobs?serverInstanceId=$SCUM_BETA_ID" "$WORK_DIR/scum-beta-jobs.response.json" "${AUTH_HEADER[@]}"
json_get "$API_URL/jobs?serverInstanceId=$SCUM_DYNAMIC_ID" "$WORK_DIR/scum-dynamic-jobs.response.json" "${AUTH_HEADER[@]}"
json_get "$API_URL/log-streams" "$WORK_DIR/log-streams.response.json" "${AUTH_HEADER[@]}"
json_get "$API_URL/artifacts" "$WORK_DIR/artifacts.response.json" "${AUTH_HEADER[@]}"
json_get "$API_URL/plugin-marketplace/plugins" "$WORK_DIR/marketplace.response.json" "${AUTH_HEADER[@]}"
json_get "$API_URL/plugin-marketplace/plugins?serverType=scum&keyword=scum" "$WORK_DIR/scum-marketplace.response.json" "${AUTH_HEADER[@]}"
require_file_contains "$WORK_DIR/scum-marketplace.response.json" '"id"[[:space:]]*:[[:space:]]*"game.scum"'
if [[ "$SCUM_BUILD_AVAILABLE" == "true" ]]; then
require_file_contains "$WORK_DIR/artifacts.response.json" '"id"[[:space:]]*:[[:space:]]*"artifact-run-dist-scum-alpha'
fi
require_file_contains "$WORK_DIR/scum-alpha-jobs.response.json" '"serverInstanceId"[[:space:]]*:[[:space:]]*"scum-alpha"'
require_file_contains "$WORK_DIR/scum-beta-jobs.response.json" '"serverInstanceId"[[:space:]]*:[[:space:]]*"scum-beta"'
require_file_contains "$WORK_DIR/scum-dynamic-jobs.response.json" '"state"[[:space:]]*:[[:space:]]*"succeeded"'
for file in "$WORK_DIR"/server-instances.response.json "$WORK_DIR"/jobs.response.json "$WORK_DIR"/scum-alpha-jobs.response.json "$WORK_DIR"/scum-beta-jobs.response.json "$WORK_DIR"/scum-dynamic-jobs.response.json "$WORK_DIR"/log-streams.response.json "$WORK_DIR"/artifacts.response.json "$WORK_DIR"/marketplace.response.json "$WORK_DIR"/scum-marketplace.response.json "$WORK_DIR"/scum-alpha-runtime-actions.response.json "$WORK_DIR"/scum-alpha-run-generate.response.json "$WORK_DIR"/scum-alpha-run-download.response.json; do
if [[ -f "$file" ]]; then
reject_forbidden_fragments "$file"
fi
done
if [[ "$VITE_PLATFORM_API_BASE_URL" != "/api/v1" ]]; then
printf 'VITE_PLATFORM_API_BASE_URL must be /api/v1, got %s\n' "$VITE_PLATFORM_API_BASE_URL" >&2
exit 1
fi
if [[ "$PLATFORM_API_PROXY" != "$PLATFORM_URL" ]]; then
printf 'PLATFORM_API_PROXY must be %s, got %s\n' "$PLATFORM_URL" "$PLATFORM_API_PROXY" >&2
exit 1
fi
if [[ "$VITE_ENABLE_LOCAL_AUTH_FALLBACK" != "false" ]]; then
printf 'VITE_ENABLE_LOCAL_AUTH_FALLBACK must stay false for local-debug proof, got %s\n' "$VITE_ENABLE_LOCAL_AUTH_FALLBACK" >&2
exit 1
fi
cat >"$WORK_DIR/local-ui-checklist.md" <<EOF
# Local Debug UI Checklist
- Open $(local_debug_web_url)
- Login with operator.local@example.test / operator-local.
- Confirm the login path is API-backed and no local fallback banner or fallback workspace appears.
- Visit 首页, 服务器管理, 插件市场, 用户管理, AI 提供商管理.
- Open server-local-debug detail and inspect lifecycle history, plugin controls, logs, and artifact references.
- Confirm 插件市场 can find SCUM Server / game.scum, then open scum-alpha, scum-beta, and scum-dynamic from 服务器管理.
- Confirm all SCUM servers are backed by game.scum and have separate lifecycle install jobs and operation history.
- Search visible text for forbidden fragments: /Users/, /private/, unix://, tcp://, Bearer , sk-, password=, apiKeyRef, rawApiKey, run session tokens, direct run URLs, plugin-owned transport details.
- Acceptance requires platform routes, logical IDs, job refs, log refs, artifact refs, and safe metadata only.
EOF
printf 'local debug smoke passed\n'
printf 'evidence directory: %s\n' "$WORK_DIR"