skills.js 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611
  1. /**
  2. * Skills HTTP shim — translates the existing `/api/skills/*` REST
  3. * contract that `ui/src/components/main-content-v2/SkillsV2.tsx` was
  4. * built against into the gateway's `skill_*` RPCs. The gateway is the
  5. * authoritative skill manager (see `src/extension/skills/SkillManager.ts`)
  6. * backed by `~/.pilotdeck/skills/` and `<project>/.pilotdeck/skills/`,
  7. * so the UI and the agent always read from the same place.
  8. *
  9. * Two endpoints stay file-based for now because they don't map cleanly
  10. * onto a single gateway RPC:
  11. *
  12. * - `/import-upload` — multipart browser folder picker. We stream the
  13. * buffers into a staging dir next to the target skill root, then ask the gateway
  14. * to refresh its in-memory caches via a follow-up `skill_validate`
  15. * call to compute the validation result. A future revision can lift
  16. * this onto a gateway RPC that accepts base64 chunks.
  17. *
  18. * - `/clawhub/*` — shells out to the `clawhub` CLI which writes its
  19. * output to disk by itself. We just retarget the install root to
  20. * `~/.pilotdeck/skills/` so installs end up where the agent looks.
  21. *
  22. * Anything else (list/read/write/create/delete/import/validate/scan) is
  23. * a one-line forward to the gateway. Errors raised by `SkillManagerError`
  24. * arrive as `{ code, message }` and we map their `code` to a sensible
  25. * HTTP status; everything else falls through as 500.
  26. */
  27. import express from 'express';
  28. import { promises as fs } from 'fs';
  29. import path from 'path';
  30. import os from 'os';
  31. import { execFile } from 'child_process';
  32. import { promisify } from 'util';
  33. import multer from 'multer';
  34. import { getPilotDeckGateway } from '../pilotdeck-bridge.js';
  35. import { resolvePilotHome } from '../utils/pilotPaths.js';
  36. import { moveDirectoryAcrossDevicesSafe } from '../utils/fileMoves.js';
  37. const execFileAsync = promisify(execFile);
  38. const router = express.Router();
  39. const upload = multer({
  40. storage: multer.memoryStorage(),
  41. limits: {
  42. fileSize: 10 * 1024 * 1024,
  43. files: 500,
  44. fields: 20,
  45. },
  46. });
  47. // ---------------------------------------------------------------------------
  48. // Path / scope helpers (small surface area kept in the bridge for protocol
  49. // translation; the SkillManager owns the same logic internally for direct
  50. // gateway callers, but the UI sends absolute `skillPath` so we need to
  51. // classify it before forwarding `(scope, slug)`).
  52. // ---------------------------------------------------------------------------
  53. const SLUG_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,99}$/;
  54. const PILOT_HOME = resolvePilotHome(process.env);
  55. const PROJECT_DIR = '.pilotdeck';
  56. const SKILLS_SUBDIR = 'skills';
  57. function safeSlug(slug) {
  58. return typeof slug === 'string' && SLUG_RE.test(slug) && !slug.includes('..');
  59. }
  60. const GENERAL_CWD_PATHS = [path.resolve(PILOT_HOME)];
  61. function isGeneralCwd(projectPath) {
  62. if (!projectPath) return false;
  63. return GENERAL_CWD_PATHS.includes(path.resolve(projectPath));
  64. }
  65. function resolveRequestedScope(scope, projectPath, { defaultToProjectWhenAvailable = false } = {}) {
  66. const generalCwd = isGeneralCwd(projectPath);
  67. const effectiveProjectPath = generalCwd ? null : projectPath || null;
  68. if (scope === 'project') {
  69. if (generalCwd) {
  70. return { ok: true, scope: 'user', projectPath: null, wantProject: false };
  71. }
  72. if (!effectiveProjectPath) {
  73. return {
  74. ok: false,
  75. error: "project scope requires a real project (general chat doesn't qualify)",
  76. };
  77. }
  78. return { ok: true, scope: 'project', projectPath: effectiveProjectPath, wantProject: true };
  79. }
  80. if (scope === 'user') {
  81. return { ok: true, scope: 'user', projectPath: null, wantProject: false };
  82. }
  83. if (defaultToProjectWhenAvailable && effectiveProjectPath) {
  84. return { ok: true, scope: 'project', projectPath: effectiveProjectPath, wantProject: true };
  85. }
  86. return { ok: true, scope: 'user', projectPath: null, wantProject: false };
  87. }
  88. function userSkillsRoot() {
  89. return path.join(PILOT_HOME, SKILLS_SUBDIR);
  90. }
  91. function projectSkillsRoot(projectPath) {
  92. return path.join(projectPath, PROJECT_DIR, SKILLS_SUBDIR);
  93. }
  94. function expandHome(p) {
  95. if (typeof p !== 'string' || !p) return p;
  96. if (p === '~') return os.homedir();
  97. if (p.startsWith('~/')) return path.join(os.homedir(), p.slice(2));
  98. return p;
  99. }
  100. /**
  101. * Translate an absolute `skillPath` (used by the UI for stable
  102. * addressing across the read/write/delete cycle) into the
  103. * `(scope, slug)` pair the gateway expects. Refuses anything outside
  104. * the user or active project skill roots so a malformed UI request
  105. * cannot cajole the gateway into touching arbitrary paths.
  106. */
  107. function classifySkillPath(skillPath, projectPath = null) {
  108. if (typeof skillPath !== 'string' || !skillPath) {
  109. return { ok: false, reason: 'skillPath is required' };
  110. }
  111. const abs = path.resolve(skillPath);
  112. if (abs.includes('..')) {
  113. return { ok: false, reason: 'skillPath contains ".."' };
  114. }
  115. const candidates = [{ root: userSkillsRoot(), scope: 'user' }];
  116. if (projectPath && !isGeneralCwd(projectPath)) {
  117. candidates.push({ root: projectSkillsRoot(projectPath), scope: 'project' });
  118. }
  119. for (const { root, scope } of candidates) {
  120. const rootResolved = path.resolve(root);
  121. if (abs === rootResolved) {
  122. return { ok: false, reason: 'skillPath is the skills root, not a skill' };
  123. }
  124. const rel = path.relative(rootResolved, abs);
  125. if (rel.startsWith('..') || path.isAbsolute(rel)) continue;
  126. const segments = rel.split(path.sep).filter(Boolean);
  127. if (segments.length === 0) continue;
  128. const slug = segments[0];
  129. if (!safeSlug(slug)) {
  130. return { ok: false, reason: `Invalid slug "${slug}"` };
  131. }
  132. return { ok: true, scope, slug };
  133. }
  134. return { ok: false, reason: 'skillPath is not inside any known skills root' };
  135. }
  136. /**
  137. * Convert a gateway error (from a `SkillManagerError` on the other side
  138. * of the WS bridge) into an HTTP status + payload. The gateway sends
  139. * structured `{ code, message, validation? }` errors when the failure
  140. * originated in the skill manager; everything else surfaces as 500.
  141. */
  142. function sendGatewayError(res, err) {
  143. const code = err?.code;
  144. const message = err?.message || (err instanceof Error ? err.message : String(err));
  145. switch (code) {
  146. case 'not_configured':
  147. return res.status(503).json({ error: message, code });
  148. case 'invalid_input':
  149. case 'invalid_slug':
  150. case 'project_required':
  151. case 'self_import':
  152. return res.status(400).json({ error: message, code });
  153. case 'not_found':
  154. case 'source_missing':
  155. case 'source_not_directory':
  156. case 'no_skill_md':
  157. return res.status(404).json({ error: message, code });
  158. case 'conflict':
  159. return res.status(409).json({ error: message, code });
  160. case 'validation_failed':
  161. return res.status(422).json({ error: message, code, validation: err.validation });
  162. default:
  163. console.error('[skills-bridge]', err);
  164. return res.status(500).json({ error: message, code: code || 'gateway_request_failed' });
  165. }
  166. }
  167. /**
  168. * Wrapper that calls a gateway RPC and normalises errors. The remote
  169. * gateway raises `GatewayRequestError` instances (see
  170. * `src/gateway/client/GatewayWsClient.ts`) which carry the structured
  171. * `code` from `SkillManagerError` plus an optional `validation`
  172. * payload — we let them propagate as-is so `sendGatewayError` can map
  173. * the code to an HTTP status. Transport-level failures (WS closed,
  174. * timeout) surface as plain `Error` and route to the 500 fallback.
  175. */
  176. async function callGateway(method, params) {
  177. const gw = await getPilotDeckGateway();
  178. return gw[method](params);
  179. }
  180. // ---------------------------------------------------------------------------
  181. // Core CRUD — every endpoint forwards to the gateway
  182. // ---------------------------------------------------------------------------
  183. router.post('/list', async (req, res) => {
  184. try {
  185. const { projectPath } = req.body || {};
  186. const generalCwd = isGeneralCwd(projectPath);
  187. const effectiveProjectPath = generalCwd ? null : projectPath || null;
  188. const data = await callGateway('skillsList', { projectKey: effectiveProjectPath });
  189. res.json({
  190. user: data.user,
  191. project: data.project,
  192. projectPath: data.projectPath,
  193. isGeneralCwd: generalCwd,
  194. });
  195. } catch (e) {
  196. sendGatewayError(res, e);
  197. }
  198. });
  199. router.post('/read', async (req, res) => {
  200. try {
  201. const { skillPath, projectPath } = req.body || {};
  202. const cls = classifySkillPath(skillPath, projectPath);
  203. if (!cls.ok) return res.status(400).json({ error: cls.reason });
  204. const result = await callGateway('skillRead', {
  205. scope: cls.scope,
  206. slug: cls.slug,
  207. projectKey: cls.scope === 'project' ? projectPath : null,
  208. });
  209. res.json(result);
  210. } catch (e) {
  211. sendGatewayError(res, e);
  212. }
  213. });
  214. router.post('/write', async (req, res) => {
  215. try {
  216. const { skillPath, content, projectPath } = req.body || {};
  217. if (typeof content !== 'string') {
  218. return res.status(400).json({ error: 'content (string) is required' });
  219. }
  220. const cls = classifySkillPath(skillPath, projectPath);
  221. if (!cls.ok) return res.status(400).json({ error: cls.reason });
  222. const result = await callGateway('skillWrite', {
  223. scope: cls.scope,
  224. slug: cls.slug,
  225. projectKey: cls.scope === 'project' ? projectPath : null,
  226. content,
  227. });
  228. res.json(result);
  229. } catch (e) {
  230. sendGatewayError(res, e);
  231. }
  232. });
  233. router.post('/create', async (req, res) => {
  234. try {
  235. const { scope, projectPath, slug, name, description, body, content } = req.body || {};
  236. const resolved = resolveRequestedScope(scope, projectPath);
  237. if (!resolved.ok) return res.status(400).json({ error: resolved.error });
  238. const result = await callGateway('skillCreate', {
  239. scope: resolved.scope,
  240. slug,
  241. projectKey: resolved.wantProject ? resolved.projectPath : null,
  242. name,
  243. description,
  244. body,
  245. content,
  246. });
  247. res.json(result);
  248. } catch (e) {
  249. sendGatewayError(res, e);
  250. }
  251. });
  252. router.post('/delete', async (req, res) => {
  253. try {
  254. const { skillPath, projectPath } = req.body || {};
  255. const cls = classifySkillPath(skillPath, projectPath);
  256. if (!cls.ok) return res.status(400).json({ error: cls.reason });
  257. const result = await callGateway('skillDelete', {
  258. scope: cls.scope,
  259. slug: cls.slug,
  260. projectKey: cls.scope === 'project' ? projectPath : null,
  261. });
  262. res.json(result);
  263. } catch (e) {
  264. sendGatewayError(res, e);
  265. }
  266. });
  267. router.post('/validate', async (req, res) => {
  268. try {
  269. const { sourcePath, skillMdContent, files } = req.body || {};
  270. const result = await callGateway(
  271. 'skillValidate',
  272. sourcePath ? { sourcePath } : { skillMdContent, files },
  273. );
  274. res.json(result);
  275. } catch (e) {
  276. sendGatewayError(res, e);
  277. }
  278. });
  279. router.post('/import', async (req, res) => {
  280. try {
  281. const { sourcePath, slug, scope, projectPath, mode, force } = req.body || {};
  282. const resolved = resolveRequestedScope(scope, projectPath);
  283. if (!resolved.ok) return res.status(400).json({ error: resolved.error });
  284. const result = await callGateway('skillImport', {
  285. sourcePath,
  286. slug,
  287. scope: resolved.scope,
  288. projectKey: resolved.wantProject ? resolved.projectPath : null,
  289. mode,
  290. force,
  291. });
  292. res.json(result);
  293. } catch (e) {
  294. sendGatewayError(res, e);
  295. }
  296. });
  297. router.post('/scan', async (req, res) => {
  298. try {
  299. const { parentPath } = req.body || {};
  300. const result = await callGateway('skillScan', { parentPath });
  301. res.json(result);
  302. } catch (e) {
  303. sendGatewayError(res, e);
  304. }
  305. });
  306. // ---------------------------------------------------------------------------
  307. // /import-upload — multipart picker upload. Multipart bodies don't fit the
  308. // WS RPC, so we stage on disk and then ask the gateway to validate. The
  309. // final move lands in `~/.pilotdeck/skills/<slug>` or
  310. // `<project>/.pilotdeck/skills/<slug>` so the agent picks it up on next
  311. // session refresh.
  312. // ---------------------------------------------------------------------------
  313. router.post('/import-upload', upload.array('files', 500), async (req, res) => {
  314. let stagingDir = null;
  315. try {
  316. const { slug: requestedSlug, scope, projectPath, force, paths: pathsJson } = req.body || {};
  317. let paths;
  318. try {
  319. paths = JSON.parse(pathsJson || '[]');
  320. } catch {
  321. return res
  322. .status(400)
  323. .json({ error: '`paths` must be a JSON array of relative paths matching the file order.' });
  324. }
  325. const filesIn = Array.isArray(req.files) ? req.files : [];
  326. if (filesIn.length === 0) return res.status(400).json({ error: 'No files were uploaded.' });
  327. if (filesIn.length !== paths.length) {
  328. return res.status(400).json({
  329. error: `paths length (${paths.length}) does not match files count (${filesIn.length}).`,
  330. });
  331. }
  332. const manifest = filesIn.map((f, i) => ({
  333. relativePath: paths[i],
  334. size: f.size,
  335. buffer: f.buffer,
  336. }));
  337. let skillMdContent = '';
  338. for (const m of manifest) {
  339. if (m.relativePath === 'SKILL.md') {
  340. skillMdContent = m.buffer.toString('utf8');
  341. break;
  342. }
  343. }
  344. const validation = await callGateway('skillValidate', {
  345. skillMdContent,
  346. files: manifest.map((m) => ({ relativePath: m.relativePath, size: m.size })),
  347. });
  348. if (!validation.ok) {
  349. return res.status(422).json({ error: 'Validation failed', validation });
  350. }
  351. const resolved = resolveRequestedScope(scope, projectPath);
  352. if (!resolved.ok) return res.status(400).json({ error: resolved.error });
  353. const root = resolved.wantProject ? projectSkillsRoot(resolved.projectPath) : userSkillsRoot();
  354. const inferredSlug =
  355. (typeof requestedSlug === 'string' && requestedSlug.trim()) ||
  356. (paths[0] && paths[0].split('/')[0]) ||
  357. '';
  358. if (!safeSlug(inferredSlug)) {
  359. return res.status(400).json({
  360. error: `Invalid slug "${inferredSlug}". Allowed: [a-zA-Z0-9][a-zA-Z0-9._-]{0,99}, no "..".`,
  361. });
  362. }
  363. const targetDir = path.join(root, inferredSlug);
  364. const stripPrefix = (() => {
  365. const first = paths[0]?.split('/')?.[0];
  366. if (!first) return null;
  367. return paths.every((p) => p.split('/')[0] === first) ? first + '/' : null;
  368. })();
  369. let exists = false;
  370. try {
  371. await fs.access(targetDir);
  372. exists = true;
  373. } catch {
  374. /* missing → fine */
  375. }
  376. if (exists) {
  377. const isForce = force === 'true' || force === true;
  378. if (!isForce) {
  379. return res
  380. .status(409)
  381. .json({ error: `Skill already exists at ${targetDir}. Re-submit with force=true to overwrite.` });
  382. }
  383. }
  384. await fs.mkdir(root, { recursive: true });
  385. stagingDir = await fs.mkdtemp(path.join(root, '.tmp-skill-upload-'));
  386. for (const m of manifest) {
  387. const rel =
  388. stripPrefix && m.relativePath.startsWith(stripPrefix)
  389. ? m.relativePath.slice(stripPrefix.length)
  390. : m.relativePath;
  391. if (rel.includes('..') || path.isAbsolute(rel)) continue;
  392. const out = path.join(stagingDir, rel);
  393. await fs.mkdir(path.dirname(out), { recursive: true });
  394. await fs.writeFile(out, m.buffer);
  395. }
  396. if (exists) await fs.rm(targetDir, { recursive: true, force: true });
  397. await moveDirectoryAcrossDevicesSafe(stagingDir, targetDir);
  398. stagingDir = null;
  399. // Round-trip through the gateway once more so the response shape
  400. // matches the rest of the API (skill summary populated, scope echoed).
  401. let skillSummary = null;
  402. try {
  403. const list = await callGateway('skillsList', {
  404. projectKey: resolved.wantProject ? resolved.projectPath : null,
  405. });
  406. const bucket = resolved.wantProject ? list.project : list.user;
  407. skillSummary = bucket.find((s) => s.slug === inferredSlug) ?? null;
  408. } catch {
  409. /* best-effort; the file is on disk regardless */
  410. }
  411. res.json({
  412. ok: true,
  413. mode: 'upload',
  414. scope: resolved.scope,
  415. slug: inferredSlug,
  416. skillPath: targetDir,
  417. skill: skillSummary,
  418. validation,
  419. });
  420. } catch (e) {
  421. if (stagingDir) {
  422. try {
  423. await fs.rm(stagingDir, { recursive: true, force: true });
  424. } catch {
  425. /* best-effort */
  426. }
  427. }
  428. sendGatewayError(res, e);
  429. }
  430. });
  431. // ---------------------------------------------------------------------------
  432. // ClawHub passthrough — kept here because the binary writes to disk and
  433. // reading it back into the gateway would just add a layer. We retarget
  434. // the install root to `~/.pilotdeck/skills/` (or `<project>/.pilotdeck/
  435. // skills/`) so installed skills end up where the agent looks.
  436. // ---------------------------------------------------------------------------
  437. router.post('/clawhub/search', async (req, res) => {
  438. try {
  439. const { query, registry } = req.body || {};
  440. if (typeof query !== 'string' || query.trim().length === 0) {
  441. return res.json({ results: [] });
  442. }
  443. const args = ['--no-input'];
  444. if (registry) args.push('--registry', registry);
  445. args.push('search', query.trim());
  446. let stdout = '';
  447. try {
  448. const r = await execFileAsync('clawhub', args, { timeout: 30_000, maxBuffer: 4 * 1024 * 1024 });
  449. stdout = r.stdout || '';
  450. } catch (e) {
  451. if (e.code === 'ENOENT') {
  452. return res
  453. .status(503)
  454. .json({ error: 'clawhub CLI not found in PATH. Install with `npm install -g clawhub`.' });
  455. }
  456. stdout = e.stdout || '';
  457. if (!stdout) {
  458. return res.status(500).json({ error: 'clawhub search failed', message: e.message });
  459. }
  460. }
  461. // eslint-disable-next-line no-control-regex
  462. const ANSI = /\x1b\[[0-9;]*m/g;
  463. const results = [];
  464. for (const rawLine of stdout.split('\n')) {
  465. const line = rawLine.replace(ANSI, '').trim();
  466. if (!line) continue;
  467. if (line.startsWith('-') || line.toLowerCase().startsWith('searching')) continue;
  468. const m = line.match(/^(\S+)\s+(.+?)\s+\(([\d.]+)\)\s*$/);
  469. if (m) {
  470. results.push({ slug: m[1], name: m[2], score: parseFloat(m[3]) });
  471. } else {
  472. const parts = line.split(/\s{2,}/);
  473. if (parts.length >= 1 && safeSlug(parts[0])) {
  474. results.push({ slug: parts[0], name: parts[1] || parts[0], score: null });
  475. }
  476. }
  477. }
  478. res.json({ results });
  479. } catch (e) {
  480. console.error('[skills/clawhub/search]', e);
  481. res.status(500).json({ error: 'Search failed', message: e.message });
  482. }
  483. });
  484. router.post('/clawhub/install', async (req, res) => {
  485. try {
  486. const { slug, version, force, scope, projectPath, registry } = req.body || {};
  487. if (!safeSlug(slug)) {
  488. return res.status(400).json({ error: `Invalid slug "${slug}".` });
  489. }
  490. const resolved = resolveRequestedScope(scope, projectPath, {
  491. defaultToProjectWhenAvailable: true,
  492. });
  493. if (!resolved.ok) return res.status(400).json({ error: resolved.error });
  494. let workdir;
  495. let dir;
  496. if (resolved.wantProject) {
  497. workdir = resolved.projectPath;
  498. dir = path.join(PROJECT_DIR, SKILLS_SUBDIR);
  499. } else {
  500. workdir = PILOT_HOME;
  501. dir = SKILLS_SUBDIR;
  502. }
  503. const installPath = path.join(workdir, dir, slug);
  504. const args = ['--no-input', '--workdir', workdir, '--dir', dir];
  505. if (registry) args.push('--registry', registry);
  506. args.push('install', slug);
  507. if (version) args.push('--version', version);
  508. if (force) args.push('--force');
  509. let stdout = '';
  510. let stderr = '';
  511. let runError = null;
  512. try {
  513. const r = await execFileAsync('clawhub', args, { timeout: 120_000, maxBuffer: 10 * 1024 * 1024 });
  514. stdout = r.stdout || '';
  515. stderr = r.stderr || '';
  516. } catch (e) {
  517. if (e.code === 'ENOENT') {
  518. return res
  519. .status(503)
  520. .json({ error: 'clawhub CLI not found in PATH. Install with `npm install -g clawhub`.' });
  521. }
  522. runError = e;
  523. stdout = e.stdout || '';
  524. stderr = e.stderr || '';
  525. }
  526. let installed = false;
  527. let skill = null;
  528. try {
  529. await fs.access(path.join(installPath, 'SKILL.md'));
  530. installed = true;
  531. // Pull the summary back through the gateway so descriptions reflect
  532. // the same frontmatter parser the agent will use.
  533. const list = await callGateway('skillsList', {
  534. projectKey: resolved.wantProject ? resolved.projectPath : null,
  535. });
  536. const bucket = resolved.wantProject ? list.project : list.user;
  537. skill = bucket.find((s) => s.slug === slug) ?? null;
  538. } catch {
  539. /* not installed */
  540. }
  541. const needsForce =
  542. !installed && !force && (stderr || stdout).match(/Use --force to install suspicious/i) !== null;
  543. res.json({
  544. ok: installed,
  545. slug,
  546. scope: resolved.scope,
  547. installPath,
  548. installed,
  549. skill,
  550. stdout: stdout.trim(),
  551. stderr: stderr.trim(),
  552. exitCode: runError ? (runError.code === undefined ? 1 : runError.code) : 0,
  553. needsForce,
  554. });
  555. } catch (e) {
  556. console.error('[skills/clawhub/install]', e);
  557. res.status(500).json({ error: 'Install failed', message: e.message });
  558. }
  559. });
  560. export default router;