From 485309295673332bbbe01752b830073302e3fafc Mon Sep 17 00:00:00 2001 From: Franz Rolfsvaag Date: Sun, 19 Jul 2026 16:04:01 +0200 Subject: [PATCH] Improve command policy administration --- CHANGELOG.md | 7 + TODO.md | 7 + docs/commands.md | 16 + knowledge/core/lumi-core.md | 2 +- package-lock.json | 4 +- package.json | 2 +- release-index.json | 30 ++ scripts/verify-command-policies.js | 27 +- scripts/verify-release-metadata.js | 6 +- scripts/verify-update-system.js | 5 +- src/services/command-policies.js | 100 ++++-- src/services/db.js | 9 + src/web/public/lumi-components.css | 176 +++++++++- src/web/public/lumi-tokens.css | 2 + src/web/server.js | 130 ++++++-- src/web/views/admin-command-policies.ejs | 301 +++++++++++++++--- .../views/partials/command-policy-fields.ejs | 49 +-- update-manifest.json | 16 +- 18 files changed, 771 insertions(+), 118 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cc0fc63..62c5f2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Lumi changelog +## 0.2.19 + +- Added a protected, implicitly inherited default command group that can be edited but not deleted. +- Replaced inherited policy inputs with accurate read-only effective summaries and made group changes recalculate those summaries live. +- Added clearer policy-section hierarchy, friendly command-source badges, fuzzy group/command suggestions, and positive-only setting/source filters. +- Replaced per-command submission with one atomic page-wide save so edits to multiple collapsed or filtered commands are retained together. + ## 0.2.18 - Added centralized command groups and per-command overrides for time-window limits, crash-tolerant per-stream limits, platform roles/subscriber tiers, and Economy Framework costs with logged refunds. diff --git a/TODO.md b/TODO.md index 81a2527..5a11778 100644 --- a/TODO.md +++ b/TODO.md @@ -14,6 +14,13 @@ OBS replay saving works through local WebSocket and the credential-free Browser Bridge BASIC permission. Admin UI and focused verification cover the shared storage and enforcement path. +Follow-up completed on 2026-07-19: added a protected, implicitly inherited +default group; accurate read-only inherited-value summaries; conditional child +fields; clearer nested visual hierarchy; friendly command-source labels; fuzzy +search suggestions with positive-only live setting/source filters; and atomic +page-wide saving for every command so collapsed or filtered drafts survive the +save. + Remaining work: - Add platform action providers when YouTube or future chat integrations expose diff --git a/docs/commands.md b/docs/commands.md index cce7716..fe9dbb9 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -3,6 +3,11 @@ Admins configure shared command rules at **Admin → Command access**. Moderators continue to create and edit custom replies at **Custom commands**. +The protected **All commands (defaults)** group applies implicitly to every +command and cannot be deleted. Administrators can leave each category on +**Keep command built-ins**, explicitly remove that category globally, or define +a new global value. Additional groups remain assignable to selected commands. + ## Built-in stream commands - `!clip [optional label]` requests a clip on the platform that delivered the @@ -58,6 +63,17 @@ Each command category has three choices: category. - **Set** replaces inherited values for that category on this command. +When **Use groups/default** is selected, the editable child fields are hidden and +the effective inherited value is shown read-only. Changing assigned groups +updates that summary immediately. Command settings use one page-wide save action, +so edits to collapsed or filtered commands are submitted atomically instead of +being lost when another command is saved. + +Group and command lists provide fuzzy search suggestions and live setting +filters. Non-matches are hidden while searching. Command sources use friendly +labels such as **Built-in command**, **Custom command**, or the plugin's display +name rather than internal registry identifiers. + Time-window limits support a shared global pool or one pool per Lumi user. Per-stream limits also support global or per-user counting. Stream identities are persisted, and a new provider stream that appears within two hours reuses diff --git a/knowledge/core/lumi-core.md b/knowledge/core/lumi-core.md index eb919d7..a004255 100644 --- a/knowledge/core/lumi-core.md +++ b/knowledge/core/lumi-core.md @@ -14,7 +14,7 @@ editable: false Lumi is the core web UI and bot runtime. ## Runtime Package: lumi-bot -Version: 0.2.18 +Version: 0.2.19 ## Routes - GET /api/events - POST /api/destructive-confirmations diff --git a/package-lock.json b/package-lock.json index 85fba58..87016f5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "lumi-bot", - "version": "0.2.18", + "version": "0.2.19", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "lumi-bot", - "version": "0.2.18", + "version": "0.2.19", "dependencies": { "adm-zip": "^0.5.12", "better-sqlite3": "^11.5.0", diff --git a/package.json b/package.json index fc88d59..530c5e1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "lumi-bot", - "version": "0.2.18", + "version": "0.2.19", "private": true, "type": "commonjs", "scripts": { diff --git a/release-index.json b/release-index.json index 5abc803..3aefb95 100644 --- a/release-index.json +++ b/release-index.json @@ -2,6 +2,36 @@ "schema_version": 1, "channel": "stable", "releases": [ + { + "version": "0.2.19", + "ref": "refs/tags/v0.2.19", + "released_at": "2026-07-19", + "installable": true, + "rollback_safe": true, + "replaces_versions": [ + "1.2.0" + ], + "data_policy": "preserve", + "dependency_policy": "sync_on_restart", + "migration_notes": "Improves command-policy administration with a protected inherited default group, conditional effective-value summaries, fuzzy searching and filters, clearer command origins, and atomic multi-command saves. Existing commands, policies, groups, usage totals, overlays, tokens, scenes, sources, settings, databases, plugin data, models, uploads, feedback, and secrets are preserved.", + "plugins": { + "auto-vc": "0.1.6", + "birthday": "0.1.3", + "economy-framework": "0.2.10", + "economy-games": "0.1.7", + "expression-interaction": "0.2.1", + "lumi_ai": "0.8.5", + "moderation": "0.1.5", + "okf": "0.1.1", + "quotes": "0.1.2", + "sample-plugin": "0.1.0", + "throne_wishlist": "0.1.2", + "welcome_messages": "0.1.1" + }, + "tools": { + "lumi_ai_web_search": "0.1.1" + } + }, { "version": "0.2.18", "ref": "refs/tags/v0.2.18", diff --git a/scripts/verify-command-policies.js b/scripts/verify-command-policies.js index 85caa4f..8dbf2d4 100644 --- a/scripts/verify-command-policies.js +++ b/scripts/verify-command-policies.js @@ -5,14 +5,18 @@ const path = require("path"); const root = path.join(__dirname, ".."); const sandbox = fs.mkdtempSync(path.join(root, ".tmp-lumi-command-policies-")); const serviceDir = path.join(sandbox, "src", "services"); +let database = null; (async () => { try { fs.cpSync(path.join(root, "src", "services"), serviceDir, { recursive: true }); - const database = require(path.join(serviceDir, "db.js")); + database = require(path.join(serviceDir, "db.js")); database.migrate(); const conditional = require(path.join(serviceDir, "command-conditional.js")); const policies = require(path.join(serviceDir, "command-policies.js")); + const defaultGroup = policies.listGroups().find((group) => group.is_default); + assert(defaultGroup, "a protected default command group must be created automatically"); + assert.throws(() => policies.deleteGroup(defaultGroup.id), /cannot be deleted/); const replies = conditional.normalizeConditionalReplies([ { keyword: "backseating", response: "Please avoid backseating." }, @@ -43,6 +47,18 @@ try { assert.strictEqual(resolved.windows[0].value.uses, 1); assert.strictEqual(resolved.requirements.length, 0, "a direct public setting must replace group roles"); assert.strictEqual(resolved.cost, 2, "a direct cost must replace the group cost"); + policies.updateGroup(defaultGroup.id, { + name: defaultGroup.name, + description: defaultGroup.description, + policy: { window: { uses: 4, seconds: 90, scope: "global" } } + }); + const inheritedKey = policies.commandKey("verification", "inherited"); + assert.strictEqual(policies.resolvePolicy(inheritedKey).windows[0].value.uses, 4, "the default group must apply without explicit membership"); + policies.saveCommandPolicies([ + { commandKey: inheritedKey, policy: { cost: { amount: 3 } }, groupIds: [] }, + { commandKey: policies.commandKey("verification", "second"), policy: { requirement: false }, groupIds: [groupId] } + ]); + assert.strictEqual(policies.resolvePolicy(inheritedKey).cost, 3, "batch saves must persist every submitted command"); const charges = []; global.lumiFrameworks = { @@ -73,6 +89,7 @@ try { const dbColumns = database.db.prepare("PRAGMA table_info(custom_commands)").all().map((column) => column.name); assert(dbColumns.includes("conditional_replies_json")); assert(dbColumns.includes("conditional_fuzzy")); + assert(database.db.prepare("PRAGMA table_info(command_groups)").all().some((column) => column.name === "is_default")); for (const table of ["command_groups", "command_group_members", "command_policies", "command_policy_uses", "command_stream_sessions"]) { assert(database.db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?").get(table), `missing ${table}`); } @@ -81,13 +98,17 @@ try { const view = fs.readFileSync(path.join(root, "src", "web", "views", "admin-command-policies.ejs"), "utf8"); const commandView = fs.readFileSync(path.join(root, "src", "web", "views", "admin-commands.ejs"), "utf8"); assert(server.includes('/admin/command-policies') && server.includes('commandPolicyFromBody')); - assert(view.includes("Command groups") && view.includes("Save command settings")); + assert(view.includes("Command groups") && view.includes("Save all command settings")); + assert(view.includes("data-policy-search") && view.includes("data-policy-setting-filter")); + assert(view.includes("Protected default") && view.includes("data-command-policy-batch")); assert(commandView.includes('value="conditional"') && commandView.includes("data-conditional-replies")); database.db.close(); + database = null; console.log("Command policies and conditional commands verified."); } finally { - fs.rmSync(sandbox, { recursive: true, force: true }); + try { database?.db?.close?.(); } catch {} + fs.rmSync(sandbox, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); } })().catch((error) => { console.error(error); diff --git a/scripts/verify-release-metadata.js b/scripts/verify-release-metadata.js index 78e0a6f..a1a66c7 100644 --- a/scripts/verify-release-metadata.js +++ b/scripts/verify-release-metadata.js @@ -4,8 +4,8 @@ const path = require("path"); const { findSafeTarget } = require("../src/services/versioning"); const root = path.join(__dirname, ".."); -const releaseVersion = "0.2.18"; -const previousCoreVersion = "0.2.17"; +const releaseVersion = "0.2.19"; +const previousCoreVersion = "0.2.18"; const earliestCompatibleCoreVersion = "0.1.9"; const changedPlugins = { "auto-vc": { from: "0.1.5", to: "0.1.6", knowledge: "auto-vc" }, @@ -87,4 +87,4 @@ assert.equal(webSearch.minimum_lumi_version, "0.2.0"); assert.equal(webSearch.minimum_lumi_ai_version, "0.8.2"); assert.equal(hasVersionHeading(readText("plugins/lumi_ai_web_search/CHANGELOG.md"), webSearch.version), true); -console.log("Release metadata verification passed: core 0.2.18, Lumi AI 0.8.5, and synchronized package metadata."); +console.log("Release metadata verification passed: core 0.2.19, Lumi AI 0.8.5, and synchronized package metadata."); diff --git a/scripts/verify-update-system.js b/scripts/verify-update-system.js index 9d4f6e2..511845c 100644 --- a/scripts/verify-update-system.js +++ b/scripts/verify-update-system.js @@ -16,7 +16,7 @@ function readJson(relativePath) { const releaseIndex = readJson("release-index.json"); const releaseVersions = releaseIndex.releases.map((release) => release.version); -assert.deepEqual(releaseVersions, ["0.2.18", "0.2.17", "0.2.16", "0.2.15", "0.2.14", "0.2.13", "0.2.12", "0.2.11", "0.2.10", "0.2.9", "0.2.8", "0.2.7", "0.2.6", "0.2.5", "0.2.4", "0.2.3", "0.2.2", "0.2.1", "0.2.0", "0.1.9"]); +assert.deepEqual(releaseVersions, ["0.2.19", "0.2.18", "0.2.17", "0.2.16", "0.2.15", "0.2.14", "0.2.13", "0.2.12", "0.2.11", "0.2.10", "0.2.9", "0.2.8", "0.2.7", "0.2.6", "0.2.5", "0.2.4", "0.2.3", "0.2.2", "0.2.1", "0.2.0", "0.1.9"]); assert.equal(new Set(releaseVersions).size, releaseVersions.length, "release versions must be unique"); for (const release of releaseIndex.releases) { assert.equal(normalizeRepositoryRef(release.ref), release.ref); @@ -37,6 +37,7 @@ for (const [toolId, version] of Object.entries(currentRelease.tools)) { const baseTarget = { current_version: "0.2.4", available_versions: [ + { version: "0.2.19", ref: "refs/tags/v0.2.19", rollback_safe: true }, { version: "0.2.18", ref: "refs/tags/v0.2.18", rollback_safe: true }, { version: "0.2.17", ref: "refs/tags/v0.2.17", rollback_safe: true }, { version: "0.2.16", ref: "refs/tags/v0.2.16", rollback_safe: true }, @@ -78,7 +79,7 @@ const corrected = buildStatus({ channel: "stable" }); assert.equal(corrected.version_correction, true); -assert.equal(corrected.safe_target_version, "0.2.18"); +assert.equal(corrected.safe_target_version, "0.2.19"); assert.equal(corrected.update_available, true); assert.equal(corrected.blocked, false); diff --git a/src/services/command-policies.js b/src/services/command-policies.js index ba828bd..64a964f 100644 --- a/src/services/command-policies.js +++ b/src/services/command-policies.js @@ -6,6 +6,8 @@ const { createLogger } = require("./logger"); const policyLog = createLogger("core:command-policies", { category: "command" }); const STREAM_RESTART_TOLERANCE_MS = 2 * 60 * 60 * 1000; +const DEFAULT_GROUP_ID = "core-default"; +let defaultGroupEnsured = false; function commandKey(pluginId, commandId) { return `premade:${String(pluginId || "core")}:${String(commandId || "command")}`; @@ -16,12 +18,31 @@ function customCommandKey(id) { } function listGroups() { + ensureDefaultGroup(); return db.prepare("SELECT * FROM command_groups ORDER BY name COLLATE NOCASE").all().map((row) => ({ ...row, + is_default: Boolean(row.is_default), policy: parsePolicy(row.policy_json) })); } +function ensureDefaultGroup() { + if (defaultGroupEnsured) return; + const now = Date.now(); + db.prepare( + "INSERT OR IGNORE INTO command_groups (id, name, description, policy_json, is_default, created_at, updated_at) VALUES (?, ?, ?, ?, 1, ?, ?)" + ).run( + DEFAULT_GROUP_ID, + "All commands (defaults)", + "Baseline settings inherited by every command unless that command overrides a category.", + "{}", + now, + now + ); + db.prepare("UPDATE command_groups SET is_default = 1 WHERE id = ?").run(DEFAULT_GROUP_ID); + defaultGroupEnsured = true; +} + function createGroup({ name, description = "", policy = {} }) { const id = crypto.randomUUID(); const now = Date.now(); @@ -36,6 +57,9 @@ function updateGroup(id, { name, description = "", policy = {} }) { } function deleteGroup(id) { + ensureDefaultGroup(); + const group = db.prepare("SELECT is_default FROM command_groups WHERE id = ?").get(id); + if (group?.is_default) throw new Error("The default command group cannot be deleted."); db.transaction(() => { db.prepare("DELETE FROM command_group_members WHERE group_id = ?").run(id); db.prepare("DELETE FROM command_groups WHERE id = ?").run(id); @@ -43,42 +67,57 @@ function deleteGroup(id) { } function getCommandPolicy(commandKeyValue) { + ensureDefaultGroup(); const row = db.prepare("SELECT policy_json FROM command_policies WHERE command_key = ?").get(commandKeyValue); const groups = db.prepare( - "SELECT g.* FROM command_groups g JOIN command_group_members m ON m.group_id = g.id WHERE m.command_key = ? ORDER BY g.name COLLATE NOCASE" - ).all(commandKeyValue).map((group) => ({ ...group, policy: parsePolicy(group.policy_json) })); + "SELECT g.* FROM command_groups g JOIN command_group_members m ON m.group_id = g.id WHERE m.command_key = ? AND g.is_default = 0 ORDER BY g.name COLLATE NOCASE" + ).all(commandKeyValue).map((group) => ({ ...group, is_default: false, policy: parsePolicy(group.policy_json) })); return { policy: row ? parsePolicy(row.policy_json) : {}, groups }; } function saveCommandPolicy(commandKeyValue, policy, groupIds = []) { - const normalized = normalizeStoredPolicy(policy); + saveCommandPolicies([{ commandKey: commandKeyValue, policy, groupIds }]); +} + +function saveCommandPolicies(updates = []) { const now = Date.now(); db.transaction(() => { - if (Object.keys(normalized).length) { - db.prepare( - "INSERT INTO command_policies (command_key, policy_json, updated_at) VALUES (?, ?, ?) ON CONFLICT(command_key) DO UPDATE SET policy_json = excluded.policy_json, updated_at = excluded.updated_at" - ).run(commandKeyValue, JSON.stringify(normalized), now); - } else { - db.prepare("DELETE FROM command_policies WHERE command_key = ?").run(commandKeyValue); + const upsertPolicy = db.prepare( + "INSERT INTO command_policies (command_key, policy_json, updated_at) VALUES (?, ?, ?) ON CONFLICT(command_key) DO UPDATE SET policy_json = excluded.policy_json, updated_at = excluded.updated_at" + ); + const deletePolicy = db.prepare("DELETE FROM command_policies WHERE command_key = ?"); + const deleteMembers = db.prepare("DELETE FROM command_group_members WHERE command_key = ?"); + const insertMember = db.prepare("INSERT OR IGNORE INTO command_group_members (group_id, command_key, created_at) VALUES (?, ?, ?)"); + for (const update of updates) { + const commandKeyValue = String(update?.commandKey || ""); + if (!commandKeyValue) throw new Error("Command key is required."); + const normalized = normalizeStoredPolicy(update.policy); + if (Object.keys(normalized).length) upsertPolicy.run(commandKeyValue, JSON.stringify(normalized), now); + else deletePolicy.run(commandKeyValue); + deleteMembers.run(commandKeyValue); + for (const groupId of [...new Set((update.groupIds || []).map(String))]) { + if (groupId !== DEFAULT_GROUP_ID) insertMember.run(groupId, commandKeyValue, now); + } } - db.prepare("DELETE FROM command_group_members WHERE command_key = ?").run(commandKeyValue); - const insert = db.prepare("INSERT OR IGNORE INTO command_group_members (group_id, command_key, created_at) VALUES (?, ?, ?)"); - for (const groupId of [...new Set(groupIds.map(String))]) insert.run(groupId, commandKeyValue, now); })(); } -function resolvePolicy(commandKeyValue, defaultPolicy = {}) { +function resolvePolicy(commandKeyValue, defaultPolicy = {}, { ignoreDirect = [] } = {}) { const stored = getCommandPolicy(commandKeyValue); - const direct = stored.policy; + const ignored = new Set(ignoreDirect); + const direct = Object.fromEntries(Object.entries(stored.policy).filter(([name]) => !ignored.has(name))); const groupPolicies = stored.groups.map((group) => ({ id: group.id, name: group.name, policy: group.policy })); + const defaultRow = db.prepare("SELECT * FROM command_groups WHERE id = ?").get(DEFAULT_GROUP_ID); + const globalDefault = defaultRow ? { id: defaultRow.id, name: defaultRow.name, policy: parsePolicy(defaultRow.policy_json) } : null; const defaults = normalizeStoredPolicy(defaultPolicy); return { - windows: resolveRules(commandKeyValue, "window", direct, groupPolicies, defaults), - perStreams: resolveRules(commandKeyValue, "perStream", direct, groupPolicies, defaults), - requirements: resolveRequirements(direct, groupPolicies, defaults), - cost: resolveCost(direct, groupPolicies, defaults), + windows: resolveRules(commandKeyValue, "window", direct, groupPolicies, globalDefault, defaults), + perStreams: resolveRules(commandKeyValue, "perStream", direct, groupPolicies, globalDefault, defaults), + requirements: resolveRequirements(direct, groupPolicies, globalDefault, defaults), + cost: resolveCost(direct, groupPolicies, globalDefault, defaults), direct, - groups: stored.groups + groups: stored.groups, + defaultGroup: globalDefault }; } @@ -224,7 +263,7 @@ async function resolveStreamSession(ctx) { return sessionId; } -function resolveRules(commandKeyValue, category, direct, groups, defaults) { +function resolveRules(commandKeyValue, category, direct, groups, globalDefault, defaults) { if (Object.prototype.hasOwnProperty.call(direct, category)) { return direct[category] === false ? [] : [{ key: `${commandKeyValue}:${category}`, source: "command", value: direct[category] }]; } @@ -232,26 +271,40 @@ function resolveRules(commandKeyValue, category, direct, groups, defaults) { const value = group.policy[category]; return value && value !== false ? [{ key: `group:${group.id}:${category}`, source: group.name, value }] : []; }); + const defaultHasCategory = Boolean(globalDefault && Object.prototype.hasOwnProperty.call(globalDefault.policy, category)); + const defaultValue = defaultHasCategory ? globalDefault.policy[category] : undefined; + if (defaultValue && defaultValue !== false) { + rules.push({ key: `group:${globalDefault.id}:${category}`, source: globalDefault.name, value: defaultValue }); + } if (rules.length) return rules; + if (defaultHasCategory && defaultValue === false) return []; const fallback = defaults[category]; return fallback && fallback !== false ? [{ key: `${commandKeyValue}:default:${category}`, source: "default", value: fallback }] : []; } -function resolveRequirements(direct, groups, defaults) { +function resolveRequirements(direct, groups, globalDefault, defaults) { if (Object.prototype.hasOwnProperty.call(direct, "requirement")) { return direct.requirement === false ? [] : [{ source: "command", value: direct.requirement }]; } const values = groups.flatMap((group) => group.policy.requirement && group.policy.requirement !== false ? [{ source: group.name, value: group.policy.requirement }] : []); + const defaultHasRequirement = Boolean(globalDefault && Object.prototype.hasOwnProperty.call(globalDefault.policy, "requirement")); + const defaultRequirement = defaultHasRequirement ? globalDefault.policy.requirement : undefined; + if (defaultRequirement && defaultRequirement !== false) values.push({ source: globalDefault.name, value: defaultRequirement }); if (values.length) return values; + if (defaultHasRequirement && defaultRequirement === false) return []; return defaults.requirement && defaults.requirement !== false ? [{ source: "default", value: defaults.requirement }] : []; } -function resolveCost(direct, groups, defaults) { +function resolveCost(direct, groups, globalDefault, defaults) { if (Object.prototype.hasOwnProperty.call(direct, "cost")) return direct.cost === false ? 0 : Number(direct.cost?.amount || 0); const costs = groups.map((group) => Number(group.policy.cost?.amount || 0)).filter((amount) => amount > 0); + const defaultHasCost = Boolean(globalDefault && Object.prototype.hasOwnProperty.call(globalDefault.policy, "cost")); + const defaultCost = defaultHasCost ? Number(globalDefault.policy.cost?.amount || 0) : 0; + if (defaultCost > 0) costs.push(defaultCost); if (costs.length) return Math.max(...costs); + if (defaultHasCost && globalDefault.policy.cost === false) return 0; return Number(defaults.cost?.amount || 0); } @@ -315,17 +368,20 @@ function formatDuration(seconds) { } module.exports = { + DEFAULT_GROUP_ID, STREAM_RESTART_TOLERANCE_MS, beginCommandUse, commandKey, createGroup, customCommandKey, deleteGroup, + ensureDefaultGroup, getCommandPolicy, labelRequirement, listGroups, normalizeStoredPolicy, resolvePolicy, saveCommandPolicy, + saveCommandPolicies, updateGroup }; diff --git a/src/services/db.js b/src/services/db.js index 4b6a68b..c383723 100644 --- a/src/services/db.js +++ b/src/services/db.js @@ -152,6 +152,7 @@ function migrate() { name TEXT NOT NULL UNIQUE COLLATE NOCASE, description TEXT NOT NULL DEFAULT '', policy_json TEXT NOT NULL DEFAULT '{}', + is_default INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL ); @@ -410,6 +411,14 @@ function migrate() { if (!columns.includes("conditional_fuzzy")) { db.exec("ALTER TABLE custom_commands ADD COLUMN conditional_fuzzy INTEGER NOT NULL DEFAULT 1"); } + + const commandGroupColumns = db + .prepare("PRAGMA table_info(command_groups)") + .all() + .map((column) => column.name); + if (!commandGroupColumns.includes("is_default")) { + db.exec("ALTER TABLE command_groups ADD COLUMN is_default INTEGER NOT NULL DEFAULT 0"); + } if (!columns.includes("rng_enabled")) { db.exec("ALTER TABLE custom_commands ADD COLUMN rng_enabled INTEGER NOT NULL DEFAULT 0"); } diff --git a/src/web/public/lumi-components.css b/src/web/public/lumi-components.css index 76b65fc..7a2ef82 100644 --- a/src/web/public/lumi-components.css +++ b/src/web/public/lumi-components.css @@ -1038,14 +1038,34 @@ input[type="color"] { .command-policy-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: var(--lumi-space-3); + gap: var(--lumi-space-4); } .command-policy-section { display: grid; align-content: start; - gap: var(--lumi-space-2); + gap: var(--lumi-space-3); margin: 0; + padding: var(--lumi-space-4); + border: 1px solid var(--lumi-border-strong); + border-radius: var(--lumi-radius-md); + background: var(--lumi-surface-subtle); + box-shadow: inset 0 3px 0 color-mix(in srgb, var(--lumi-accent) 32%, transparent); +} + +.command-policy-section legend { + padding: 0 var(--lumi-space-2); + color: var(--lumi-text); + font-weight: 750; +} + +.command-policy-values[hidden], +.command-policy-inherited[hidden], +.command-policy-no-results[hidden], +.command-policy-suggestions[hidden], +[data-policy-item][hidden], +[data-subscriber-tier][hidden] { + display: none !important; } .command-policy-values { @@ -1054,10 +1074,148 @@ input[type="color"] { gap: var(--lumi-space-2); } -.command-policy-item > form { +.command-policy-inherited { + display: grid; + gap: var(--lumi-space-1); + padding: var(--lumi-space-3); + border-left: 3px solid var(--lumi-accent); + border-radius: var(--lumi-radius-sm); + background: var(--lumi-surface); +} + +.command-policy-inherited span { + color: var(--lumi-text-muted); + font-size: var(--lumi-font-size-sm); +} + +.command-policy-item > form, +.command-policy-item > .command-policy-editor { margin-top: var(--lumi-space-3); } +.command-policy-item[open] { + border-color: var(--lumi-border-strong); + background: var(--lumi-surface); +} + +.command-policy-item[open] > summary { + border-bottom: 1px solid var(--lumi-border); +} + +.command-policy-item.is-default-group { + border-color: color-mix(in srgb, var(--lumi-accent) 58%, var(--lumi-border)); +} + +.command-policy-summary-copy { + display: grid; + gap: var(--lumi-space-1); + min-width: 0; +} + +.command-policy-title { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--lumi-space-2); +} + +.command-policy-toolbar { + position: relative; + display: grid; + grid-template-columns: minmax(15rem, 2fr) minmax(12rem, 1fr) auto; + gap: var(--lumi-space-3); + align-items: end; + margin-bottom: var(--lumi-space-4); + padding: var(--lumi-space-3); + border: 1px solid var(--lumi-border); + border-radius: var(--lumi-radius-md); + background: var(--lumi-surface-subtle); +} + +.command-policy-command-toolbar { + grid-template-columns: minmax(16rem, 2fr) repeat(2, minmax(11rem, 1fr)) auto; +} + +.command-policy-search { + position: relative; +} + +.command-policy-suggestions { + position: absolute; + z-index: 20; + top: calc(100% + var(--lumi-space-1)); + left: 0; + right: 0; + display: grid; + overflow: hidden; + border: 1px solid var(--lumi-border-strong); + border-radius: var(--lumi-radius-sm); + background: var(--lumi-surface-raised); + box-shadow: var(--lumi-shadow-lg); +} + +.command-policy-suggestions button { + width: 100%; + padding: var(--lumi-space-2) var(--lumi-space-3); + border: 0; + border-bottom: 1px solid var(--lumi-border); + border-radius: 0; + background: transparent; + color: var(--lumi-text); + text-align: left; +} + +.command-policy-suggestions button:hover, +.command-policy-suggestions button:focus-visible { + background: var(--lumi-surface-subtle); +} + +.command-policy-result-count { + align-self: center; + white-space: nowrap; +} + +.command-policy-savebar { + position: sticky; + z-index: 8; + top: var(--lumi-space-2); + display: flex; + justify-content: space-between; + align-items: center; + gap: var(--lumi-space-3); + margin-bottom: var(--lumi-space-3); + padding: var(--lumi-space-3); + border: 1px solid var(--lumi-border-strong); + border-radius: var(--lumi-radius-md); + background: var(--lumi-surface-raised); + box-shadow: var(--lumi-shadow-md); +} + +.command-policy-savebar > span:first-child { + display: grid; + gap: var(--lumi-space-1); +} + +.command-policy-savebar.is-bottom { + position: static; + margin-top: var(--lumi-space-4); + margin-bottom: 0; +} + +.command-policy-groups { + padding: var(--lumi-space-4); + border-color: var(--lumi-border-strong); + background: var(--lumi-surface-subtle); +} + +.command-policy-no-results { + padding: var(--lumi-space-4); + border: 1px dashed var(--lumi-border-strong); + border-radius: var(--lumi-radius-md); + color: var(--lumi-text-muted); + text-align: center; +} + .command-rng-range { display: grid; grid-template-columns: repeat(2, minmax(8rem, 1fr)); @@ -1845,11 +2003,23 @@ details > summary:focus-visible { .command-random-row, .command-policy-grid, + .command-policy-toolbar, + .command-policy-command-toolbar, .command-rng-range, .custom-placeholder-row { grid-template-columns: 1fr; } + .command-policy-savebar { + position: static; + align-items: stretch; + flex-direction: column; + } + + .command-policy-savebar .button { + width: 100%; + } + .homepage-hero-media { min-height: 12rem; } diff --git a/src/web/public/lumi-tokens.css b/src/web/public/lumi-tokens.css index 3ac0233..710d3ec 100644 --- a/src/web/public/lumi-tokens.css +++ b/src/web/public/lumi-tokens.css @@ -21,6 +21,8 @@ --lumi-surface-subtle: var(--surface-2, #f4f7f8); --lumi-surface-raised: var(--surface-3, #edf2f3); --lumi-border: var(--border, #d8e0e3); + --lumi-border-strong: color-mix(in srgb, var(--lumi-border) 72%, var(--lumi-text)); + --lumi-font-size-sm: 0.875rem; --lumi-input-bg: var(--lumi-surface); --lumi-input-border: var(--lumi-border); --lumi-input-text: var(--lumi-text); diff --git a/src/web/server.js b/src/web/server.js index f9dc7da..9801c99 100644 --- a/src/web/server.js +++ b/src/web/server.js @@ -86,10 +86,12 @@ const { customCommandKey, deleteGroup: deleteCommandGroup, getCommandPolicy, + labelRequirement, listGroups: listCommandGroups, normalizeStoredPolicy, resolvePolicy, saveCommandPolicy, + saveCommandPolicies, updateGroup: updateCommandGroup } = require("../services/command-policies"); const { @@ -1373,31 +1375,32 @@ function buildCustomCommandListPreview(command) { }; } -function commandPolicyFromBody(body = {}, { allowInherit = true } = {}) { +function commandPolicyFromBody(body = {}, { allowInherit = true, prefix = "" } = {}) { const output = {}; - const mode = (name) => String(body[`${name}_mode`] || (allowInherit ? "inherit" : "off")); + const field = (name) => body[`${prefix}${name}`]; + const mode = (name) => String(field(`${name}_mode`) || (allowInherit ? "inherit" : "off")); const assign = (name, value) => { const selected = mode(name); if (selected === "inherit" && allowInherit) return; output[name] = selected === "off" ? false : value; }; assign("window", { - uses: body.window_uses, - seconds: body.window_seconds, - scope: body.window_scope + uses: field("window_uses"), + seconds: field("window_seconds"), + scope: field("window_scope") }); assign("perStream", { - uses: body.perStream_uses, - scope: body.perStream_scope + uses: field("perStream_uses"), + scope: field("perStream_scope") }); const requirementMode = mode("requirement"); if (!(allowInherit && requirementMode === "inherit")) { output.requirement = requirementMode === "off" ? false : { - role: body.requirement_role, - subscriberTier: body.requirement_subscriber_tier + role: field("requirement_role"), + subscriberTier: field("requirement_subscriber_tier") }; } - assign("cost", { amount: body.cost_amount }); + assign("cost", { amount: field("cost_amount") }); return normalizeStoredPolicy(output); } @@ -1419,6 +1422,34 @@ function commandPolicyPresentation(key, defaultPolicy = {}) { }; } +function commandPolicyEffectivePresentation(key, defaultPolicy = {}) { + const resolved = resolvePolicy(key, defaultPolicy); + const inheritedWindow = resolvePolicy(key, defaultPolicy, { ignoreDirect: ["window"] }); + const inheritedStream = resolvePolicy(key, defaultPolicy, { ignoreDirect: ["perStream"] }); + const inheritedRequirement = resolvePolicy(key, defaultPolicy, { ignoreDirect: ["requirement"] }); + const inheritedCost = resolvePolicy(key, defaultPolicy, { ignoreDirect: ["cost"] }); + const formatScope = (scope) => scope === "global" ? "shared by everyone" : "per user"; + const joinRules = (rules, formatter, empty) => rules.length + ? rules.map((rule) => `${rule.source}: ${formatter(rule.value)}`).join(" · ") + : empty; + const assignedGroups = resolved.groups || []; + return { + window: joinRules(inheritedWindow.windows, (value) => `${value.uses} use${value.uses === 1 ? "" : "s"} per ${value.seconds} seconds, ${formatScope(value.scope)}`, "No time-window limit."), + perStream: joinRules(inheritedStream.perStreams, (value) => `${value.uses} use${value.uses === 1 ? "" : "s"} per stream, ${formatScope(value.scope)}`, "No per-stream limit."), + requirement: inheritedRequirement.requirements.length + ? inheritedRequirement.requirements.map((item) => `${item.source}: ${labelRequirement(item.value.role, item.value.subscriberTier)}`).join(" · ") + : "Everyone may use it.", + cost: inheritedCost.cost > 0 ? `${inheritedCost.cost} currency per successful run.` : "Free to use.", + flags: { + rate: resolved.windows.length > 0, + stream: resolved.perStreams.length > 0, + access: resolved.requirements.length > 0, + cost: resolved.cost > 0, + groups: assignedGroups.length > 0 + } + }; +} + function customPlaceholdersFromBody(body = {}) { const array = (value) => Array.isArray(value) ? value : value === undefined ? [] : [value]; const names = array(body.custom_placeholder_name); @@ -3089,7 +3120,7 @@ function createWebServer({ loadPlugins, discordClient, commandRouter }) { store: sessionStore }) ); - app.use(express.urlencoded({ extended: false })); + app.use(express.urlencoded({ extended: false, limit: "1mb", parameterLimit: 20000 })); app.use(express.json({ limit: "1mb" })); app.use((req, res, next) => { const requestId = crypto.randomUUID(); @@ -6334,30 +6365,52 @@ function createWebServer({ loadPlugins, discordClient, commandRouter }) { }); app.get("/admin/command-policies", requireRole("admin"), (req, res) => { + const pluginNames = new Map(getPlugins().map((plugin) => [plugin.id, plugin.name || plugin.id])); + const allGroups = listCommandGroups(); + const defaultGroup = allGroups.find((group) => group.is_default) || null; + const groups = allGroups.filter((group) => !group.is_default); const premade = (commandRouter?.listCommands?.() || []).map((command) => ({ key: command.key, label: `!${command.triggers[0] || command.id}`, - description: command.description || `${command.pluginId} command`, - origin: command.pluginId === "core" ? "Core" : command.pluginId, + description: command.description || "No description provided.", + origin: command.pluginId === "core" ? "Built-in command" : `${pluginNames.get(command.pluginId) || command.pluginId} plugin`, defaultPolicy: command.defaultPolicy || {}, platforms: command.platforms || [] })); const custom = db.prepare("SELECT id, trigger, description, platform FROM custom_commands ORDER BY trigger").all().map((command) => ({ key: customCommandKey(command.id), label: `!${command.trigger}`, - description: command.description || "Custom command", - origin: "Custom", + description: command.description || "No description provided.", + origin: "Custom command", defaultPolicy: {}, platforms: normalizePlatformSelection(command.platform, getEnabledPlatformIds()) })); const commands = [...premade, ...custom] .filter((command, index, all) => all.findIndex((item) => item.key === command.key) === index) .sort((left, right) => left.label.localeCompare(right.label)) - .map((command) => ({ ...command, ...getCommandPolicy(command.key) })); + .map((command) => { + const stored = getCommandPolicy(command.key); + const effective = commandPolicyEffectivePresentation(command.key, command.defaultPolicy); + return { + ...command, + ...stored, + effective, + searchText: [command.label, command.origin, command.description, ...stored.groups.map((group) => group.name)].join(" ").toLowerCase(), + settingTokens: [ + effective.flags.rate && "rate", + effective.flags.stream && "stream", + effective.flags.access && "access", + effective.flags.cost && "cost", + effective.flags.groups && "groups", + Object.keys(stored.policy).length && "overrides" + ].filter(Boolean) + }; + }); res.render("admin-command-policies", { title: "Command access and limits", commands, - groups: listCommandGroups() + groups, + defaultGroup }); }); @@ -6377,10 +6430,12 @@ function createWebServer({ loadPlugins, discordClient, commandRouter }) { app.post("/admin/command-policies/groups/:id", requireRole("admin"), (req, res) => { try { + const existing = listCommandGroups().find((group) => group.id === req.params.id); + if (!existing) throw new Error("That command group no longer exists."); updateCommandGroup(req.params.id, { name: req.body.name, description: req.body.description, - policy: commandPolicyFromBody(req.body, { allowInherit: false }) + policy: commandPolicyFromBody(req.body, { allowInherit: Boolean(existing.is_default) }) }); setFlash(req, "success", "Command group updated."); } catch (error) { @@ -6390,11 +6445,46 @@ function createWebServer({ loadPlugins, discordClient, commandRouter }) { }); app.post("/admin/command-policies/groups/:id/delete", requireRole("admin"), (req, res) => { - deleteCommandGroup(req.params.id); - setFlash(req, "success", "Command group deleted. Its commands keep their direct settings."); + try { + deleteCommandGroup(req.params.id); + setFlash(req, "success", "Command group deleted. Its commands keep their direct settings."); + } catch (error) { + setFlash(req, "error", error?.message || "The command group could not be deleted."); + } res.redirect("/admin/command-policies"); }); + app.post("/admin/command-policies/commands", requireRole("admin"), (req, res) => { + const knownKeys = new Set([ + ...(commandRouter?.listCommands?.() || []).map((command) => command.key), + ...db.prepare("SELECT id FROM custom_commands").all().map((command) => customCommandKey(command.id)) + ]); + const validGroups = new Set(listCommandGroups().filter((group) => !group.is_default).map((group) => group.id)); + const count = Math.min(2000, Math.max(0, Number(req.body.command_count) || 0)); + const updates = []; + for (let index = 0; index < count; index += 1) { + const prefix = `command_${index}_`; + const key = String(req.body[`${prefix}key`] || ""); + if (!knownKeys.has(key)) continue; + updates.push({ + commandKey: key, + policy: commandPolicyFromBody(req.body, { prefix }), + groupIds: asStringArray(req.body[`${prefix}group_ids`]).filter((id) => validGroups.has(id)) + }); + } + if (!updates.length) { + setFlash(req, "error", "No available command settings were submitted."); + return res.redirect("/admin/command-policies"); + } + try { + saveCommandPolicies(updates); + setFlash(req, "success", `${updates.length} command${updates.length === 1 ? "" : "s"} saved.`); + } catch (error) { + setFlash(req, "error", error?.message || "Command settings could not be saved."); + } + return res.redirect("/admin/command-policies"); + }); + app.post("/admin/command-policies/command", requireRole("admin"), (req, res) => { const key = String(req.body.command_key || ""); const knownKeys = new Set([ diff --git a/src/web/views/admin-command-policies.ejs b/src/web/views/admin-command-policies.ejs index ae1d4b3..bb4e973 100644 --- a/src/web/views/admin-command-policies.ejs +++ b/src/web/views/admin-command-policies.ejs @@ -1,36 +1,83 @@ <%- include("partials/layout-top", { title }) %> +<% + const hasSetting = (policy, name) => Object.prototype.hasOwnProperty.call(policy || {}, name); + const settingTokens = (policy, extra = []) => [ + hasSetting(policy, "window") && "rate", + hasSetting(policy, "perStream") && "stream", + hasSetting(policy, "requirement") && "access", + hasSetting(policy, "cost") && "cost", + ...extra + ].filter(Boolean).join(" "); + const commandOrigins = [...new Set(commands.map((command) => command.origin))].sort((a, b) => a.localeCompare(b)); +%>
<%- include("partials/page-header", { eyebrow: "Chat tools", pageTitle: "Command access and limits", - description: "Keep common rules in groups, then override only the settings that are different for an individual command.", + description: "Set a baseline once, reuse groups for related commands, and override only genuine exceptions.", actions: 'Custom commands' }) %>
How settings combine -

