mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
* Add Claude-style design system workflow * Merge design system workflow into main * Restore design system workflow UI styles * Fix design system setup scrolling * Fix design system setup connector button * Preserve connector auth link after popup block * Simplify connected GitHub setup state * Open generated design system workspace project * Summarize design system auto prompt in chat * Add bounded GitHub connector design intake * Prefer path-scoped GitHub intake tools * Restore branch GitHub design context intake * Restore design system review workspace * Restore design system manager tab * Let design system workflow routes own details * Open editable design systems as projects * Restore design system workspace coverage * Fix bounded GitHub connector intake * Hide design system review while generating * Suppress design system generation questions * Constrain GitHub design intake to bounded command * Tolerate oversized GitHub metadata during intake * Rebuild daemon CLI when sources change * Fallback when GitHub connector snapshots are rate limited * Allow GitHub intake without Composio * Use native GitHub auth for design intake * Remove design system review group heading * Improve design system extraction evidence * Align design system scaffold with Claude output * Add evidence inventory for design system intake * Add local design system evidence intake * Add design system package audit gate * Allow auditing Claude Design reference packages * Audit design system package content quality * Migrate legacy design system artifacts * Clean migrated design system artifacts * Require modular design system UI kits * Reject thin design system UI kits * Prioritize core design evidence intake * Require role-based design system UI kits * Clean stale design system manifest references * Require representative preserved design assets * Warn on generic design system visuals * Enforce design system quality warnings * Audit connected design system UI kits * Require mounted design system UI kits * Require composed design system app shells * Require runnable JSX design system kits * Require browser globals for design system components * Infer design system names from source URLs * Require source examples in design system packages * Bind preserved fonts in design system tokens * Require skill frontmatter in design system packages * Preserve build icons in design system packages * Require real assets in brand previews * Require substantive source examples * Require product overview in design system README * Require reusable UI kit README * Require reusable design system skill docs * Seed Claude-style UI kit entry contract * Preserve runtime build assets in design packages * Audit design system packages after generation * Audit design system first-run output * Audit source-backed preview cards * Align design system UI kit scaffolds * Materialize design evidence package artifacts * Show project chat during design system setup * Hand off design system setup to project chat * Auto-repair design system audit failures * Harden design system evidence preservation * Tighten design system package guidance * Add targeted design system repair guidance * Bound design system audit auto repair * Use connector statuses in design system setup * Audit design system preview manifests * Require README preview manifests for design systems * Fix design system GitHub intake handoff * Fix daemon prompt CI assertions
733 lines
28 KiB
TypeScript
733 lines
28 KiB
TypeScript
import type { Express } from 'express';
|
|
import path from 'node:path';
|
|
import fs from 'node:fs';
|
|
import { detectAgents } from './agents.js';
|
|
import {
|
|
SkillImportError,
|
|
deleteUserSkill,
|
|
findSkillById,
|
|
importUserSkill,
|
|
listSkillFiles,
|
|
splitDerivedSkillId,
|
|
updateUserSkill,
|
|
} from './skills.js';
|
|
import { listCodexPets, readCodexPetSpritesheet } from './codex-pets.js';
|
|
import { syncCommunityPets } from './community-pets-sync.js';
|
|
import { readDesignSystem } from './design-systems.js';
|
|
import {
|
|
LocalDesignSystemImportError,
|
|
importLocalDesignSystemProject,
|
|
} from './design-system-import.js';
|
|
import { importGitHubDesignSystemProject } from './design-system-github-import.js';
|
|
import { renderDesignSystemPreview } from './design-system-preview.js';
|
|
import { renderDesignSystemShowcase } from './design-system-showcase.js';
|
|
import { listPromptTemplates, readPromptTemplate } from './prompt-templates.js';
|
|
import { readAppConfig } from './app-config.js';
|
|
import { installFromTarget, uninstallById } from './library-install.js';
|
|
import type { RouteDeps } from './server-context.js';
|
|
|
|
export interface RegisterStaticResourceRoutesDeps extends RouteDeps<'http' | 'paths' | 'resources'> {}
|
|
|
|
export function registerStaticResourceRoutes(app: Express, ctx: RegisterStaticResourceRoutesDeps) {
|
|
const {
|
|
RUNTIME_DATA_DIR,
|
|
RUNTIME_DATA_DIR_CANONICAL,
|
|
PROJECT_ROOT,
|
|
DESIGN_SYSTEMS_DIR,
|
|
USER_DESIGN_SYSTEMS_DIR,
|
|
DESIGN_TEMPLATES_DIR,
|
|
USER_DESIGN_TEMPLATES_DIR,
|
|
SKILLS_DIR,
|
|
USER_SKILLS_DIR,
|
|
PROMPT_TEMPLATES_DIR,
|
|
BUNDLED_PETS_DIR,
|
|
} = ctx.paths;
|
|
const {
|
|
listAllSkills,
|
|
listAllDesignTemplates,
|
|
listAllSkillLikeEntries,
|
|
listAllDesignSystems,
|
|
mimeFor,
|
|
} = ctx.resources;
|
|
const { isLocalSameOrigin, resolvedPortRef, sendApiError } = ctx.http;
|
|
const requireLocalOrigin = (req: any, res: any) => {
|
|
if (isLocalSameOrigin(req, resolvedPortRef.current)) return true;
|
|
sendApiError(res, 403, 'FORBIDDEN', 'local origin required');
|
|
return false;
|
|
};
|
|
|
|
app.get('/api/agents', async (_req, res) => {
|
|
try {
|
|
const config = await readAppConfig(RUNTIME_DATA_DIR);
|
|
const list = await detectAgents(config.agentCliEnv ?? {});
|
|
res.json({ agents: list });
|
|
} catch (err: any) {
|
|
res.status(500).json({ error: String(err) });
|
|
}
|
|
});
|
|
|
|
app.get('/api/skills', async (_req, res) => {
|
|
try {
|
|
const skills = await listAllSkills();
|
|
// Strip full body + on-disk dir from the listing — frontend fetches the
|
|
// body via /api/skills/:id when needed (keeps the listing payload small).
|
|
res.json({
|
|
skills: skills.map(({ body, dir: _dir, ...rest }) => ({
|
|
...rest,
|
|
hasBody: typeof body === 'string' && body.length > 0,
|
|
})),
|
|
});
|
|
} catch (err: any) {
|
|
res.status(500).json({ error: String(err) });
|
|
}
|
|
});
|
|
|
|
app.get('/api/skills/:id', async (req, res) => {
|
|
try {
|
|
const skills = await listAllSkills();
|
|
const skill = findSkillById(skills, req.params.id);
|
|
if (!skill) return res.status(404).json({ error: 'skill not found' });
|
|
const { dir: _dir, ...serializable } = skill;
|
|
res.json(serializable);
|
|
} catch (err: any) {
|
|
res.status(500).json({ error: String(err) });
|
|
}
|
|
});
|
|
|
|
// Design templates — the rendering catalogue. Same shape as /api/skills
|
|
// (so the web client can reuse SkillSummary types) but rooted at
|
|
// DESIGN_TEMPLATE_ROOTS so the listing stays focused on template-style
|
|
// entries without bleeding functional skills into the EntryView gallery.
|
|
app.get('/api/design-templates', async (_req, res) => {
|
|
try {
|
|
const templates = await listAllDesignTemplates();
|
|
res.json({
|
|
designTemplates: templates.map(({ body, dir: _dir, ...rest }) => ({
|
|
...rest,
|
|
hasBody: typeof body === 'string' && body.length > 0,
|
|
})),
|
|
});
|
|
} catch (err: any) {
|
|
res.status(500).json({ error: String(err) });
|
|
}
|
|
});
|
|
|
|
app.get('/api/design-templates/:id', async (req, res) => {
|
|
try {
|
|
const templates = await listAllDesignTemplates();
|
|
const template = findSkillById(templates, req.params.id);
|
|
if (!template) return res.status(404).json({ error: 'design template not found' });
|
|
const { dir: _dir, ...serializable } = template;
|
|
res.json(serializable);
|
|
} catch (err: any) {
|
|
res.status(500).json({ error: String(err) });
|
|
}
|
|
});
|
|
|
|
// POST /api/skills/import — write a new SKILL.md under USER_SKILLS_DIR
|
|
// from a UI-supplied body. The next /api/skills request surfaces it
|
|
// automatically because listSkills walks USER_SKILLS_DIR first.
|
|
app.post('/api/skills/import', async (req, res) => {
|
|
try {
|
|
const result = await importUserSkill(USER_SKILLS_DIR, req.body || {});
|
|
const skills = await listAllSkills();
|
|
const skill = findSkillById(skills, result.id);
|
|
if (!skill) {
|
|
return sendApiError(
|
|
res,
|
|
500,
|
|
'INTERNAL_ERROR',
|
|
'imported skill was not found in catalog',
|
|
);
|
|
}
|
|
const { dir: _dir, body: _body, ...serializable } = skill;
|
|
res.status(201).json({
|
|
skill: {
|
|
...serializable,
|
|
hasBody: typeof skill.body === 'string' && skill.body.length > 0,
|
|
},
|
|
});
|
|
} catch (err: any) {
|
|
if (err instanceof SkillImportError) {
|
|
const status = err.code === 'NOT_FOUND' ? 404 : err.code === 'BAD_REQUEST' ? 400 : 500;
|
|
return sendApiError(res, status, err.code, err.message);
|
|
}
|
|
sendApiError(res, 500, 'INTERNAL_ERROR', String(err));
|
|
}
|
|
});
|
|
|
|
// PUT /api/skills/:id — update an existing user-managed skill's
|
|
// SKILL.md (and, when the user edits a built-in for the first time,
|
|
// clone its side files into USER_SKILLS_DIR/<slug>/ so subsequent
|
|
// /api/skills/:id/{files,example,assets/*} requests keep resolving
|
|
// the bundled assets/references/scripts/examples). See PR #955 review.
|
|
app.put('/api/skills/:id', async (req, res) => {
|
|
try {
|
|
const skills = await listAllSkills();
|
|
const skill = findSkillById(skills, req.params.id);
|
|
if (!skill) {
|
|
return sendApiError(res, 404, 'NOT_FOUND', 'skill not found');
|
|
}
|
|
const result = await updateUserSkill(USER_SKILLS_DIR, {
|
|
...(req.body || {}),
|
|
id: skill.id,
|
|
sourceDir: skill.dir,
|
|
});
|
|
const next = await listAllSkills();
|
|
const updated = findSkillById(next, result.id);
|
|
if (!updated) {
|
|
return sendApiError(
|
|
res,
|
|
500,
|
|
'INTERNAL_ERROR',
|
|
'updated skill was not found in catalog',
|
|
);
|
|
}
|
|
const { dir: _dir, body: _body, ...serializable } = updated;
|
|
res.json({
|
|
skill: {
|
|
...serializable,
|
|
hasBody: typeof updated.body === 'string' && updated.body.length > 0,
|
|
},
|
|
});
|
|
} catch (err: any) {
|
|
if (err instanceof SkillImportError) {
|
|
const status = err.code === 'NOT_FOUND' ? 404 : err.code === 'BAD_REQUEST' ? 400 : 500;
|
|
return sendApiError(res, status, err.code, err.message);
|
|
}
|
|
sendApiError(res, 500, 'INTERNAL_ERROR', String(err));
|
|
}
|
|
});
|
|
|
|
// GET /api/skills/:id/files — flat listing of the files that ship with
|
|
// a skill. Used by the Settings → Skills detail panel to render the
|
|
// file tree (capped server-side to keep payload bounded).
|
|
app.get('/api/skills/:id/files', async (req, res) => {
|
|
try {
|
|
const skills = await listAllSkills();
|
|
const skill = findSkillById(skills, req.params.id);
|
|
if (!skill) {
|
|
return sendApiError(res, 404, 'NOT_FOUND', 'skill not found');
|
|
}
|
|
const files = await listSkillFiles(skill.dir);
|
|
res.json({ files });
|
|
} catch (err: any) {
|
|
sendApiError(res, 500, 'INTERNAL_ERROR', String(err));
|
|
}
|
|
});
|
|
|
|
// Codex hatch-pet registry — pets packaged by the upstream `hatch-pet`
|
|
// skill under `${CODEX_HOME:-$HOME/.codex}/pets/`. Surfaced so the web
|
|
// pet settings can offer one-click adoption of recently-hatched pets.
|
|
app.get('/api/codex-pets', async (_req, res) => {
|
|
try {
|
|
const result = await listCodexPets({
|
|
baseUrl: '',
|
|
bundledRoot: BUNDLED_PETS_DIR,
|
|
});
|
|
res.json(result);
|
|
} catch (err: any) {
|
|
res.status(500).json({ error: String(err) });
|
|
}
|
|
});
|
|
|
|
// One-click community sync. Hits the Codex Pet Share + j20 Hatchery
|
|
// catalogs and drops every pet into `${CODEX_HOME:-$HOME/.codex}/pets/`
|
|
// so `GET /api/codex-pets` (and the web Pet settings) pick them up
|
|
// immediately. The body is intentionally tiny — we keep the heavier
|
|
// tuning knobs (`--limit`, `--concurrency`) on the CLI script and
|
|
// only surface `force` + `source` here.
|
|
app.post('/api/codex-pets/sync', async (req, res) => {
|
|
try {
|
|
const body = req.body && typeof req.body === 'object' ? req.body : {};
|
|
const sourceRaw = typeof body.source === 'string' ? body.source : 'all';
|
|
const source =
|
|
sourceRaw === 'petshare' || sourceRaw === 'hatchery'
|
|
? sourceRaw
|
|
: 'all';
|
|
const result = await syncCommunityPets({
|
|
source,
|
|
force: Boolean(body.force),
|
|
});
|
|
res.json(result);
|
|
} catch (err: any) {
|
|
res.status(500).json({ error: String((err && err.message) || err) });
|
|
}
|
|
});
|
|
|
|
app.get('/api/codex-pets/:id/spritesheet', async (req, res) => {
|
|
try {
|
|
const sheet = await readCodexPetSpritesheet(req.params.id, {
|
|
bundledRoot: BUNDLED_PETS_DIR,
|
|
});
|
|
if (!sheet) {
|
|
return res
|
|
.status(404)
|
|
.type('text/plain')
|
|
.send('codex pet spritesheet not found');
|
|
}
|
|
const mime =
|
|
sheet.ext === 'webp'
|
|
? 'image/webp'
|
|
: sheet.ext === 'gif'
|
|
? 'image/gif'
|
|
: 'image/png';
|
|
res.type(mime);
|
|
// Same-origin callers (the web app proxies `/api/*` through to
|
|
// the daemon, so PetSettings adoption fetches arrive same-origin)
|
|
// do not need any CORS header here. We only echo
|
|
// `Access-Control-Allow-Origin` for sandboxed iframes / data:
|
|
// URIs (Origin: null) which need it to draw the bytes onto a
|
|
// canvas without tainting. Local pet bytes should not be exposed
|
|
// to arbitrary third-party origins via a wildcard ACAO.
|
|
if (req.headers.origin === 'null') {
|
|
res.setHeader('Access-Control-Allow-Origin', 'null');
|
|
}
|
|
res.setHeader('Cache-Control', 'no-store');
|
|
res.sendFile(sheet.absPath);
|
|
} catch (err: any) {
|
|
res.status(500).type('text/plain').send(String(err));
|
|
}
|
|
});
|
|
|
|
app.get('/api/design-systems', async (_req, res) => {
|
|
try {
|
|
const systems = await listAllDesignSystems();
|
|
res.json({
|
|
designSystems: systems.map(({ body, ...rest }) => rest),
|
|
});
|
|
} catch (err: any) {
|
|
res.status(500).json({ error: String(err) });
|
|
}
|
|
});
|
|
|
|
app.get('/api/design-systems/:id', (_req, _res, next) => {
|
|
// The design-system workflow owns the detail shape now because user-created
|
|
// systems may be backed by a review workspace project. Let the richer route
|
|
// registered in server.ts answer this request.
|
|
next();
|
|
});
|
|
|
|
app.get('/api/prompt-templates', async (_req, res) => {
|
|
try {
|
|
const templates = await listPromptTemplates(PROMPT_TEMPLATES_DIR);
|
|
res.json({
|
|
promptTemplates: templates.map(({ prompt: _prompt, ...rest }) => rest),
|
|
});
|
|
} catch (err: any) {
|
|
res.status(500).json({ error: String(err) });
|
|
}
|
|
});
|
|
|
|
app.get('/api/prompt-templates/:surface/:id', async (req, res) => {
|
|
try {
|
|
const tpl = await readPromptTemplate(
|
|
PROMPT_TEMPLATES_DIR,
|
|
req.params.surface,
|
|
req.params.id,
|
|
);
|
|
if (!tpl)
|
|
return res.status(404).json({ error: 'prompt template not found' });
|
|
res.json({ promptTemplate: tpl });
|
|
} catch (err: any) {
|
|
res.status(500).json({ error: String(err) });
|
|
}
|
|
});
|
|
|
|
// Showcase HTML for a design system — palette swatches, typography
|
|
// samples, sample components, and the full DESIGN.md rendered as prose.
|
|
// Built at request time from the on-disk DESIGN.md so any update to the
|
|
// file shows up on the next view, no rebuild needed.
|
|
app.get('/api/design-systems/:id/preview', (_req, _res, next) => {
|
|
next();
|
|
});
|
|
|
|
// Marketing-style showcase derived from the same DESIGN.md — full landing
|
|
// page parameterised by the system's tokens. Same lazy-render strategy as
|
|
// /preview: built at request time, no caching.
|
|
app.get('/api/design-systems/:id/showcase', (_req, _res, next) => {
|
|
next();
|
|
});
|
|
|
|
// Pre-built example HTML for a skill — what a typical artifact from this
|
|
// skill looks like. Lets users browse skills without running an agent.
|
|
//
|
|
// The skill's `id` (from SKILL.md frontmatter `name`) can differ from its
|
|
// on-disk folder name (e.g. id `magazine-web-ppt` lives in `skills/guizang-ppt/`),
|
|
// so we resolve the actual directory via listSkills() rather than guessing.
|
|
//
|
|
// Resolution order:
|
|
// 1. Derived id (`<parent>:<child>`):
|
|
// <parentDir>/examples/<child>.html — pre-baked single-file sample.
|
|
// Subfolder layouts (e.g. live-artifact's
|
|
// `examples/<name>/template.html`) are intentionally not served:
|
|
// they still contain `{{data.x}}` placeholders that only the
|
|
// daemon-side renderer fills in, and serving the raw template
|
|
// would render visible placeholder braces in the gallery.
|
|
// 2. <skillDir>/example.html — fully-baked static example (preferred)
|
|
// 3. <skillDir>/assets/template.html +
|
|
// <skillDir>/assets/example-slides.html — assemble at request time
|
|
// by replacing the `<!-- SLIDES_HERE -->` marker with the snippet
|
|
// and patching the placeholder <title>. Lets a skill ship one
|
|
// canonical seed plus a small content fragment, so the example
|
|
// never drifts from the seed.
|
|
// 4. <skillDir>/assets/template.html — raw template, no content slides
|
|
// 5. <skillDir>/assets/index.html — generic fallback
|
|
// 6. First .html in <skillDir>/examples/ — used as a friendly fallback
|
|
// so a skill that aggregates examples (like live-artifact) still has
|
|
// a real preview on its parent card instead of returning 404.
|
|
app.get('/api/skills/:id/example', async (req, res) => {
|
|
try {
|
|
// Span both functional skills and design templates: rendered example
|
|
// HTML rewrites assets to /api/skills/<id>/... and we want those URLs
|
|
// to keep resolving regardless of which root owns the backing folder
|
|
// after the skills/design-templates split.
|
|
const skills = await listAllSkillLikeEntries();
|
|
|
|
// 1. Derived `<parent>:<child>` id — resolve straight to the matching
|
|
// file under <parentDir>/examples/. Done before findSkillById so the
|
|
// parent's normal fallback chain never accidentally serves a stale
|
|
// file when a sample is missing (we'd rather 404 explicitly).
|
|
const derived = splitDerivedSkillId(req.params.id);
|
|
if (derived) {
|
|
const parent = findSkillById(skills, derived.parentId);
|
|
if (!parent) {
|
|
return res.status(404).type('text/plain').send('skill not found');
|
|
}
|
|
const candidate = path.join(
|
|
parent.dir,
|
|
'examples',
|
|
`${derived.childKey}.html`,
|
|
);
|
|
if (fs.existsSync(candidate)) {
|
|
const html = await fs.promises.readFile(candidate, 'utf8');
|
|
return res
|
|
.type('text/html')
|
|
.send(rewriteSkillAssetUrls(html, parent.id));
|
|
}
|
|
return res
|
|
.status(404)
|
|
.type('text/plain')
|
|
.send('derived example not found');
|
|
}
|
|
|
|
const skill = findSkillById(skills, req.params.id);
|
|
if (!skill) {
|
|
return res.status(404).type('text/plain').send('skill not found');
|
|
}
|
|
|
|
const baked = path.join(skill.dir, 'example.html');
|
|
if (fs.existsSync(baked)) {
|
|
const html = await fs.promises.readFile(baked, 'utf8');
|
|
return res
|
|
.type('text/html')
|
|
.send(rewriteSkillAssetUrls(html, skill.id));
|
|
}
|
|
|
|
const tpl = path.join(skill.dir, 'assets', 'template.html');
|
|
const slides = path.join(skill.dir, 'assets', 'example-slides.html');
|
|
if (fs.existsSync(tpl) && fs.existsSync(slides)) {
|
|
try {
|
|
const tplHtml = await fs.promises.readFile(tpl, 'utf8');
|
|
const slidesHtml = await fs.promises.readFile(slides, 'utf8');
|
|
const assembled = assembleExample(tplHtml, slidesHtml, skill.name);
|
|
return res
|
|
.type('text/html')
|
|
.send(rewriteSkillAssetUrls(assembled, skill.id));
|
|
} catch {
|
|
// Fall through to raw template on read failure.
|
|
}
|
|
}
|
|
if (fs.existsSync(tpl)) {
|
|
const html = await fs.promises.readFile(tpl, 'utf8');
|
|
return res
|
|
.type('text/html')
|
|
.send(rewriteSkillAssetUrls(html, skill.id));
|
|
}
|
|
const idx = path.join(skill.dir, 'assets', 'index.html');
|
|
if (fs.existsSync(idx)) {
|
|
const html = await fs.promises.readFile(idx, 'utf8');
|
|
return res
|
|
.type('text/html')
|
|
.send(rewriteSkillAssetUrls(html, skill.id));
|
|
}
|
|
|
|
// Friendly fallback for skills that aggregate examples in a sibling
|
|
// `examples/` folder (e.g. live-artifact). The parent card would
|
|
// otherwise 404 even though plenty of perfectly valid samples ship
|
|
// alongside SKILL.md; pick the first .html file alphabetically so
|
|
// direct URL access (e.g. deep links) shows something representative.
|
|
// Subfolder layouts are excluded for the same reason as the derived
|
|
// resolver above — their `template.html` still has unresolved
|
|
// `{{data.x}}` placeholders.
|
|
const examplesDir = path.join(skill.dir, 'examples');
|
|
if (fs.existsSync(examplesDir)) {
|
|
let entries: string[] = [];
|
|
try {
|
|
entries = await fs.promises.readdir(examplesDir);
|
|
} catch {
|
|
entries = [];
|
|
}
|
|
entries.sort();
|
|
for (const name of entries) {
|
|
if (name.startsWith('.')) continue;
|
|
if (!name.toLowerCase().endsWith('.html')) continue;
|
|
const direct = path.join(examplesDir, name);
|
|
try {
|
|
const html = await fs.promises.readFile(direct, 'utf8');
|
|
return res
|
|
.type('text/html')
|
|
.send(rewriteSkillAssetUrls(html, skill.id));
|
|
} catch {
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
|
|
res
|
|
.status(404)
|
|
.type('text/plain')
|
|
.send(
|
|
'no example.html, assets/template.html, assets/index.html, or examples/*.html for this skill',
|
|
);
|
|
} catch (err: any) {
|
|
res.status(500).type('text/plain').send(String(err));
|
|
}
|
|
});
|
|
|
|
// Static assets shipped beside a skill's example/template HTML. Lets the
|
|
// example HTML reference `./assets/foo.png`-style paths that resolve
|
|
// correctly when the response is loaded into a sandboxed `srcdoc` iframe
|
|
// (where relative URLs would otherwise resolve against `about:srcdoc`).
|
|
// The example response above rewrites `./assets/<file>` into a request
|
|
// against this route; we still keep the on-disk paths human-friendly so
|
|
// contributors can preview `example.html` straight from disk.
|
|
app.get('/api/skills/:id/assets/*', async (req, res) => {
|
|
try {
|
|
// Same rationale as /example above — assets need to resolve whether
|
|
// the owning skill folder lives under skills/ or design-templates/.
|
|
const skills = await listAllSkillLikeEntries();
|
|
const skill = findSkillById(skills, req.params.id);
|
|
if (!skill) {
|
|
return res.status(404).type('text/plain').send('skill not found');
|
|
}
|
|
const relPath = String((req.params as any)[0] || '');
|
|
const assetsRoot = path.resolve(skill.dir, 'assets');
|
|
const target = path.resolve(assetsRoot, relPath);
|
|
if (target !== assetsRoot && !target.startsWith(assetsRoot + path.sep)) {
|
|
return res.status(400).type('text/plain').send('invalid asset path');
|
|
}
|
|
if (!fs.existsSync(target)) {
|
|
return res.status(404).type('text/plain').send('asset not found');
|
|
}
|
|
// The example HTML is rendered inside a sandboxed iframe (Origin: null).
|
|
// Mirror the project /raw route's allowance so the iframe can fetch the
|
|
// image bytes; same-origin web callers do not need this header.
|
|
if (req.headers.origin === 'null') {
|
|
res.header('Access-Control-Allow-Origin', '*');
|
|
}
|
|
res.type(mimeFor(target)).sendFile(target);
|
|
} catch (err: any) {
|
|
res.status(500).type('text/plain').send(String(err));
|
|
}
|
|
});
|
|
|
|
app.post('/api/skills/install', async (req, res) => {
|
|
if (!requireLocalOrigin(req, res)) return;
|
|
try {
|
|
const result = await installFromTarget(req.body, USER_SKILLS_DIR, 'skill');
|
|
if (!result.ok) return res.status(400).json({ error: result.error });
|
|
if (typeof result.dir !== 'string' || !result.dir) {
|
|
return res.status(500).json({ error: 'skill install did not return an installation directory' });
|
|
}
|
|
const skills = await listAllSkills();
|
|
const installedDir = fs.realpathSync.native(result.dir);
|
|
const skill = skills.find((candidate) => fs.realpathSync.native(candidate.dir) === installedDir);
|
|
if (!skill) {
|
|
return res.status(500).json({ error: `installed skill was not found in catalog: ${result.dir}` });
|
|
}
|
|
res.json({
|
|
skill: {
|
|
...skill,
|
|
dir: undefined,
|
|
body: undefined,
|
|
hasBody: typeof skill.body === 'string' && skill.body.length > 0,
|
|
},
|
|
});
|
|
} catch (err: any) {
|
|
res.status(500).json({ error: String(err) });
|
|
}
|
|
});
|
|
|
|
app.delete('/api/skills/:id', async (req, res) => {
|
|
if (!requireLocalOrigin(req, res)) return;
|
|
try {
|
|
const result = await uninstallById(req.params.id, USER_SKILLS_DIR, SKILLS_DIR, 'skill');
|
|
if (!result.ok) return res.status(result.status || 400).json({ error: result.error });
|
|
res.json({ ok: true });
|
|
} catch (err: any) {
|
|
res.status(500).json({ error: String(err) });
|
|
}
|
|
});
|
|
|
|
app.post('/api/design-systems/install', async (req, res) => {
|
|
if (!requireLocalOrigin(req, res)) return;
|
|
try {
|
|
const result = await installFromTarget(req.body, USER_DESIGN_SYSTEMS_DIR, 'design-system');
|
|
if (!result.ok) return res.status(400).json({ error: result.error });
|
|
if (typeof result.dir !== 'string' || !result.dir) {
|
|
return res.status(500).json({ error: 'design system install did not return an installation directory' });
|
|
}
|
|
const systems = await listAllDesignSystems();
|
|
const designSystemId = path.basename(fs.realpathSync.native(result.dir));
|
|
const designSystem = systems.find((system) => system.id === designSystemId);
|
|
if (!designSystem) {
|
|
return res.status(500).json({ error: `installed design system was not found in catalog: ${result.dir}` });
|
|
}
|
|
res.json({ designSystem });
|
|
} catch (err: any) {
|
|
res.status(500).json({ error: String(err) });
|
|
}
|
|
});
|
|
|
|
app.post('/api/design-systems/import/local', async (req, res) => {
|
|
if (!requireLocalOrigin(req, res)) return;
|
|
try {
|
|
const body = req.body && typeof req.body === 'object' ? req.body : {};
|
|
const inputPath =
|
|
typeof body.baseDir === 'string'
|
|
? body.baseDir
|
|
: typeof body.path === 'string'
|
|
? body.path
|
|
: typeof body.localPath === 'string'
|
|
? body.localPath
|
|
: '';
|
|
if (!path.isAbsolute(inputPath)) {
|
|
return sendApiError(res, 400, 'BAD_REQUEST', 'local project path must be absolute');
|
|
}
|
|
let sourceRoot: string;
|
|
let sourceStats: fs.Stats;
|
|
try {
|
|
sourceRoot = fs.realpathSync.native(inputPath);
|
|
sourceStats = fs.statSync(sourceRoot);
|
|
} catch {
|
|
return sendApiError(res, 400, 'BAD_REQUEST', 'local project path was not found');
|
|
}
|
|
if (!sourceStats.isDirectory()) {
|
|
return sendApiError(res, 400, 'BAD_REQUEST', 'local project path must be a directory');
|
|
}
|
|
const sourceParent = path.dirname(sourceRoot);
|
|
if (sourceRoot === sourceParent) {
|
|
return sendApiError(res, 400, 'BAD_REQUEST', 'local project path cannot be a filesystem root');
|
|
}
|
|
try {
|
|
const runtimeRoot = fs.realpathSync.native(RUNTIME_DATA_DIR_CANONICAL);
|
|
if (sourceRoot === runtimeRoot || sourceRoot.startsWith(`${runtimeRoot}${path.sep}`)) {
|
|
return sendApiError(res, 400, 'BAD_REQUEST', 'cannot import Open Design runtime data');
|
|
}
|
|
} catch {
|
|
// The runtime data directory may not exist yet in first-run tests.
|
|
}
|
|
|
|
const before = await listAllDesignSystems();
|
|
const result = await importLocalDesignSystemProject(sourceRoot, USER_DESIGN_SYSTEMS_DIR, {
|
|
name: typeof body.name === 'string' ? body.name : undefined,
|
|
reservedIds: before.map((system) => system.id),
|
|
});
|
|
const systems = await listAllDesignSystems();
|
|
const designSystem = systems.find((system) => system.id === result.id);
|
|
if (!designSystem) {
|
|
return sendApiError(
|
|
res,
|
|
500,
|
|
'INTERNAL_ERROR',
|
|
`imported design system was not found in catalog: ${result.dir}`,
|
|
);
|
|
}
|
|
res.status(201).json({ designSystem });
|
|
} catch (err: any) {
|
|
if (err instanceof LocalDesignSystemImportError) {
|
|
return sendApiError(res, err.code === 'BAD_REQUEST' ? 400 : 500, err.code, err.message);
|
|
}
|
|
sendApiError(res, 500, 'INTERNAL_ERROR', String(err));
|
|
}
|
|
});
|
|
|
|
app.post('/api/design-systems/import/github', async (req, res) => {
|
|
if (!requireLocalOrigin(req, res)) return;
|
|
try {
|
|
const body = req.body && typeof req.body === 'object' ? req.body : {};
|
|
const githubUrl =
|
|
typeof body.githubUrl === 'string'
|
|
? body.githubUrl
|
|
: typeof body.url === 'string'
|
|
? body.url
|
|
: '';
|
|
const before = await listAllDesignSystems();
|
|
const result = await importGitHubDesignSystemProject(
|
|
githubUrl,
|
|
path.join(PROJECT_ROOT, '.tmp'),
|
|
USER_DESIGN_SYSTEMS_DIR,
|
|
{
|
|
name: typeof body.name === 'string' ? body.name : undefined,
|
|
branch: typeof body.branch === 'string' ? body.branch : undefined,
|
|
reservedIds: before.map((system) => system.id),
|
|
},
|
|
);
|
|
const systems = await listAllDesignSystems();
|
|
const designSystem = systems.find((system) => system.id === result.id);
|
|
if (!designSystem) {
|
|
return sendApiError(
|
|
res,
|
|
500,
|
|
'INTERNAL_ERROR',
|
|
`imported GitHub design system was not found in catalog: ${result.dir}`,
|
|
);
|
|
}
|
|
res.status(201).json({ designSystem });
|
|
} catch (err: any) {
|
|
if (err instanceof LocalDesignSystemImportError) {
|
|
return sendApiError(res, err.code === 'BAD_REQUEST' ? 400 : 500, err.code, err.message);
|
|
}
|
|
sendApiError(res, 500, 'INTERNAL_ERROR', String(err));
|
|
}
|
|
});
|
|
|
|
app.delete('/api/design-systems/:id', async (req, res, next) => {
|
|
if (!requireLocalOrigin(req, res)) return;
|
|
if (req.params.id.startsWith('user:')) {
|
|
return next();
|
|
}
|
|
try {
|
|
const result = await uninstallById(
|
|
req.params.id,
|
|
USER_DESIGN_SYSTEMS_DIR,
|
|
DESIGN_SYSTEMS_DIR,
|
|
'design-system',
|
|
);
|
|
if (!result.ok) return res.status(result.status || 400).json({ error: result.error });
|
|
res.json({ ok: true });
|
|
} catch (err: any) {
|
|
res.status(500).json({ error: String(err) });
|
|
}
|
|
});
|
|
|
|
}
|
|
|
|
function assembleExample(templateHtml: string, slidesHtml: string, title: string) {
|
|
return templateHtml
|
|
.replace('<!-- SLIDES_HERE -->', slidesHtml)
|
|
.replace(/<title>.*?<\/title>/, `<title>${title} | Open Design Example</title>`);
|
|
}
|
|
|
|
function rewriteSkillAssetUrls(html: string, skillId: string) {
|
|
if (typeof html !== 'string' || html.length === 0) return html;
|
|
return html.replace(
|
|
/(\s(?:src|href)\s*=\s*)(['"])((?:\.\.\/([^/'"#?]+)\/)?(?:\.\/)?assets\/([^'"#?]+))(\2)/gi,
|
|
(_match, attr, openQuote, _fullPath, siblingSkillId, relPath, closeQuote) => {
|
|
const resolvedSkillId = siblingSkillId || skillId;
|
|
const prefix = `/api/skills/${encodeURIComponent(resolvedSkillId)}/assets/`;
|
|
return `${attr}${openQuote}${prefix}${relPath}${closeQuote}`;
|
|
},
|
|
);
|
|
}
|