1aee080fca
The quota check failed with `invalid_grant` for the account the user was logged into - the one profile they would try first. An OAuth refresh token is single use, so every refresh Claude Code performs on its own account retires the copy sitting in that account's saved profile: the stored credentials of the LIVE account are the one set guaranteed to be rejected, however recently the profile was saved. Confirmed on a real install - the saved profile held a refresh token three rotations behind `.credentials.json`. `cc-profiles.liveCredentialsFor` now matches a profile's accountUuid against the live credentials and, on a hit, the usage call uses what the CLI is holding. That token is never refreshed and that file is never written: rotating it would log the user out of their own terminal. An expired live token answers ELIVEEXPIRED - using Claude Code once makes the CLI refresh it - and a genuinely spent refresh token is now reported as ESTALE with what to do about it, instead of echoing Anthropic's raw 400. Opening the Profiles modal also asks for every saved account at once, rather than one button per row: comparing accounts is the only reason the panel exists. The guard against rewriting credentials over and over is no longer "one row at a time" but the rules above - the live account costs a plain read, and any other profile refreshes only once its token has actually expired, which then leaves it valid for hours of further opens. Each row resolves on its own, so a superseded account still lets its neighbours report.
631 lines
21 KiB
JavaScript
631 lines
21 KiB
JavaScript
/**
|
|
* @file cc-config.js
|
|
* @description HTTP surface for inspecting and (carefully) mutating Claude
|
|
* Code configuration: skills, subagents, slash commands, output styles,
|
|
* plugins, marketplaces, MCP servers, hooks, settings, memory, keybindings,
|
|
* statusline, hook scripts. Powers the Claude Config Explorer dashboard
|
|
* page.
|
|
*
|
|
* Read paths cover every surface. Write paths cover: low-risk text-file
|
|
* artifacts (skills, agents, commands, output styles, memory, per-project
|
|
* auto-memory), the structured keybindings.json editor, settings.json layers
|
|
* via a per-key merge (`PATCH /settings`, which is also how hooks are edited),
|
|
* plugin/marketplace management delegated to the `claude plugin …` CLI, and
|
|
* MCP server add/remove delegated to the `claude mcp …` CLI. Every file
|
|
* write is preceded by a timestamped backup; the CLI-delegated writes are
|
|
* not files this server touches directly, so there is nothing to back up —
|
|
* the CLI owns `~/.claude.json`.
|
|
*
|
|
* Also hosts the auth-profile routes (`/profiles*`) that swap which Anthropic
|
|
* account the local CLI runs as; those carry identity metadata only, never
|
|
* token material.
|
|
*
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
const { Router } = require("express");
|
|
const path = require("path");
|
|
const cc = require("../lib/cc-discovery");
|
|
const ccMutate = require("../lib/cc-mutate");
|
|
const ccCli = require("../lib/cc-plugin-cli");
|
|
const ccMcpCli = require("../lib/cc-mcp-cli");
|
|
const ccProfiles = require("../lib/cc-profiles");
|
|
const ccUsage = require("../lib/cc-usage");
|
|
const projectMemory = require("../lib/project-memory");
|
|
const { sameOriginGuard } = require("../lib/same-origin");
|
|
const { broadcast } = require("../websocket");
|
|
const { reloadOpenSessions } = require("../lib/cc-reload");
|
|
const { startUpdateAll, readUpdateAll, isRunning } = require("../lib/cc-update-all");
|
|
const { detectPluginInstall } = require("../lib/update-check");
|
|
|
|
/** Mirrors cc-plugin-cli's own spec rule — ids from the CLI's JSON still reach
|
|
* an argv in the detached batch worker, so they are filtered before they go. */
|
|
const PLUGIN_SPEC_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*(@[A-Za-z0-9][A-Za-z0-9._-]*)?$/;
|
|
|
|
const router = Router();
|
|
|
|
function emitChanged(payload) {
|
|
try {
|
|
broadcast("cc_config_changed", { source: "dashboard", ...payload });
|
|
} catch {
|
|
/* websocket may not be initialised in unit tests */
|
|
}
|
|
}
|
|
|
|
// Map mutate-error codes → HTTP status. Anything unmapped is a 500.
|
|
const ERR_TO_STATUS = {
|
|
EBADTYPE: 400,
|
|
EBADSCOPE: 400,
|
|
EBADNAME: 400,
|
|
EBADPROJECT: 400,
|
|
EBADCONTENT: 400,
|
|
EBADDIR: 400,
|
|
EBADTOOL: 400,
|
|
// The project already has a memory file; creating would clobber it.
|
|
EEXISTS: 409,
|
|
ETOOLARGE: 413,
|
|
EOUTOFROOT: 400,
|
|
ENOTFOUND: 404,
|
|
EREDACTED: 400,
|
|
// The file on disk is unusable as-is; the caller must fix it by hand first.
|
|
EBADJSON: 409,
|
|
};
|
|
|
|
function mutateError(res, err) {
|
|
const status = ERR_TO_STATUS[err.code] || 500;
|
|
return res
|
|
.status(status)
|
|
.json({ error: { code: err.code || "EINTERNAL", message: err.message } });
|
|
}
|
|
|
|
function scopeOf(req) {
|
|
const s = String(req.query.scope || "all");
|
|
return s === "user" || s === "project" ? s : "all";
|
|
}
|
|
|
|
function cwdOf(req) {
|
|
// The dashboard server's own cwd is the natural "project" — but allow
|
|
// override via ?cwd= so the user can inspect another working dir without
|
|
// restarting the server.
|
|
const c = typeof req.query.cwd === "string" && req.query.cwd ? req.query.cwd : null;
|
|
return c || process.cwd();
|
|
}
|
|
|
|
router.get("/overview", (req, res) => {
|
|
res.json(cc.readOverview({ cwd: cwdOf(req) }));
|
|
});
|
|
|
|
router.get("/skills", (req, res) => {
|
|
res.json({ items: cc.readSkills({ scope: scopeOf(req), cwd: cwdOf(req) }) });
|
|
});
|
|
|
|
router.get("/agents", (req, res) => {
|
|
res.json({ items: cc.readAgents({ scope: scopeOf(req), cwd: cwdOf(req) }) });
|
|
});
|
|
|
|
router.get("/commands", (req, res) => {
|
|
res.json({ items: cc.readCommands({ scope: scopeOf(req), cwd: cwdOf(req) }) });
|
|
});
|
|
|
|
router.get("/output-styles", (req, res) => {
|
|
res.json({ items: cc.readOutputStyles({ scope: scopeOf(req), cwd: cwdOf(req) }) });
|
|
});
|
|
|
|
router.get("/plugins", (_req, res) => {
|
|
res.json(cc.readPlugins());
|
|
});
|
|
|
|
router.get("/mcp", (req, res) => {
|
|
res.json(cc.readMcpServers({ cwd: cwdOf(req) }));
|
|
});
|
|
|
|
router.get("/hooks", (req, res) => {
|
|
res.json({ items: cc.readHooks({ cwd: cwdOf(req) }) });
|
|
});
|
|
|
|
router.get("/settings", (req, res) => {
|
|
res.json({ items: cc.readSettings({ cwd: cwdOf(req) }) });
|
|
});
|
|
|
|
router.get("/memory", (req, res) => {
|
|
res.json({ items: cc.readMemory({ cwd: cwdOf(req) }) });
|
|
});
|
|
|
|
router.get("/marketplaces", (_req, res) => {
|
|
res.json(cc.readMarketplaces());
|
|
});
|
|
|
|
router.get("/keybindings", (_req, res) => {
|
|
res.json(cc.readKeybindings());
|
|
});
|
|
|
|
// PUT /api/cc-config/keybindings — structured overwrite of the user's
|
|
// ~/.claude/keybindings.json. Body: { groups: [{ context, bindings: [{ key,
|
|
// action }] }] }. Backs the file up first and preserves top-level metadata.
|
|
router.put("/keybindings", (req, res) => {
|
|
const { groups } = req.body || {};
|
|
if (!Array.isArray(groups)) {
|
|
return res
|
|
.status(400)
|
|
.json({ error: { code: "EBADREQ", message: "groups array is required" } });
|
|
}
|
|
try {
|
|
const result = ccMutate.writeKeybindings({ groups });
|
|
emitChanged({ action: "write", type: "keybindings", name: null, project: null });
|
|
res.json(result);
|
|
} catch (err) {
|
|
return mutateError(res, err);
|
|
}
|
|
});
|
|
|
|
router.get("/statusline", (_req, res) => {
|
|
res.json(cc.readStatusline());
|
|
});
|
|
|
|
router.get("/hook-scripts", (_req, res) => {
|
|
res.json(cc.readHookScripts());
|
|
});
|
|
|
|
// GET /api/cc-config/file?path=/abs/path — return body of a single file.
|
|
// Path must resolve under CLAUDE_HOME, project .claude/, or be project CLAUDE.md.
|
|
router.get("/file", (req, res) => {
|
|
const p = req.query.path;
|
|
if (typeof p !== "string" || !p) {
|
|
return res.status(400).json({ error: { code: "BAD_PATH", message: "path is required" } });
|
|
}
|
|
const result = cc.readFileSafe(p, { cwd: cwdOf(req) });
|
|
if (result.error)
|
|
return res.status(400).json({ error: { code: "READ_DENIED", message: result.error } });
|
|
res.json(result);
|
|
});
|
|
|
|
// ── Phase-2 mutation endpoints ─────────────────────────────────────────
|
|
//
|
|
// PUT /api/cc-config/file — create or overwrite. Body: { scope, type, name?, content }
|
|
// DELETE /api/cc-config/file — delete. Body: { scope, type, name? }
|
|
// GET /api/cc-config/backups[?scope=&type=] — list backups
|
|
//
|
|
// Plugins, MCP, hooks-in-settings, and settings.json files are intentionally
|
|
// not mutable here. See cc-mutate.js for the rationale.
|
|
|
|
router.put("/file", (req, res) => {
|
|
const { scope, type, name, content, project } = req.body || {};
|
|
if (typeof scope !== "string" || typeof type !== "string") {
|
|
return res
|
|
.status(400)
|
|
.json({ error: { code: "EBADREQ", message: "scope and type are required" } });
|
|
}
|
|
try {
|
|
// process.cwd(), never ?cwd= — the same rule PATCH /settings already
|
|
// follows: a caller-chosen write target lets any request drop a file
|
|
// anywhere on disk. Reads keep the override; writes do not.
|
|
const result = ccMutate.writeArtifact({
|
|
scope,
|
|
type,
|
|
name,
|
|
content,
|
|
project,
|
|
cwd: process.cwd(),
|
|
});
|
|
emitChanged({ action: "write", scope, type, name: name || null, project: project || null });
|
|
res.json(result);
|
|
} catch (err) {
|
|
return mutateError(res, err);
|
|
}
|
|
});
|
|
|
|
router.delete("/file", (req, res) => {
|
|
const { scope, type, name, project } = req.body || {};
|
|
if (typeof scope !== "string" || typeof type !== "string") {
|
|
return res
|
|
.status(400)
|
|
.json({ error: { code: "EBADREQ", message: "scope and type are required" } });
|
|
}
|
|
try {
|
|
// Pinned for the same reason as PUT above.
|
|
const result = ccMutate.deleteArtifact({ scope, type, name, project, cwd: process.cwd() });
|
|
emitChanged({ action: "delete", scope, type, name: name || null, project: project || null });
|
|
res.json(result);
|
|
} catch (err) {
|
|
return mutateError(res, err);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* POST /api/cc-config/project-memory — create `CLAUDE.md` in a project the
|
|
* dashboard has never run in. Body: `{ dir, content }`.
|
|
*
|
|
* The one write here that honours a caller-supplied directory, which is the
|
|
* whole point: you cannot bootstrap a NEW project's memory from a writer
|
|
* pinned to the server's own cwd. It stays narrow — see project-memory.js —
|
|
* and sits behind the same loopback-origin guard the process-spawning routes
|
|
* use, so a random web page cannot plant instructions in a project directory.
|
|
*/
|
|
router.post("/project-memory", sameOriginGuard, (req, res) => {
|
|
const { dir, content } = req.body || {};
|
|
try {
|
|
const result = projectMemory.createProjectMemory({ tool: "claude", dir, content });
|
|
emitChanged({ action: "write", scope: "project", type: "memory", name: null });
|
|
res.json({ ...result, created: true });
|
|
} catch (err) {
|
|
return mutateError(res, err);
|
|
}
|
|
});
|
|
|
|
// ── Plugins / marketplaces (via the `claude` CLI) ──────────────────────
|
|
//
|
|
// These shell out to `claude plugin …` with an argv array — never a shell
|
|
// string — because the CLI owns the manifest + cache tree that plugin state
|
|
// lives in. Identifiers travel in the body, not the URL, so a key like
|
|
// `agenthub@sg` needs no encoding (same reason `DELETE /file` takes a body).
|
|
|
|
const CLI_ERR_TO_STATUS = {
|
|
EBADARG: 400,
|
|
ECLINOTFOUND: 503,
|
|
ETIMEOUT: 504,
|
|
ECLIFAILED: 502,
|
|
};
|
|
|
|
function cliError(res, err) {
|
|
const status = CLI_ERR_TO_STATUS[err.code] || 500;
|
|
return res
|
|
.status(status)
|
|
.json({ error: { code: err.code || "EINTERNAL", message: err.message } });
|
|
}
|
|
|
|
/** Map a finished CLI run onto a response; a non-zero exit is a 422, not a 500. */
|
|
function cliResult(res, result, changed, extra) {
|
|
if (!result.ok) {
|
|
return res.status(422).json({
|
|
error: {
|
|
code: "ECLIEXIT",
|
|
message: ccCli.cliMessage(result) || `claude exited with code ${result.code}`,
|
|
},
|
|
code: result.code,
|
|
stdout: result.stdout,
|
|
stderr: result.stderr,
|
|
});
|
|
}
|
|
emitChanged(changed);
|
|
res.json({ ok: true, stdout: result.stdout, stderr: result.stderr, ...extra });
|
|
}
|
|
|
|
router.get("/plugins/available", async (_req, res) => {
|
|
try {
|
|
res.json(await ccCli.listAvailable());
|
|
} catch (err) {
|
|
return cliError(res, err);
|
|
}
|
|
});
|
|
|
|
router.post("/plugins/install", async (req, res) => {
|
|
const { spec, scope } = req.body || {};
|
|
try {
|
|
const result = await ccCli.pluginInstall({ spec, scope });
|
|
return cliResult(res, result, { action: "install", type: "plugin", name: spec });
|
|
} catch (err) {
|
|
return cliError(res, err);
|
|
}
|
|
});
|
|
|
|
const PLUGIN_ACTIONS = {
|
|
enable: ccCli.pluginEnable,
|
|
disable: ccCli.pluginDisable,
|
|
update: ccCli.pluginUpdate,
|
|
uninstall: ccCli.pluginUninstall,
|
|
};
|
|
|
|
router.post("/plugins/action", async (req, res) => {
|
|
const { key, action, reload } = req.body || {};
|
|
const run = PLUGIN_ACTIONS[action];
|
|
if (!run) {
|
|
return res.status(400).json({
|
|
error: {
|
|
code: "EBADACTION",
|
|
message: `action must be one of ${Object.keys(PLUGIN_ACTIONS).join(", ")}`,
|
|
},
|
|
});
|
|
}
|
|
try {
|
|
const result = await run({ spec: key });
|
|
// "Update one, reload that one" — the counterpart to the batch, which
|
|
// reloads once at the end. Only on success, and only when asked: an
|
|
// enable/disable already needs a reload but the caller decides, and a
|
|
// reload after a FAILED update would tell the session to activate nothing.
|
|
let reloaded;
|
|
if (reload && result.ok) {
|
|
try {
|
|
reloaded = await reloadOpenSessions();
|
|
} catch {
|
|
/* a reload that cannot be delivered must not fail the update itself */
|
|
}
|
|
}
|
|
return cliResult(res, result, { action, type: "plugin", name: key }, { reloaded });
|
|
} catch (err) {
|
|
return cliError(res, err);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Type `/reload-plugins` + `/reload-skills` into the live Claude Code lane
|
|
* panes. Not a config mutation — it changes nothing on disk, it just tells
|
|
* sessions to re-read what is already there — so it neither queues on the CLI
|
|
* mutation chain nor emits `cc_config_changed`.
|
|
*/
|
|
router.post("/reload", async (req, res) => {
|
|
const { kinds } = req.body || {};
|
|
if (kinds !== undefined && !Array.isArray(kinds)) {
|
|
return res.status(400).json({
|
|
error: { code: "EBADARG", message: "kinds must be an array of 'plugins' | 'skills'" },
|
|
});
|
|
}
|
|
try {
|
|
res.json({ ok: true, ...(await reloadOpenSessions({ kinds })) });
|
|
} catch (err) {
|
|
res.status(500).json({
|
|
error: { code: "ERELOAD", message: err.message || String(err) },
|
|
});
|
|
}
|
|
});
|
|
|
|
// ── Update all ─────────────────────────────────────────────────────────
|
|
//
|
|
// Runs detached (see server/lib/cc-update-all.js): the batch ends with AgentHub
|
|
// itself when this server runs from the plugin cache, and that step stops the
|
|
// process. The response is therefore only an acknowledgement — progress is
|
|
// polled from GET below, which reads the worker's on-disk journal.
|
|
|
|
router.get("/plugins/update-all", (_req, res) => {
|
|
res.json({ batch: readUpdateAll() });
|
|
});
|
|
|
|
router.post("/plugins/update-all", async (req, res) => {
|
|
if (isRunning()) {
|
|
return res.status(409).json({
|
|
error: { code: "EBUSY", message: "an update-all batch is already running" },
|
|
batch: readUpdateAll(),
|
|
});
|
|
}
|
|
|
|
let installed;
|
|
try {
|
|
installed = (await ccCli.listAvailable()).installed || [];
|
|
} catch (err) {
|
|
return cliError(res, err);
|
|
}
|
|
|
|
// Ids come from the CLI's own JSON, never from the request body, but they
|
|
// reach an argv in a detached process — so they are filtered here too.
|
|
const plugins = installed.map((p) => String(p.id || "")).filter((id) => PLUGIN_SPEC_RE.test(id));
|
|
|
|
// Which plugin, if any, is the one this dashboard is running from. Derived
|
|
// from our own path — never from the request — so no caller can nominate a
|
|
// different plugin as "self" and get a bootstrap run against it.
|
|
const selfRoot = path.resolve(__dirname, "..", "..");
|
|
const detected = detectPluginInstall(selfRoot);
|
|
const selfPlugin = detected
|
|
? { marketplace: detected.marketplace, plugin: detected.plugin }
|
|
: null;
|
|
|
|
const plan = {
|
|
plugins,
|
|
selfPlugin,
|
|
selfRoot: selfPlugin ? selfRoot : null,
|
|
reload: req.body?.reload !== false,
|
|
};
|
|
|
|
try {
|
|
const started = startUpdateAll(plan);
|
|
res.json({ ok: true, ...started, planned: plugins.length, self_plugin: selfPlugin });
|
|
} catch (err) {
|
|
res.status(500).json({
|
|
error: { code: "EUPDATEALL", message: err.message || String(err) },
|
|
});
|
|
}
|
|
});
|
|
|
|
router.post("/marketplaces/add", async (req, res) => {
|
|
const { source, scope } = req.body || {};
|
|
try {
|
|
const result = await ccCli.marketplaceAdd({ source, scope });
|
|
return cliResult(res, result, { action: "add", type: "marketplace", name: source });
|
|
} catch (err) {
|
|
return cliError(res, err);
|
|
}
|
|
});
|
|
|
|
const MARKETPLACE_ACTIONS = {
|
|
remove: ccCli.marketplaceRemove,
|
|
update: ccCli.marketplaceUpdate,
|
|
};
|
|
|
|
router.post("/marketplaces/action", async (req, res) => {
|
|
const { name, action } = req.body || {};
|
|
const run = MARKETPLACE_ACTIONS[action];
|
|
if (!run) {
|
|
return res.status(400).json({
|
|
error: {
|
|
code: "EBADACTION",
|
|
message: `action must be one of ${Object.keys(MARKETPLACE_ACTIONS).join(", ")}`,
|
|
},
|
|
});
|
|
}
|
|
try {
|
|
const result = await run({ name });
|
|
return cliResult(res, result, { action, type: "marketplace", name });
|
|
} catch (err) {
|
|
return cliError(res, err);
|
|
}
|
|
});
|
|
|
|
// ── MCP servers (via the `claude` CLI) ─────────────────────────────────
|
|
//
|
|
// Same reasoning as the plugin/marketplace routes above: `~/.claude.json` is
|
|
// rewritten concurrently by the running CLI, so add/remove shell out to
|
|
// `claude mcp …` rather than editing the file. Reads are unaffected — GET
|
|
// /mcp above stays on `readMcpServers`; the client refetches it after either
|
|
// call below succeeds.
|
|
|
|
router.post("/mcp/add", async (req, res) => {
|
|
const { name, scope, transport, command, args, url, headers, env } = req.body || {};
|
|
try {
|
|
const result = await ccMcpCli.mcpAdd({
|
|
name,
|
|
scope,
|
|
transport,
|
|
command,
|
|
args,
|
|
url,
|
|
headers,
|
|
env,
|
|
});
|
|
return cliResult(res, result, { action: "add", type: "mcp", name });
|
|
} catch (err) {
|
|
return cliError(res, err);
|
|
}
|
|
});
|
|
|
|
router.post("/mcp/remove", async (req, res) => {
|
|
const { name, scope } = req.body || {};
|
|
try {
|
|
const result = await ccMcpCli.mcpRemove({ name, scope });
|
|
return cliResult(res, result, { action: "remove", type: "mcp", name });
|
|
} catch (err) {
|
|
return cliError(res, err);
|
|
}
|
|
});
|
|
|
|
// PATCH /api/cc-config/settings — merge individual keys into one settings
|
|
// layer. Body: { scope: "user"|"project"|"project-local", set?: {dotted: value},
|
|
// unset?: ["dotted"] }. Hooks are edited through this same route as
|
|
// `set: { "hooks.PreToolUse": [...] }` in settings.json's own nested shape.
|
|
//
|
|
// The project layers resolve against the SERVER's cwd, never `?cwd=`: a write
|
|
// target chosen by the caller would let any request create a settings.json
|
|
// anywhere on disk. Reads keep the override; writes do not.
|
|
router.patch("/settings", (req, res) => {
|
|
const { scope, set, unset } = req.body || {};
|
|
try {
|
|
const result = ccMutate.patchSettings({ scope, cwd: process.cwd(), set, unset });
|
|
emitChanged({ action: "patch", type: "settings", name: scope, project: null });
|
|
res.json(result);
|
|
} catch (err) {
|
|
return mutateError(res, err);
|
|
}
|
|
});
|
|
|
|
// ── Auth profiles ──────────────────────────────────────────────────────
|
|
//
|
|
// Switch which Anthropic account (= which quota) the local `claude` CLI runs
|
|
// as, keeping every other config surface in place. Responses carry identity
|
|
// metadata only — never token material. See cc-profiles.js.
|
|
|
|
const PROFILE_ERR_TO_STATUS = {
|
|
EBADNAME: 400,
|
|
EBADLABEL: 400,
|
|
EBADREQ: 400,
|
|
ENOTFOUND: 404,
|
|
EBADJSON: 409,
|
|
EACTIVE: 409,
|
|
EUNSAVED: 409,
|
|
ENOAUTH: 409,
|
|
EDUPLICATE: 409,
|
|
ELABELTAKEN: 409,
|
|
};
|
|
|
|
function profileError(res, e) {
|
|
const status = PROFILE_ERR_TO_STATUS[e.code] || 500;
|
|
return res.status(status).json({ error: { code: e.code || "EINTERNAL", message: e.message } });
|
|
}
|
|
|
|
router.get("/profiles", (_req, res) => {
|
|
try {
|
|
res.json(ccProfiles.listProfiles());
|
|
} catch (e) {
|
|
return profileError(res, e);
|
|
}
|
|
});
|
|
|
|
router.post("/profiles", (req, res) => {
|
|
try {
|
|
const result = ccProfiles.saveProfile((req.body || {}).name);
|
|
emitChanged({ action: "save", type: "profile", name: result.name, project: null });
|
|
res.json(result);
|
|
} catch (e) {
|
|
return profileError(res, e);
|
|
}
|
|
});
|
|
|
|
// Rename is a label edit; the profile's `name` identifier never moves.
|
|
router.patch("/profiles", (req, res) => {
|
|
try {
|
|
const body = req.body || {};
|
|
const result = ccProfiles.renameProfile(body.name, body.label);
|
|
emitChanged({ action: "rename", type: "profile", name: result.name, project: null });
|
|
res.json(result);
|
|
} catch (e) {
|
|
return profileError(res, e);
|
|
}
|
|
});
|
|
|
|
router.post("/profiles/activate", (req, res) => {
|
|
try {
|
|
const result = ccProfiles.activateProfile((req.body || {}).name);
|
|
emitChanged({ action: "activate", type: "profile", name: result.name, project: null });
|
|
res.json(result);
|
|
} catch (e) {
|
|
return profileError(res, e);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* POST /profiles/usage — how much of this profile's account quota is spent.
|
|
*
|
|
* A POST, and behind the same-origin guard, because it is not a read: a saved
|
|
* profile's access token has almost always expired, so answering means
|
|
* refreshing it, and the refresh rotates the stored credentials on disk (see
|
|
* `cc-usage.js`). That is also why nothing calls this on a timer — the UI asks
|
|
* per profile, when the user asks.
|
|
*
|
|
* The response is two percentages and two reset timestamps. No token material
|
|
* crosses this boundary, in either direction: the name is all the caller sends.
|
|
*/
|
|
router.post("/profiles/usage", sameOriginGuard, async (req, res) => {
|
|
const name = (req.body || {}).name;
|
|
try {
|
|
res.json(await ccUsage.profileUsage(name));
|
|
} catch (e) {
|
|
const status =
|
|
{
|
|
ENOTFOUND: 404,
|
|
EBADNAME: 400,
|
|
ENOREFRESH: 409,
|
|
ENOAUTH: 409,
|
|
ESTALE: 409,
|
|
ELIVEEXPIRED: 409,
|
|
EUNAUTHORIZED: 401,
|
|
}[e.code] || 502;
|
|
res.status(status).json({ error: { code: e.code || "EUPSTREAM", message: e.message } });
|
|
}
|
|
});
|
|
|
|
// Identifier travels in the body, not the URL — same reason as DELETE /file.
|
|
router.delete("/profiles", (req, res) => {
|
|
try {
|
|
const result = ccProfiles.deleteProfile((req.body || {}).name);
|
|
emitChanged({ action: "delete", type: "profile", name: result.name, project: null });
|
|
res.json(result);
|
|
} catch (e) {
|
|
return profileError(res, e);
|
|
}
|
|
});
|
|
|
|
router.get("/backups", (req, res) => {
|
|
const scope =
|
|
req.query.scope === "user" || req.query.scope === "project" ? req.query.scope : undefined;
|
|
const type = typeof req.query.type === "string" ? req.query.type : undefined;
|
|
res.json({ items: ccMutate.listBackups({ scope, type, cwd: cwdOf(req) }) });
|
|
});
|
|
|
|
module.exports = router;
|