Commands inherit their groups. If several groups are assigned, every group limit and role requirement applies; the highest group currency cost is used. Choosing a setting directly on a command replaces all group and built-in values for that one category.

+

The protected default group applies to every command. Other assigned groups add their restrictions. A setting chosen directly on a command replaces every group and built-in value for that category.

-
-

Command groups

Create a reusable set of access, rate, stream, and currency rules.

-
- Create command groupFor example: Public fun, Moderator tools, or Expensive commands +
+

Command groups

Manage the global baseline and reusable rules for related commands.

+
+ + + +
+ + <% if (defaultGroup) { %> +
+ + <%= defaultGroup.name %>Protected default<%= defaultGroup.description %> + +
+
+
+
Inherited by every command

“Keep command built-ins” leaves that category untouched. Choose an explicit value to replace built-in defaults globally.

+
<%- include("partials/command-policy-fields", { policy: defaultGroup.policy, allowInherit: true, namePrefix: "", effective: null, inheritLabel: "Keep command built-ins" }) %>
+
+
+
+ <% } %> + +
+ Create command groupFor example: Public fun, Moderator tools, or Expensive commands
-
<%- include("partials/command-policy-fields", { policy: {}, allowInherit: false }) %>
+
<%- include("partials/command-policy-fields", { policy: {}, allowInherit: false, namePrefix: "", effective: null }) %>
- <% if (!groups.length) { %>

No command groups yet.

<% } %> + <% groups.forEach((group) => { %> -
- <%= group.name %><%= group.description || "Reusable command rules" %> +
+ <%= group.name %><%= group.description || "Reusable command rules" %>
-
<%- include("partials/command-policy-fields", { policy: group.policy, allowInherit: false }) %>
+
<%- include("partials/command-policy-fields", { policy: group.policy, allowInherit: false, namePrefix: "", effective: null }) %>
@@ -38,42 +85,213 @@
<% }) %> +
-
-

