feat(artifacts): a shared skill follows its owner's folder; installers get Update
A skill in the Hub was a snapshot taken once: the owner changing
~/.claude/skills/<name> changed nothing there, and an installer had to
uninstall and install again to get a newer version.
- syncSkillSources (on GET /api/artifacts, at most every 15 s): a skill row
taken from a folder — the owner's ~/.claude/skills/<name>, or the
workspace's .claude/skills/<name> — is brought up to it. Only a folder
whose names/sizes/mtimes changed is read again, with a pick's checks (no
symlink, no secret, <= 5 MB, a member's files as theirs); a change is a
NEW snapshot, the old one dropped, and an artifact_changed broadcast.
- artifacts.version (snapshot content hash) and source_hash (the folder's
when last taken, additive columns): an upload or a Hub edit records the
folder as it is then, so only a LATER folder change replaces it.
- A list row's updateAvailable: installed, and the viewer's copy differs
from version. The page shows Update (a pack: Update N); install with
{update: true} replaces the copies (old ones moved aside) and is not
counted as another install.
This commit is contained in:
@@ -10,8 +10,10 @@
|
||||
* junk skipped, nothing stored when one fails) or written in the dialog, a
|
||||
* file uploaded, sharing refused in local mode, visibility for a server-mode
|
||||
* developer, the admin's installs switch, same-origin writes, Claude Docs
|
||||
* docs captured at their birth, documents captured at a session's stop, and
|
||||
* unowned rows claimed, declined and assigned.
|
||||
* docs captured at their birth, documents captured at a session's stop,
|
||||
* unowned rows claimed, declined and assigned, and a skill that follows its
|
||||
* source folder (a newer change wins over a Hub edit) with Update offered to
|
||||
* an installed copy that differs.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -338,6 +340,69 @@ describe("skills", () => {
|
||||
assert.equal(empty.status, 400);
|
||||
});
|
||||
|
||||
it("follows its source folder and offers Update to an installed copy", async () => {
|
||||
const src = path.join(ws, ".claude", "skills", "notes");
|
||||
fs.mkdirSync(src, { recursive: true });
|
||||
fs.writeFileSync(path.join(src, "SKILL.md"), "---\nname: notes\ndescription: v1\n---\nOne.\n");
|
||||
const r = await request("POST", "/api/artifacts/skill", { laneId: lane.id, name: "notes" });
|
||||
assert.equal(r.status, 201, r.text);
|
||||
const id = r.body.item.id;
|
||||
const listed = async () =>
|
||||
(await request("GET", "/api/artifacts?kind=skill")).body.items.find((i) => i.id === id);
|
||||
const raw = async () => (await request("GET", `/api/artifacts/${id}/raw?path=SKILL.md`)).text;
|
||||
assert.equal((await request("POST", `/api/artifacts/${id}/install`, {})).status, 200);
|
||||
let item = await listed();
|
||||
assert.equal(item.installed, true);
|
||||
assert.equal(item.updateAvailable, false);
|
||||
const installs = item.installs;
|
||||
|
||||
// Unchanged folder: nothing taken.
|
||||
assert.deepEqual(artifacts.syncSkillSources({ force: true }), []);
|
||||
// The owner changes it: a new snapshot, and the installed copy is behind.
|
||||
const snapBefore = db.prepare("SELECT snapshot FROM artifacts WHERE id = ?").get(id).snapshot;
|
||||
fs.writeFileSync(path.join(src, "SKILL.md"), "---\nname: notes\ndescription: v2\n---\nTwo.\n");
|
||||
fs.writeFileSync(path.join(src, "extra.md"), "more\n");
|
||||
const changed = artifacts.syncSkillSources({ force: true });
|
||||
assert.deepEqual(
|
||||
changed.map((c) => c.id),
|
||||
[id]
|
||||
);
|
||||
assert.match(await raw(), /Two\./);
|
||||
assert.ok(!fs.existsSync(path.join(process.env.DASHBOARD_DATA_DIR, "artifacts", snapBefore)));
|
||||
item = await listed();
|
||||
assert.equal(item.description, "v2");
|
||||
assert.ok(item.files.some((f) => f.path === "extra.md"));
|
||||
assert.equal(item.updateAvailable, true);
|
||||
|
||||
// Update: the copy is replaced (old one moved aside), not counted again.
|
||||
const up = await request("POST", `/api/artifacts/${id}/install`, { update: true });
|
||||
assert.equal(up.status, 200, up.text);
|
||||
const target = path.join(process.env.CLAUDE_HOME, "skills", "notes");
|
||||
assert.match(fs.readFileSync(path.join(target, "SKILL.md"), "utf8"), /Two\./);
|
||||
item = await listed();
|
||||
assert.equal(item.updateAvailable, false);
|
||||
assert.equal(item.installs, installs);
|
||||
|
||||
// A Hub edit is not undone by the unchanged folder; a newer folder change wins.
|
||||
const edit = await request("PATCH", `/api/artifacts/${id}`, {
|
||||
skill: { name: "notes", description: "hub", content: "Edited in the Hub." },
|
||||
});
|
||||
assert.equal(edit.status, 200, edit.text);
|
||||
artifacts.syncSkillSources({ force: true });
|
||||
assert.match(await raw(), /Edited in the Hub\./);
|
||||
fs.writeFileSync(
|
||||
path.join(src, "SKILL.md"),
|
||||
"---\nname: notes\ndescription: v3\n---\nThree.\n"
|
||||
);
|
||||
artifacts.syncSkillSources({ force: true });
|
||||
assert.match(await raw(), /Three\./);
|
||||
|
||||
// Update needs an installed copy.
|
||||
await request("POST", `/api/artifacts/${id}/uninstall`, {});
|
||||
const none = await request("POST", `/api/artifacts/${id}/install`, { update: true });
|
||||
assert.equal(none.body.error.code, "ENOTINSTALLED");
|
||||
});
|
||||
|
||||
it("refuses a skill holding a symlink or a secret-looking file", async () => {
|
||||
const bad = path.join(ws, ".claude", "skills", "leaky");
|
||||
fs.mkdirSync(bad, { recursive: true });
|
||||
|
||||
@@ -964,9 +964,15 @@ try {
|
||||
// (`captureOutputs`), not pinned by hand. `suggested_owner_id`: for a row with
|
||||
// no owner, the user whose folder the session ran in — they may claim it or
|
||||
// say it is not theirs (`claim`/`decline`); an admin assigns any (`assign`).
|
||||
// `version`: a skill snapshot's content hash; `source_hash`: its source
|
||||
// folder's hash when last taken — a skill follows its owner's folder and an
|
||||
// installer whose copy differs from `version` is offered Update
|
||||
// (`syncSkillSources`/`updateCheck`). NULL until first computed.
|
||||
for (const [col, ddl] of [
|
||||
["auto", "auto INTEGER NOT NULL DEFAULT 0"],
|
||||
["suggested_owner_id", "suggested_owner_id INTEGER"],
|
||||
["version", "version TEXT"],
|
||||
["source_hash", "source_hash TEXT"],
|
||||
]) {
|
||||
try {
|
||||
db.prepare(`SELECT ${col} FROM artifacts LIMIT 1`).get();
|
||||
|
||||
+287
-17
@@ -226,7 +226,11 @@ function list(req, { scope = "all", kind, q, laneId, sessionId, limit = 200 } =
|
||||
).all(...params);
|
||||
const owners = new Map();
|
||||
const installed = installedCheck(req);
|
||||
const out = rows.map((r) => ({ ...view(r, req, owners), installed: installed(r) }));
|
||||
const updatable = updateCheck(req);
|
||||
const out = rows.map((r) => {
|
||||
const isIn = installed(r);
|
||||
return { ...view(r, req, owners), installed: isIn, updateAvailable: isIn && updatable(r) };
|
||||
});
|
||||
return scope === "mine" && req?.user ? out.filter((a) => a.mine || a.installed) : out;
|
||||
}
|
||||
|
||||
@@ -753,7 +757,7 @@ function walkSkill(dir) {
|
||||
total += st.size;
|
||||
if (files.length >= SKILL_MAX_FILES) throw fail("ETOOBIG", "the skill has too many files");
|
||||
if (total > SKILL_MAX_BYTES) throw fail("ETOOBIG", "the skill is larger than 5 MB");
|
||||
files.push({ abs: childAbs, path: childRel, size: st.size });
|
||||
files.push({ abs: childAbs, path: childRel, size: st.size, mtimeMs: st.mtimeMs });
|
||||
}
|
||||
};
|
||||
walk(dir, "", 0);
|
||||
@@ -822,6 +826,7 @@ function storeSkill(req, { name, files, title, description, laneId = null }) {
|
||||
const snap = newSnapshotDir();
|
||||
let total = 0;
|
||||
const recorded = [];
|
||||
const hashed = [];
|
||||
try {
|
||||
for (const f of files) {
|
||||
const data = typeof f.data === "function" ? f.data() : f.data;
|
||||
@@ -831,6 +836,7 @@ function storeSkill(req, { name, files, title, description, laneId = null }) {
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
|
||||
fs.writeFileSync(target, data, { mode: 0o600 });
|
||||
recorded.push({ path: f.path, size: data.length });
|
||||
hashed.push({ path: f.path, data });
|
||||
}
|
||||
} catch (err) {
|
||||
dropSnapshot(snap.id);
|
||||
@@ -845,20 +851,30 @@ function storeSkill(req, { name, files, title, description, laneId = null }) {
|
||||
} catch {
|
||||
/* no frontmatter */
|
||||
}
|
||||
return view(
|
||||
insert({
|
||||
kind: "skill",
|
||||
title: cleanTitle(title, name),
|
||||
description: cleanDescription(description) || summary.slice(0, DESCRIPTION_MAX),
|
||||
owner_id: req?.user ? req.user.id : null,
|
||||
lane_id: laneId,
|
||||
source: name,
|
||||
snapshot: snap.id,
|
||||
files: JSON.stringify(recorded),
|
||||
size: total,
|
||||
}),
|
||||
req
|
||||
);
|
||||
const row = insert({
|
||||
kind: "skill",
|
||||
title: cleanTitle(title, name),
|
||||
description: cleanDescription(description) || summary.slice(0, DESCRIPTION_MAX),
|
||||
owner_id: req?.user ? req.user.id : null,
|
||||
lane_id: laneId,
|
||||
source: name,
|
||||
snapshot: snap.id,
|
||||
files: JSON.stringify(recorded),
|
||||
size: total,
|
||||
version: contentHash(hashed),
|
||||
});
|
||||
// The owner's folder as it is now: only a LATER change to it is taken
|
||||
// (`syncOne`), so an upload is never replaced by an older local copy.
|
||||
try {
|
||||
const h = sourceHash(row);
|
||||
if (h) {
|
||||
sql("UPDATE artifacts SET source_hash = ? WHERE id = ?").run(h, row.id);
|
||||
row.source_hash = h;
|
||||
}
|
||||
} catch {
|
||||
/* no folder */
|
||||
}
|
||||
return view(row, req);
|
||||
}
|
||||
|
||||
// ── skill: written or uploaded ────────────────────────────────────────────
|
||||
@@ -1569,10 +1585,22 @@ function editSkill(req, row, { name, description, content }) {
|
||||
throw err;
|
||||
}
|
||||
const desc = frontmatterField(text, "description");
|
||||
// The source folder as it is now, so this edit is not undone by it (`syncOne`).
|
||||
const folder = sourceHash({ ...row, source: n });
|
||||
sql(
|
||||
`UPDATE artifacts SET source = ?, snapshot = ?, files = ?, size = ?, description = ?,
|
||||
version = ?, source_hash = ?,
|
||||
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`
|
||||
).run(n, snap.id, JSON.stringify(next), total, cleanDescription(desc), row.id);
|
||||
).run(
|
||||
n,
|
||||
snap.id,
|
||||
JSON.stringify(next),
|
||||
total,
|
||||
cleanDescription(desc),
|
||||
contentHash(all),
|
||||
folder,
|
||||
row.id
|
||||
);
|
||||
dropSnapshot(row.snapshot);
|
||||
}
|
||||
|
||||
@@ -1756,6 +1784,237 @@ async function installSkill(req, row, { replace = false } = {}) {
|
||||
return { installed: paths.join(", "), paths, replaced: taken.length > 0 };
|
||||
}
|
||||
|
||||
// ── skill versions: follow the owner's folder, offer installers an Update ──
|
||||
|
||||
/** A skill's content id: sha256 over its files (path + bytes), path order. */
|
||||
function contentHash(files) {
|
||||
const h = crypto.createHash("sha256");
|
||||
for (const f of [...files].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))) {
|
||||
h.update(f.path);
|
||||
h.update("\0");
|
||||
h.update(crypto.createHash("sha256").update(f.data).digest());
|
||||
}
|
||||
return h.digest("hex").slice(0, 16);
|
||||
}
|
||||
|
||||
/** A skill row's snapshot version (`artifacts.version`), computed once for an older row. */
|
||||
function snapshotVersion(row) {
|
||||
if (row.version) return row.version;
|
||||
if (row.kind !== "skill" || !row.snapshot || !SNAPSHOT_RE.test(row.snapshot)) return null;
|
||||
try {
|
||||
const files = (row.files ? JSON.parse(row.files) : []).map((f) => ({
|
||||
path: f.path,
|
||||
data: fs.readFileSync(path.join(root(), row.snapshot, ...f.path.split("/"))),
|
||||
}));
|
||||
const v = contentHash(files);
|
||||
sql("UPDATE artifacts SET version = ? WHERE id = ?").run(v, row.id);
|
||||
row.version = v;
|
||||
return v;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// realpath → { sig, hash }: a folder is read again only once a file's name,
|
||||
// size or mtime changed (a stat walk is cheap; reading 5 MB per poll is not).
|
||||
const folderCache = new Map();
|
||||
const FOLDER_CACHE_MAX = 2000;
|
||||
|
||||
/**
|
||||
* A skill folder as it is on disk now — `{ real, hash, files, read() }` — or
|
||||
* null when it is missing, has no SKILL.md, is a link out of `top`, or breaks
|
||||
* a snapshot rule (`walkSkill`: no symlink, no secret, ≤ 5 MB). `read()`
|
||||
* answers its files' bytes, a member's only as theirs (`readFileSafe`).
|
||||
*/
|
||||
function folderState(dir, top) {
|
||||
let real;
|
||||
let topReal;
|
||||
try {
|
||||
real = fs.realpathSync(dir);
|
||||
topReal = fs.realpathSync(top);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!inside(topReal, real) || path.basename(real) !== path.basename(dir)) return null;
|
||||
let walked;
|
||||
try {
|
||||
walked = walkSkill(real);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!walked.files.some((f) => f.path === "SKILL.md")) return null;
|
||||
const sig = walked.files
|
||||
.map((f) => `${f.path}:${f.size}:${f.mtimeMs}`)
|
||||
.sort()
|
||||
.join("|");
|
||||
const read = () => {
|
||||
const runAs = require("./run-as");
|
||||
return walked.files.map((f) => ({
|
||||
path: f.path,
|
||||
data: runAs.readFileSafe(f.abs, null, { max: SKILL_MAX_BYTES }),
|
||||
}));
|
||||
};
|
||||
const hit = folderCache.get(real);
|
||||
if (hit && hit.sig === sig) return { real, hash: hit.hash, files: walked.files, read };
|
||||
let hash;
|
||||
try {
|
||||
hash = contentHash(read());
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
folderCache.delete(real);
|
||||
folderCache.set(real, { sig, hash });
|
||||
if (folderCache.size > FOLDER_CACHE_MAX) folderCache.delete(folderCache.keys().next().value);
|
||||
return { real, hash, files: walked.files, read };
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a skill row came from: `{dir, top}` — its workspace's
|
||||
* `.claude/skills/<name>` when it was taken from one, else its OWNER's
|
||||
* `~/.claude/skills/<name>` (the host's in local mode). Null for a row with no
|
||||
* owner in server mode, or an owner with no Claude config of their own.
|
||||
*/
|
||||
function sourceDirOf(row) {
|
||||
if (row.kind !== "skill" || !row.source || !SKILL_NAME_RE.test(row.source)) return null;
|
||||
if (row.lane_id != null) {
|
||||
const lane = sql("SELECT cwd FROM lanes WHERE id = ?").get(row.lane_id);
|
||||
if (!lane?.cwd) return null;
|
||||
return { dir: path.join(lane.cwd, ".claude", "skills", row.source), top: lane.cwd };
|
||||
}
|
||||
let claudeHome;
|
||||
if (row.owner_id == null) {
|
||||
if (isServer()) return null;
|
||||
claudeHome = getClaudeHome();
|
||||
} else {
|
||||
const user = sql("SELECT id, username, role FROM users WHERE id = ?").get(row.owner_id);
|
||||
if (!user) return null;
|
||||
try {
|
||||
claudeHome = ownAccount({ user }).claudeHome;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return { dir: path.join(claudeHome, "skills", row.source), top: claudeHome };
|
||||
}
|
||||
|
||||
/** The hash of a row's source folder now, or null when it has none. */
|
||||
function sourceHash(row) {
|
||||
const src = sourceDirOf(row);
|
||||
return src ? (folderState(src.dir, src.top)?.hash ?? null) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Take a skill row's NEW source folder as its snapshot, when the folder
|
||||
* changed since it was last taken (`source_hash`) and differs from the
|
||||
* snapshot. A change made in the Hub (`editSkill`) records the folder as it
|
||||
* was then, so it is not undone until the folder itself changes again: the
|
||||
* newer change wins. A new snapshot directory, then the old one dropped —
|
||||
* never a write into the one being read. Answers whether the row changed.
|
||||
*/
|
||||
function syncOne(row) {
|
||||
const src = sourceDirOf(row);
|
||||
if (!src) return false;
|
||||
const st = folderState(src.dir, src.top);
|
||||
if (!st || st.hash === row.source_hash) return false;
|
||||
if (st.hash === snapshotVersion(row)) {
|
||||
sql("UPDATE artifacts SET source_hash = ? WHERE id = ?").run(st.hash, row.id);
|
||||
return false;
|
||||
}
|
||||
let files;
|
||||
try {
|
||||
files = st.read();
|
||||
checkSkillFiles(files);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
const snap = newSnapshotDir();
|
||||
let total = 0;
|
||||
const next = [];
|
||||
try {
|
||||
for (const f of files) {
|
||||
const target = path.join(snap.dir, ...f.path.split("/"));
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
|
||||
fs.writeFileSync(target, f.data, { mode: 0o600 });
|
||||
total += f.data.length;
|
||||
next.push({ path: f.path, size: f.data.length });
|
||||
}
|
||||
} catch {
|
||||
dropSnapshot(snap.id);
|
||||
return false;
|
||||
}
|
||||
// The description follows SKILL.md's only while it still says what the old one did.
|
||||
const md = (list) => list.find((f) => f.path === "SKILL.md");
|
||||
let oldSummary = "";
|
||||
try {
|
||||
oldSummary = frontmatterField(
|
||||
fs.readFileSync(path.join(root(), row.snapshot, "SKILL.md"), "utf8"),
|
||||
"description"
|
||||
).slice(0, DESCRIPTION_MAX);
|
||||
} catch {
|
||||
/* none */
|
||||
}
|
||||
const newSummary = frontmatterField(md(files)?.data.toString("utf8") ?? "", "description");
|
||||
const description =
|
||||
!row.description || row.description === oldSummary
|
||||
? cleanDescription(newSummary) || row.description
|
||||
: row.description;
|
||||
sql(
|
||||
`UPDATE artifacts SET snapshot = ?, files = ?, size = ?, version = ?, source_hash = ?,
|
||||
description = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`
|
||||
).run(snap.id, JSON.stringify(next), total, st.hash, st.hash, description, row.id);
|
||||
if (row.snapshot && SNAPSHOT_RE.test(row.snapshot)) dropSnapshot(row.snapshot);
|
||||
return true;
|
||||
}
|
||||
|
||||
const SYNC_EVERY_MS = 15 * 1000;
|
||||
let lastSync = 0;
|
||||
|
||||
/**
|
||||
* Bring every skill row up to its source folder (`syncOne`), at most once per
|
||||
* `SYNC_EVERY_MS` unless `force`. Runs when the Hub is listed. Answers the
|
||||
* rows that changed, for the route to announce.
|
||||
*/
|
||||
function syncSkillSources({ force = false } = {}) {
|
||||
if (!force && Date.now() - lastSync < SYNC_EVERY_MS) return [];
|
||||
lastSync = Date.now();
|
||||
const changedRows = [];
|
||||
for (const row of sql(
|
||||
"SELECT * FROM artifacts WHERE kind = 'skill' AND snapshot IS NOT NULL"
|
||||
).all()) {
|
||||
try {
|
||||
if (syncOne(row)) changedRows.push(sql("SELECT * FROM artifacts WHERE id = ?").get(row.id));
|
||||
} catch {
|
||||
/* one bad row never stops the rest */
|
||||
}
|
||||
}
|
||||
return changedRows;
|
||||
}
|
||||
|
||||
/**
|
||||
* `row => boolean`: an installed skill whose copy in the viewer's
|
||||
* `~/.claude/skills/<name>` (any account it runs as) differs from the row's
|
||||
* newest snapshot — the list offers **Update** for it. Pure reads.
|
||||
*/
|
||||
function updateCheck(req) {
|
||||
let accounts = null;
|
||||
return (row) => {
|
||||
if (row.kind !== "skill" || !row.source) return false;
|
||||
const version = snapshotVersion(row);
|
||||
if (!version) return false;
|
||||
if (accounts === null) {
|
||||
try {
|
||||
accounts = installAccounts(req);
|
||||
} catch {
|
||||
accounts = [];
|
||||
}
|
||||
}
|
||||
return accounts.some((a) => {
|
||||
const st = folderState(path.join(a.claudeHome, "skills", row.source), a.claudeHome);
|
||||
return !!st && st.hash !== version;
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
let execImpl = promisify(require("node:child_process").execFile);
|
||||
function __setExecImpl(fn) {
|
||||
execImpl = fn;
|
||||
@@ -1815,6 +2074,16 @@ async function installPlugin(req, row) {
|
||||
async function install(req, id, opts = {}) {
|
||||
const row = load(req, id);
|
||||
installable(req, row);
|
||||
// Update: an installed skill's copies replaced by the newest snapshot (the
|
||||
// old ones moved aside like any replace). Not another install: no count, no
|
||||
// second row in who-installed.
|
||||
if (opts.update) {
|
||||
if (row.kind !== "skill") throw fail("EBADKIND", "only a skill is updated here");
|
||||
if (!installedCheck(req)(row)) throw fail("ENOTINSTALLED", `${row.source} is not installed`);
|
||||
const result = await installSkill(req, row, { replace: true });
|
||||
if (req?.user && row.owner_id !== req.user.id) audit(req, row, "updated");
|
||||
return result;
|
||||
}
|
||||
const result =
|
||||
row.kind === "skill" ? await installSkill(req, row, opts) : await installPlugin(req, row);
|
||||
sql("UPDATE artifacts SET installs = installs + 1 WHERE id = ?").run(row.id);
|
||||
@@ -1947,6 +2216,7 @@ module.exports = {
|
||||
update,
|
||||
remove,
|
||||
snapshotFile,
|
||||
syncSkillSources,
|
||||
install,
|
||||
uninstall,
|
||||
installsEnabled,
|
||||
|
||||
@@ -75,6 +75,9 @@ router.get(
|
||||
"/",
|
||||
wrap((req, res) => {
|
||||
const q = req.query;
|
||||
// A skill follows its owner's folder: take what changed there first
|
||||
// (throttled), and tell every open Hub page.
|
||||
for (const row of artifacts.syncSkillSources()) changed(row);
|
||||
res.json({
|
||||
items: artifacts.list(req, {
|
||||
scope: q.scope,
|
||||
@@ -358,6 +361,7 @@ router.post(
|
||||
wrap(async (req, res) => {
|
||||
const result = await artifacts.install(req, req.params.id, {
|
||||
replace: req.body?.replace === true,
|
||||
update: req.body?.update === true,
|
||||
});
|
||||
res.json(result);
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user