feat(cc-config): manage plugins, marketplaces, hooks and settings from the dashboard
The Claude Config page was read-only for these four surfaces. It can now: - register a marketplace from a pasted GitHub `owner/repo`, http(s) URL or absolute path, and install / enable / disable / update / uninstall plugins, all delegated to the `claude plugin …` CLI so its registry stays consistent; - edit hooks and settings.json keys in the user, project and project-local layers. Server: - `server/lib/cc-plugin-cli.js` wraps the CLI with `execFile` and an explicit argv array — never a shell string. Every argument is validated before the spawn (shape allowlist per kind, no control characters, no leading `-`, no `..`), `CLAUDE_CONFIG_DIR` is pinned to the dashboard's CLAUDE_HOME, stdin is closed so a prompt errors instead of hanging, and mutating calls are serialized in-process. - `cc-mutate.patchSettings` merges individual dotted keys into one settings layer. It re-reads the file unredacted, so keys the UI never displayed — including secret-valued ones — survive byte-for-byte. A payload carrying the `<redacted>` placeholder is refused (EREDACTED), as are `__proto__` / `prototype` / `constructor` segments; a settings file that does not parse is reported (EBADJSON) rather than clobbered. Backup first, then atomic write. - Six additive routes on /api/cc-config; a non-zero CLI exit is a 422 carrying the CLI's own message, a missing binary a 503. Client: install/add bars, per-card plugin and marketplace actions behind a two-click confirm, and edit modes for hooks and settings that send only the keys that changed. New i18n keys in en + vi; the explainer banners that said these surfaces were read-only now describe what the writes actually do. Tests: 61 new server tests (CLI argv construction and validation, the settings key-merge, and the six routes against a fake `claude` on PATH).
This commit is contained in:
+184
-3
@@ -5,10 +5,17 @@
|
||||
* skills, subagents, slash commands, output styles, CLAUDE.md memory, and
|
||||
* per-project file-based memory (~/.claude/projects/<slug>/memory/*.md).
|
||||
*
|
||||
* settings.json layers (user / project / project-local) are also writable, but
|
||||
* ONLY through `patchSettings`'s per-key merge — never as a whole-blob
|
||||
* overwrite. Hooks are edited through that same path (`hooks.<Event>`).
|
||||
*
|
||||
* Hard constraints (do not relax without a follow-up review):
|
||||
* - Plugins, MCP servers, hooks-in-settings, and settings.json files are
|
||||
* NEVER touched here. Those have concurrent-write races with the live
|
||||
* Claude Code CLI and need different handling.
|
||||
* - Plugins and MCP servers are NEVER touched here. Plugin state is written
|
||||
* by the `claude` CLI (see cc-plugin-cli.js); MCP config stays read-only.
|
||||
* - A settings write re-reads the file unredacted and merges the named keys
|
||||
* only. Writing back what `readSettings` returned would replace a real
|
||||
* secret with the literal `<redacted>`, so any payload carrying that
|
||||
* marker is refused.
|
||||
* - Every write/delete creates a timestamped backup BEFORE the mutation.
|
||||
* Backups land under <root>/cc-config-backups/<type>/, well outside the
|
||||
* directories Claude Code scans, so a deleted skill cannot reappear as
|
||||
@@ -439,6 +446,179 @@ function writeKeybindings(args = {}) {
|
||||
return { ok: true, file, target: file, backupPath, created: !existedBefore };
|
||||
}
|
||||
|
||||
// ── settings.json (key-merge only) ─────────────────────────────────────
|
||||
//
|
||||
// The three settings layers Claude Code reads. `project-local` is the
|
||||
// dashboard's name for `settings.local.json`; the plugin CLI calls the same
|
||||
// layer `local` (see cc-plugin-cli.js) — the two vocabularies are separate on
|
||||
// purpose and never share a validator.
|
||||
const SETTINGS_SCOPES = {
|
||||
user: (opts) => path.join(getClaudeHome(), "settings.json"),
|
||||
project: (opts) => path.join(getProjectClaudeDir(opts.cwd), "settings.json"),
|
||||
"project-local": (opts) => path.join(getProjectClaudeDir(opts.cwd), "settings.local.json"),
|
||||
};
|
||||
|
||||
/** A single dotted-path segment. `$` is allowed for `$schema`-style metadata. */
|
||||
const SETTINGS_SEGMENT_RE = /^[A-Za-z0-9_$][A-Za-z0-9._$-]*$/;
|
||||
const FORBIDDEN_SEGMENTS = new Set(["__proto__", "prototype", "constructor"]);
|
||||
|
||||
function settingsFileFor(scope, opts) {
|
||||
const resolver = SETTINGS_SCOPES[scope];
|
||||
if (!resolver) throw makeError("EBADSCOPE", `unknown settings scope: ${scope}`);
|
||||
return resolver(opts);
|
||||
}
|
||||
|
||||
function settingsBackupRoot(scope, opts) {
|
||||
// Beside the file being edited, in the dotted dir Claude Code never scans —
|
||||
// same convention memory backups use.
|
||||
return path.join(path.dirname(settingsFileFor(scope, opts)), ".cc-config-backups", "settings");
|
||||
}
|
||||
|
||||
/** Split `"permissions.allow"` into validated segments. */
|
||||
function parseSettingsPath(dotted) {
|
||||
if (typeof dotted !== "string" || !dotted.trim()) {
|
||||
throw makeError("EBADNAME", "settings key path must be a non-empty string");
|
||||
}
|
||||
const parts = dotted.split(".");
|
||||
for (const seg of parts) {
|
||||
if (!seg || !SETTINGS_SEGMENT_RE.test(seg) || FORBIDDEN_SEGMENTS.has(seg)) {
|
||||
throw makeError("EBADNAME", `invalid settings key segment: ${seg}`);
|
||||
}
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Guard against the redaction trap: `readSettings` replaces secret-looking
|
||||
* string values with the literal `<redacted>` before the UI ever sees them, so
|
||||
* echoing a value read from the UI back into the file would overwrite a real
|
||||
* credential with that placeholder. Any payload carrying the marker is
|
||||
* refused outright rather than partially applied.
|
||||
*/
|
||||
function assertNoRedactionMarker(value) {
|
||||
let json;
|
||||
try {
|
||||
json = JSON.stringify(value);
|
||||
} catch {
|
||||
throw makeError("EBADCONTENT", "settings value is not JSON-serializable");
|
||||
}
|
||||
if (json === undefined) throw makeError("EBADCONTENT", "settings value is not JSON-serializable");
|
||||
if (json.includes("<redacted>")) {
|
||||
throw makeError(
|
||||
"EREDACTED",
|
||||
"value contains the <redacted> placeholder — edit that key in the file directly instead"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function setDeep(obj, parts, value) {
|
||||
let node = obj;
|
||||
for (let i = 0; i < parts.length - 1; i += 1) {
|
||||
const key = parts[i];
|
||||
const next = node[key];
|
||||
if (!next || typeof next !== "object" || Array.isArray(next)) node[key] = {};
|
||||
node = node[key];
|
||||
}
|
||||
node[parts[parts.length - 1]] = value;
|
||||
}
|
||||
|
||||
function unsetDeep(obj, parts) {
|
||||
let node = obj;
|
||||
for (let i = 0; i < parts.length - 1; i += 1) {
|
||||
const next = node[parts[i]];
|
||||
if (!next || typeof next !== "object") return false;
|
||||
node = next;
|
||||
}
|
||||
const last = parts[parts.length - 1];
|
||||
if (!Object.prototype.hasOwnProperty.call(node, last)) return false;
|
||||
delete node[last];
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge individual keys into a settings.json layer. This is the ONLY write
|
||||
* path to a settings file, and it is deliberately a per-key merge: the file is
|
||||
* re-read (unredacted) here, so keys the dashboard never showed — including
|
||||
* secret-valued ones — survive byte-for-byte.
|
||||
*
|
||||
* A file that exists but does not parse as JSON is an error, never a
|
||||
* clobber: overwriting a hand-edited file the CLI still owns would lose data.
|
||||
*
|
||||
* @param {{scope:string, cwd?:string, set?:Record<string,unknown>, unset?:string[]}} args
|
||||
* @returns {{path:string, backupPath:string|null, created:boolean, changed:string[]}}
|
||||
*/
|
||||
function patchSettings(args) {
|
||||
const { scope, cwd, set, unset } = args;
|
||||
const opts = { cwd };
|
||||
const file = settingsFileFor(scope, opts);
|
||||
|
||||
const setEntries =
|
||||
set && typeof set === "object" && !Array.isArray(set) ? Object.entries(set) : [];
|
||||
const unsetPaths = Array.isArray(unset) ? unset : [];
|
||||
if (setEntries.length === 0 && unsetPaths.length === 0) {
|
||||
throw makeError("EBADCONTENT", "nothing to change: provide `set` and/or `unset`");
|
||||
}
|
||||
|
||||
// Validate everything before touching disk — a partial apply is worse than
|
||||
// a rejected request.
|
||||
const parsedSet = setEntries.map(([key, value]) => {
|
||||
assertNoRedactionMarker(value);
|
||||
return { parts: parseSettingsPath(key), key, value };
|
||||
});
|
||||
const parsedUnset = unsetPaths.map((key) => ({ parts: parseSettingsPath(key), key }));
|
||||
|
||||
let existed = false;
|
||||
let data = {};
|
||||
let raw = null;
|
||||
try {
|
||||
raw = fs.readFileSync(file, "utf8");
|
||||
existed = true;
|
||||
} catch {
|
||||
/* fresh file */
|
||||
}
|
||||
if (existed) {
|
||||
if (Buffer.byteLength(raw, "utf8") > MAX_FILE_BYTES) {
|
||||
throw makeError("ETOOLARGE", "settings file is too large to edit safely");
|
||||
}
|
||||
try {
|
||||
data = raw.trim() ? JSON.parse(raw) : {};
|
||||
} catch {
|
||||
throw makeError("EBADJSON", `${file} is not valid JSON — fix it by hand before editing here`);
|
||||
}
|
||||
if (!data || typeof data !== "object" || Array.isArray(data)) {
|
||||
throw makeError("EBADJSON", `${file} does not contain a JSON object`);
|
||||
}
|
||||
}
|
||||
|
||||
const changed = [];
|
||||
for (const { parts, key, value } of parsedSet) {
|
||||
setDeep(data, parts, value);
|
||||
changed.push(key);
|
||||
}
|
||||
for (const { parts, key } of parsedUnset) {
|
||||
if (unsetDeep(data, parts)) changed.push(key);
|
||||
}
|
||||
|
||||
const output = `${JSON.stringify(data, null, 2)}\n`;
|
||||
// The same ceiling the read path enforces: a value pasted into the editor
|
||||
// must not produce a file the explorer can no longer open. Checked BEFORE
|
||||
// the backup, so a rejected write leaves no artifacts at all.
|
||||
if (Buffer.byteLength(output, "utf8") > MAX_FILE_BYTES) {
|
||||
throw makeError("ETOOLARGE", "the resulting settings file would be too large");
|
||||
}
|
||||
|
||||
let backupPath = null;
|
||||
if (existed) {
|
||||
const root = settingsBackupRoot(scope, opts);
|
||||
fs.mkdirSync(root, { recursive: true });
|
||||
backupPath = path.join(root, `${path.basename(file)}.${timestamp()}.bak`);
|
||||
fs.copyFileSync(file, backupPath);
|
||||
}
|
||||
|
||||
atomicWriteFile(file, output);
|
||||
return { path: file, backupPath, created: !existed, changed };
|
||||
}
|
||||
|
||||
/**
|
||||
* List backups for either all types or a specific (scope, type) bucket.
|
||||
* Returns [{ scope, type, name, backupPath, mtime, size }].
|
||||
@@ -528,6 +708,7 @@ module.exports = {
|
||||
writeArtifact,
|
||||
deleteArtifact,
|
||||
writeKeybindings,
|
||||
patchSettings,
|
||||
listBackups,
|
||||
resolveTarget, // exported for tests
|
||||
TYPES,
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
/**
|
||||
* @file cc-plugin-cli.js
|
||||
* @description Drives the official `claude plugin …` CLI so the dashboard can
|
||||
* add/remove marketplaces and install/uninstall/enable/disable/update plugins
|
||||
* without the user opening a terminal.
|
||||
*
|
||||
* Why the CLI and not direct JSON edits: `installed_plugins.json`,
|
||||
* `known_marketplaces.json` and the plugin cache tree are written together by
|
||||
* the CLI (it clones the marketplace repo, resolves versions, prunes the
|
||||
* cache). Reproducing that here would drift the moment the CLI's schema moves.
|
||||
*
|
||||
* Hard constraints (do not relax without a follow-up review):
|
||||
* - `execFile` with an explicit argv array. NEVER a shell string — the
|
||||
* `source`/`spec` values come straight from a user-typed field.
|
||||
* - Every argument is validated BEFORE the spawn: no leading `-` (a value
|
||||
* can never become a flag), no control characters, and a shape allowlist
|
||||
* per argument kind.
|
||||
* - Mutating calls are serialized in-process; two dashboard clicks must not
|
||||
* interleave writes to the same manifest.
|
||||
* - `CLAUDE_CONFIG_DIR` is pinned to the same CLAUDE_HOME the dashboard
|
||||
* reads, so the CLI writes where `cc-discovery` looks.
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { execFile } = require("node:child_process");
|
||||
const path = require("node:path");
|
||||
const { getClaudeHome } = require("./claude-home");
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 120_000;
|
||||
const MAX_OUTPUT_BYTES = 1024 * 1024;
|
||||
|
||||
/** `owner/repo` — the GitHub shorthand `claude plugin marketplace add` accepts. */
|
||||
// Both halves must START with an alphanumeric, so `./relative` is not mistaken
|
||||
// for `owner/repo`.
|
||||
const REPO_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*\/[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
||||
/** `name` or `name@marketplace`. No slashes: a plugin spec is never a path. */
|
||||
const PLUGIN_SPEC_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*(@[A-Za-z0-9][A-Za-z0-9._-]*)?$/;
|
||||
/** A marketplace's registered name (what `known_marketplaces.json` is keyed by). */
|
||||
const MARKETPLACE_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
||||
/** Install scopes the CLI understands — note `local`, not `project-local`. */
|
||||
const CLI_SCOPES = ["user", "project", "local"];
|
||||
|
||||
function makeError(code, message) {
|
||||
const err = new Error(message);
|
||||
err.code = code;
|
||||
return err;
|
||||
}
|
||||
|
||||
/**
|
||||
* Baseline check applied to every argv element we interpolate. A value that
|
||||
* starts with `-` would be parsed as a flag by the CLI even inside an argv
|
||||
* array, so it is rejected rather than escaped.
|
||||
*/
|
||||
function assertArg(value, label) {
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
throw makeError("EBADARG", `${label} is required`);
|
||||
}
|
||||
if (value.length > 512) throw makeError("EBADARG", `${label} is too long`);
|
||||
// eslint-disable-next-line no-control-regex
|
||||
if (/[\u0000-\u001f\u007f]/.test(value)) {
|
||||
throw makeError("EBADARG", `${label} contains control characters`);
|
||||
}
|
||||
if (value.startsWith("-")) throw makeError("EBADARG", `${label} may not start with "-"`);
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* A marketplace source is one of: `owner/repo`, an http(s) URL, or an absolute
|
||||
* path on this machine. Anything else (an ssh remote, a `file://` URL, a
|
||||
* relative path) is refused — the set the UI advertises is the set we accept.
|
||||
*/
|
||||
function assertSource(value) {
|
||||
assertArg(value, "source");
|
||||
const v = value.trim();
|
||||
if (v.includes("..")) throw makeError("EBADARG", "source may not contain '..'");
|
||||
if (/^https?:\/\/\S+$/i.test(v)) return v;
|
||||
if (path.isAbsolute(v)) return v;
|
||||
if (REPO_RE.test(v)) return v;
|
||||
throw makeError("EBADARG", "source must be owner/repo, an http(s) URL, or an absolute path");
|
||||
}
|
||||
|
||||
function assertPluginSpec(value) {
|
||||
assertArg(value, "plugin");
|
||||
if (!PLUGIN_SPEC_RE.test(value)) {
|
||||
throw makeError("EBADARG", "plugin must be <name> or <name>@<marketplace>");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function assertMarketplaceName(value) {
|
||||
assertArg(value, "marketplace");
|
||||
if (!MARKETPLACE_NAME_RE.test(value)) {
|
||||
throw makeError("EBADARG", "marketplace name is not a valid identifier");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function assertCliScope(value) {
|
||||
if (value === undefined || value === null || value === "") return "user";
|
||||
if (!CLI_SCOPES.includes(value)) {
|
||||
throw makeError("EBADARG", `scope must be one of ${CLI_SCOPES.join(", ")}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
// One in-process queue for every mutating CLI call. The dashboard is a single
|
||||
// Node process, so a promise chain is the whole locking story — no file lock,
|
||||
// no cross-process coordination. It does NOT protect against a human running
|
||||
// `claude plugin …` in a terminal at the same moment; the CLI owns that race.
|
||||
let mutationQueue = Promise.resolve();
|
||||
|
||||
function serialize(fn) {
|
||||
const run = mutationQueue.then(fn, fn);
|
||||
mutationQueue = run.then(
|
||||
() => undefined,
|
||||
() => undefined
|
||||
);
|
||||
return run;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `claude <args…>` and resolve with the outcome. A non-zero exit is a
|
||||
* normal result (`ok:false`), not a rejection — callers map it to a response
|
||||
* body. Only a spawn failure (binary missing) rejects.
|
||||
*
|
||||
* @param {string[]} args argv AFTER the binary name; every element pre-validated.
|
||||
* @param {{timeoutMs?:number}} [opts]
|
||||
* @returns {Promise<{ok:boolean, code:number|null, stdout:string, stderr:string, args:string[]}>}
|
||||
*/
|
||||
function runClaude(args, opts = {}) {
|
||||
const timeout = opts.timeoutMs || DEFAULT_TIMEOUT_MS;
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = execFile(
|
||||
"claude",
|
||||
args,
|
||||
{
|
||||
timeout,
|
||||
maxBuffer: MAX_OUTPUT_BYTES,
|
||||
// Pin the CLI to the same config dir cc-discovery reads, so a write
|
||||
// made here is a write the dashboard can see.
|
||||
env: { ...process.env, CLAUDE_CONFIG_DIR: getClaudeHome() },
|
||||
},
|
||||
(err, stdout, stderr) => {
|
||||
const out = String(stdout || "");
|
||||
const errOut = String(stderr || "");
|
||||
if (err && (err.code === "ENOENT" || err.code === "EACCES")) {
|
||||
return reject(
|
||||
makeError(
|
||||
"ECLINOTFOUND",
|
||||
"the `claude` CLI was not found on PATH — install Claude Code or add it to the dashboard's PATH"
|
||||
)
|
||||
);
|
||||
}
|
||||
if (err && err.killed) {
|
||||
return reject(makeError("ETIMEOUT", `claude ${args[0]} timed out after ${timeout}ms`));
|
||||
}
|
||||
const code = err ? (typeof err.code === "number" ? err.code : 1) : 0;
|
||||
resolve({ ok: !err, code, stdout: out, stderr: errOut, args });
|
||||
}
|
||||
);
|
||||
// Every command here is non-interactive. Closing stdin turns a would-be
|
||||
// prompt into an immediate CLI error instead of a hang until `timeout`.
|
||||
try {
|
||||
child.stdin?.end();
|
||||
} catch {
|
||||
/* the child may already have exited */
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Operations ─────────────────────────────────────────────────────────
|
||||
// Each validates first, then queues. Validation errors throw synchronously
|
||||
// from the returned promise, which is what the routes turn into a 400.
|
||||
|
||||
async function marketplaceAdd({ source, scope }) {
|
||||
const src = assertSource(source);
|
||||
const sc = assertCliScope(scope);
|
||||
return serialize(() => runClaude(["plugin", "marketplace", "add", src, "--scope", sc]));
|
||||
}
|
||||
|
||||
async function marketplaceRemove({ name }) {
|
||||
const n = assertMarketplaceName(name);
|
||||
return serialize(() => runClaude(["plugin", "marketplace", "remove", n]));
|
||||
}
|
||||
|
||||
async function marketplaceUpdate({ name }) {
|
||||
const n = assertMarketplaceName(name);
|
||||
return serialize(() => runClaude(["plugin", "marketplace", "update", n]));
|
||||
}
|
||||
|
||||
async function pluginInstall({ spec, scope }) {
|
||||
const s = assertPluginSpec(spec);
|
||||
const sc = assertCliScope(scope);
|
||||
// `-y` accepts the marketplace-declared install command without a prompt;
|
||||
// required because stdin is not a TTY here.
|
||||
return serialize(() => runClaude(["plugin", "install", s, "--scope", sc, "-y"]));
|
||||
}
|
||||
|
||||
async function pluginUninstall({ spec }) {
|
||||
const s = assertPluginSpec(spec);
|
||||
return serialize(() => runClaude(["plugin", "uninstall", s]));
|
||||
}
|
||||
|
||||
async function pluginEnable({ spec }) {
|
||||
const s = assertPluginSpec(spec);
|
||||
return serialize(() => runClaude(["plugin", "enable", s]));
|
||||
}
|
||||
|
||||
async function pluginDisable({ spec }) {
|
||||
const s = assertPluginSpec(spec);
|
||||
return serialize(() => runClaude(["plugin", "disable", s]));
|
||||
}
|
||||
|
||||
async function pluginUpdate({ spec }) {
|
||||
const s = assertPluginSpec(spec);
|
||||
return serialize(() => runClaude(["plugin", "update", s]));
|
||||
}
|
||||
|
||||
/**
|
||||
* `claude plugin list --available --json` — every plugin offered by a
|
||||
* registered marketplace, plus what is already installed. Read-only, so it
|
||||
* skips the mutation queue.
|
||||
*
|
||||
* @returns {Promise<{installed:object[], available:object[]}>}
|
||||
*/
|
||||
async function listAvailable() {
|
||||
const res = await runClaude(["plugin", "list", "--available", "--json"], { timeoutMs: 30_000 });
|
||||
if (!res.ok) {
|
||||
throw makeError("ECLIFAILED", cliMessage(res) || "claude plugin list failed");
|
||||
}
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(res.stdout);
|
||||
} catch {
|
||||
throw makeError("ECLIFAILED", "could not parse `claude plugin list --json` output");
|
||||
}
|
||||
return {
|
||||
installed: Array.isArray(parsed.installed) ? parsed.installed : [],
|
||||
available: Array.isArray(parsed.available) ? parsed.available : [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort human message from a failed run. The CLI prints its errors with
|
||||
* a `✘` marker on either stream; fall back to the last non-empty line.
|
||||
*/
|
||||
function cliMessage(res) {
|
||||
const text = `${res.stderr || ""}\n${res.stdout || ""}`;
|
||||
const lines = text
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean);
|
||||
const marked = lines.find((l) => l.includes("✘"));
|
||||
const picked = marked || lines[lines.length - 1] || "";
|
||||
return picked.replace(/^✘\s*/, "").slice(0, 1000);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
runClaude,
|
||||
cliMessage,
|
||||
listAvailable,
|
||||
marketplaceAdd,
|
||||
marketplaceRemove,
|
||||
marketplaceUpdate,
|
||||
pluginInstall,
|
||||
pluginUninstall,
|
||||
pluginEnable,
|
||||
pluginDisable,
|
||||
pluginUpdate,
|
||||
// exported for tests
|
||||
assertSource,
|
||||
assertPluginSpec,
|
||||
assertMarketplaceName,
|
||||
assertCliScope,
|
||||
PLUGIN_SPEC_RE,
|
||||
};
|
||||
Reference in New Issue
Block a user