Individual commands

Assign groups or override a category for one command.

+
+

Individual commands

Search, compare effective values, and save every command edit together.

+
+ + + + +
+ <% if (!commands.length) { %>

No commands are currently registered.

<% } %> - <% commands.forEach((command) => { %> -
- - <%= command.label %><%= command.origin %> · <%= command.description %> - <% if (command.groups.length) { %><%= command.groups.length %> group<%= command.groups.length === 1 ? "" : "s" %><% } %> - -
- -
- Groups -
- <% groups.forEach((group) => { %> - - <% }) %> -
- <% if (!groups.length) { %>

Create a group above first, or configure this command directly.

<% } %> -
- <% if (Object.keys(command.defaultPolicy || {}).length) { %> -
Safe built-in defaults

This command has conservative built-in settings. Leave a category on “Use group/default” to keep them unless a group supplies that category.

- <% } %> -
<%- include("partials/command-policy-fields", { policy: command.policy, allowInherit: true }) %>
-
-
-
- <% }) %> + <% if (commands.length) { %> +
+ +
+ Command settingsAll displayed values are saved. + +
+ <% commands.forEach((command, index) => { %> + <% const prefix = `command_${index}_`; %> +
+ + + <%= command.label %><%= command.origin %> + <%= command.description %> + + <% if (command.groups.length) { %><%= command.groups.length %> assigned group<%= command.groups.length === 1 ? "" : "s" %><% } %> + +
+ +
+ Assigned groups +
+ <% groups.forEach((group) => { %> + + <% }) %> +
+ <% if (!groups.length) { %>

Create a reusable group above, or configure this command directly.

<% } %> +

The protected default group already applies automatically and does not need to be assigned.

+
+
<%- include("partials/command-policy-fields", { policy: command.policy, allowInherit: true, namePrefix: prefix, effective: command.effective, inheritLabel: "Use groups/default" }) %>
+
+
+ <% }) %> + +
+ Every command on this page is submitted together, including collapsed commands. + +
+
+ <% } %>
<%- include("partials/layout-bottom") %> diff --git a/src/web/views/partials/command-policy-fields.ejs b/src/web/views/partials/command-policy-fields.ejs index 74ccae8..b6c7171 100644 --- a/src/web/views/partials/command-policy-fields.ejs +++ b/src/web/views/partials/command-policy-fields.ejs @@ -5,67 +5,76 @@ const streamValue = policy?.perStream && policy.perStream !== false ? policy.perStream : {}; const requirementValue = policy?.requirement && policy.requirement !== false ? policy.requirement : {}; const costValue = policy?.cost && policy.cost !== false ? policy.cost : {}; + const fieldPrefix = typeof namePrefix === "string" ? namePrefix : ""; + const fieldName = (name) => `${fieldPrefix}${name}`; + const effectiveValues = typeof effective === "object" && effective ? effective : {}; + const inheritOptionLabel = typeof inheritLabel === "string" && inheritLabel ? inheritLabel : "Use group/default"; + const inheritedText = (name, fallback) => effectiveValues[name] || fallback; %>
-
+
Rate limit + <% if (allowInherit) { %>
Currently applies<%= inheritedText("window", "This category keeps each command's built-in value.") %>
<% } %>
- - - + + +
-
+
Per stream + <% if (allowInherit) { %>
Currently applies<%= inheritedText("perStream", "This category keeps each command's built-in value.") %>
<% } %>
- - + +

A restarted stream stays in the same session for two hours, so a crash does not reset this limit.

-
+
Who may use it + <% if (allowInherit) { %>
Currently applies<%= inheritedText("requirement", "This category keeps each command's built-in value.") %>
<% } %>
- - +
-
+
Currency cost + <% if (allowInherit) { %>
Currently applies<%= inheritedText("cost", "This category keeps each command's built-in value.") %>
<% } %>
- +

The Economy Framework deducts and logs this like any other banking transaction.

diff --git a/update-manifest.json b/update-manifest.json index 8e6ff73..d853936 100644 --- a/update-manifest.json +++ b/update-manifest.json @@ -1,6 +1,6 @@ { "name": "Lumi Core", - "version": "0.2.18", + "version": "0.2.19", "channel": "stable", "released_at": "2026-07-19", "compatible_from": "0.1.9", @@ -8,7 +8,7 @@ "replaces_versions": [ "1.2.0" ], - "migration_notes": "Adds centralized command policies, conditional replies, platform clip and raid actions, and OBS replay saving. Existing commands, usage totals, overlays, website URLs, CSS, tokens, scenes, sources, settings, databases, logs, plugin data, community knowledge, AI models, runtimes, uploads, feedback, and secrets are preserved.", + "migration_notes": "Improves command-policy administration with a protected inherited default group, conditional effective-value summaries, fuzzy searching and filters, clearer command origins, and atomic multi-command saves. Existing commands, policies, groups, usage totals, overlays, website URLs, CSS, tokens, scenes, sources, settings, databases, logs, plugin data, community knowledge, AI models, runtimes, uploads, feedback, and secrets are preserved.", "rollback_safe": true, "requirements": [ "Node.js 18 or newer" @@ -241,6 +241,18 @@ ], "rollback_safe": true, "migration_notes": "Adds centralized command policies, conditional replies, platform clip and raid actions, and OBS replay saving; existing commands, usage totals, overlays, tokens, scenes, sources, settings, databases, plugin data, models, uploads, feedback, and secrets remain preserved." + }, + { + "version": "0.2.19", + "channel": "stable", + "released_at": "2026-07-19", + "compatible_from": "0.1.9", + "migration_kind": "patch", + "replaces_versions": [ + "1.2.0" + ], + "rollback_safe": true, + "migration_notes": "Improves command-policy administration with a protected inherited default group, conditional effective-value summaries, fuzzy searching and filters, clearer command origins, and atomic multi-command saves; existing commands, policies, groups, usage totals, overlays, tokens, scenes, sources, settings, databases, plugin data, models, uploads, feedback, and secrets remain preserved." } ] }