feat(arcrun): mira wiki page with tag filter + accumulated WIP
- landing/app/mira/wiki: tag=mira-wiki list now shows all wiki paragraphs (depends on KBDB tag filter exposed in matrix/kbdb commit, separate repo) - landing: app/mira hub + feed split + various WIP from prior sessions - registry/components: claude_api / kbdb_create_block / kbdb_get / km_writer / platform_crypto / auth_oauth2 contracts + main.go (accumulated) - .component-builds: pkg-lock updates + index.ts adjustments (WIP) - .agents/specs/arcrun/frontend-redesign: design notes - docs/test_credentials, docs/user_requirements/arcrun-landing-page: WIP docs - cypher-executor: auth-dispatcher / wasi-shim adjustments (WIP) Includes accumulated work from prior sessions plus the wiki UI tag-filter update that surfaces the AI-generated wiki paragraphs at /mira/wiki. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,25 @@
|
|||||||
|
# CODING AGENTS: READ THIS FIRST
|
||||||
|
|
||||||
|
This is a **handoff bundle** from Claude Design (claude.ai/design).
|
||||||
|
|
||||||
|
A user mocked up designs in HTML/CSS/JS using an AI design tool, then exported this bundle so a coding agent can implement the designs for real.
|
||||||
|
|
||||||
|
## What you should do — IMPORTANT
|
||||||
|
|
||||||
|
**Read the chat transcripts first.** There are 1 chat transcript(s) in `arcrun/chats/`. The transcripts show the full back-and-forth between the user and the design assistant — they tell you **what the user actually wants** and **where they landed** after iterating. Don't skip them. The final HTML files are the output, but the chat is where the intent lives.
|
||||||
|
|
||||||
|
**Find the primary design file under `arcrun/project/` and read it top to bottom.** The chat transcripts will tell you which file the user was last iterating on. Then **follow its imports**: open every file it pulls in (shared components, CSS, scripts) so you understand how the pieces fit together before you start implementing.
|
||||||
|
|
||||||
|
**If anything is ambiguous, ask the user to confirm before you start implementing.** It's much cheaper to clarify scope up front than to build the wrong thing.
|
||||||
|
|
||||||
|
## About the design files
|
||||||
|
|
||||||
|
The design medium is **HTML/CSS/JS** — these are prototypes, not production code. Your job is to **recreate them pixel-perfectly** in whatever technology makes sense for the target codebase (React, Vue, native, whatever fits). Match the visual output; don't copy the prototype's internal structure unless it happens to fit.
|
||||||
|
|
||||||
|
**Don't render these files in a browser or take screenshots unless the user asks you to.** Everything you need — dimensions, colors, layout rules — is spelled out in the source. Read the HTML and CSS directly; a screenshot won't tell you anything they don't.
|
||||||
|
|
||||||
|
## Bundle contents
|
||||||
|
|
||||||
|
- `arcrun/README.md` — this file
|
||||||
|
- `arcrun/chats/` — conversation transcripts (read these!)
|
||||||
|
- `arcrun/project/` — the `arcrun` project files (HTML prototypes, assets, components)
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
// App root — screen switcher with persistent route
|
||||||
|
const { useState, useEffect } = React;
|
||||||
|
|
||||||
|
const SCREENS = [
|
||||||
|
{ id: 'landing', label: 'Landing' },
|
||||||
|
{ id: 'auth', label: 'Auth' },
|
||||||
|
{ id: 'dashboard', label: 'Dashboard' },
|
||||||
|
{ id: 'keys', label: 'API Keys' },
|
||||||
|
{ id: 'workflow', label: 'Workflow' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Synonyms from sidebar ids
|
||||||
|
const aliases = { apps: 'dashboard', workflows: 'dashboard', docs: 'landing', settings: 'keys' };
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const [screen, setScreen] = useState(() => {
|
||||||
|
const saved = localStorage.getItem('arcrun:screen');
|
||||||
|
return saved && SCREENS.some(s => s.id === saved) ? saved : 'landing';
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem('arcrun:screen', screen);
|
||||||
|
window.scrollTo(0, 0);
|
||||||
|
}, [screen]);
|
||||||
|
|
||||||
|
const nav = (id) => {
|
||||||
|
const resolved = aliases[id] || id;
|
||||||
|
if (SCREENS.some(s => s.id === resolved)) setScreen(resolved);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Current = {
|
||||||
|
landing: Landing,
|
||||||
|
auth: Auth,
|
||||||
|
dashboard: Dashboard,
|
||||||
|
keys: ApiKeys,
|
||||||
|
workflow: WorkflowViewer,
|
||||||
|
}[screen];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="app">
|
||||||
|
<Current onNav={nav} />
|
||||||
|
|
||||||
|
<div className="proto-switch" role="tablist" aria-label="Screen switcher">
|
||||||
|
{SCREENS.map(s => (
|
||||||
|
<button key={s.id}
|
||||||
|
className={screen === s.id ? 'active' : ''}
|
||||||
|
onClick={() => nav(s.id)}>
|
||||||
|
{s.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
// Top nav and sidebar
|
||||||
|
|
||||||
|
const TopNav = ({ onNav, current }) => {
|
||||||
|
const [scrolled, setScrolled] = React.useState(false);
|
||||||
|
React.useEffect(() => {
|
||||||
|
const onScroll = () => setScrolled(window.scrollY > 8);
|
||||||
|
window.addEventListener('scroll', onScroll);
|
||||||
|
return () => window.removeEventListener('scroll', onScroll);
|
||||||
|
}, []);
|
||||||
|
return (
|
||||||
|
<nav className={`topnav ${scrolled ? 'scrolled' : ''}`}>
|
||||||
|
<div className="flex gap-12" style={{alignItems: 'center'}}>
|
||||||
|
<Logo onClick={() => onNav('landing')} />
|
||||||
|
<div className="nav-links" style={{marginLeft: 20}}>
|
||||||
|
<a>Product</a>
|
||||||
|
<a>Docs</a>
|
||||||
|
<a>Pricing</a>
|
||||||
|
<a>Changelog</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-8" style={{alignItems: 'center'}}>
|
||||||
|
<button className="btn btn-ghost" onClick={() => onNav('auth')}>Log in</button>
|
||||||
|
<button className="btn btn-primary" onClick={() => onNav('auth')}>
|
||||||
|
Get started <Icon name="arrow_right" size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Footer = ({ onNav }) => (
|
||||||
|
<footer className="footer">
|
||||||
|
<div className="flex gap-12" style={{alignItems: 'center'}}>
|
||||||
|
<Logo size="sm" />
|
||||||
|
<span>© 2026 Arcrun Labs</span>
|
||||||
|
</div>
|
||||||
|
<div className="footer-links">
|
||||||
|
<a>Docs</a>
|
||||||
|
<a>Pricing</a>
|
||||||
|
<a>Changelog</a>
|
||||||
|
<a>Status</a>
|
||||||
|
<a>Privacy</a>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
|
||||||
|
// App shell with sidebar for logged-in screens
|
||||||
|
const Sidebar = ({ current, onNav }) => {
|
||||||
|
const items = [
|
||||||
|
{ id: 'dashboard', label: 'Dashboard', icon: 'home' },
|
||||||
|
{ id: 'apps', label: 'Apps', icon: 'grid', count: 6 },
|
||||||
|
{ id: 'workflows', label: 'Workflows', icon: 'workflow', count: 12 },
|
||||||
|
{ id: 'keys', label: 'API Keys', icon: 'key' },
|
||||||
|
{ id: 'docs', label: 'Docs', icon: 'book' },
|
||||||
|
];
|
||||||
|
const bottom = [
|
||||||
|
{ id: 'settings', label: 'Settings', icon: 'settings' },
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<aside className="sidebar">
|
||||||
|
<div className="sidebar-head">
|
||||||
|
<Logo size="md" onClick={() => onNav('landing')} />
|
||||||
|
</div>
|
||||||
|
<div className="sidebar-section">Workspace</div>
|
||||||
|
{items.map(it => (
|
||||||
|
<div key={it.id}
|
||||||
|
className={`sidebar-item ${current === it.id ? 'active' : ''}`}
|
||||||
|
onClick={() => onNav(it.id)}>
|
||||||
|
<span className="sb-ico"><Icon name={it.icon} size={15} /></span>
|
||||||
|
<span>{it.label}</span>
|
||||||
|
{it.count != null && <span className="sb-count">{it.count}</span>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div style={{flex: 1}} />
|
||||||
|
{bottom.map(it => (
|
||||||
|
<div key={it.id} className="sidebar-item" onClick={() => onNav(it.id)}>
|
||||||
|
<span className="sb-ico"><Icon name={it.icon} size={15} /></span>
|
||||||
|
<span>{it.label}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="sidebar-foot">
|
||||||
|
<div className="avatar-circ">MR</div>
|
||||||
|
<div className="meta">
|
||||||
|
<div className="name">Maya Rivera</div>
|
||||||
|
<div className="email">maya@northwind.co</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
Object.assign(window, { TopNav, Footer, Sidebar });
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
// Shared primitives: icons, logo, etc.
|
||||||
|
|
||||||
|
const Icon = ({ name, size = 16, stroke = 1.7 }) => {
|
||||||
|
const paths = {
|
||||||
|
arrow_right: <path d="M5 12h14M13 6l6 6-6 6" />,
|
||||||
|
arrow_left: <path d="M19 12H5M11 6l-6 6 6 6" />,
|
||||||
|
plus: <path d="M12 5v14M5 12h14" />,
|
||||||
|
copy: <><rect x="9" y="9" width="13" height="13" rx="2" /><path d="M5 15V5a2 2 0 0 1 2-2h10" /></>,
|
||||||
|
check: <path d="M20 6L9 17l-5-5" />,
|
||||||
|
close: <path d="M18 6L6 18M6 6l12 12" />,
|
||||||
|
eye: <><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" /><circle cx="12" cy="12" r="3" /></>,
|
||||||
|
search: <><circle cx="11" cy="11" r="7" /><path d="M21 21l-4.35-4.35" /></>,
|
||||||
|
warn: <><path d="M10.3 3.86L1.82 18a2 2 0 001.72 3h16.92a2 2 0 001.72-3L13.7 3.86a2 2 0 00-3.4 0z" /><line x1="12" y1="9" x2="12" y2="13" /><circle cx="12" cy="17" r="0.5" fill="currentColor" /></>,
|
||||||
|
home: <><path d="M3 10l9-7 9 7v10a2 2 0 01-2 2h-4a2 2 0 01-2-2v-5h-2v5a2 2 0 01-2 2H5a2 2 0 01-2-2V10z" /></>,
|
||||||
|
grid: <><rect x="3" y="3" width="7" height="7" rx="1" /><rect x="14" y="3" width="7" height="7" rx="1" /><rect x="3" y="14" width="7" height="7" rx="1" /><rect x="14" y="14" width="7" height="7" rx="1" /></>,
|
||||||
|
workflow: <><circle cx="5" cy="6" r="2" /><circle cx="19" cy="12" r="2" /><circle cx="5" cy="18" r="2" /><path d="M7 6h4a4 4 0 014 4v0m0 4a4 4 0 01-4 4H7" /></>,
|
||||||
|
key: <><circle cx="7.5" cy="15.5" r="4.5" /><path d="M10.68 12.32L21 2M17 6l3 3M15 8l3 3" /></>,
|
||||||
|
book: <><path d="M2 3h6a4 4 0 014 4v14a3 3 0 00-3-3H2zM22 3h-6a4 4 0 00-4 4v14a3 3 0 013-3h7z" /></>,
|
||||||
|
settings: <><circle cx="12" cy="12" r="3" /><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 01-2.83 2.83l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06a1.65 1.65 0 00.33-1.82 1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 012.83-2.83l.06.06A1.65 1.65 0 009 4.6a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09A1.65 1.65 0 0015 4.6a1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06A1.65 1.65 0 0019.4 9v0a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z" /></>,
|
||||||
|
chevron_right: <path d="M9 6l6 6-6 6" />,
|
||||||
|
chevron_down: <path d="M6 9l6 6 6-6" />,
|
||||||
|
external: <><path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6" /><path d="M15 3h6v6M10 14L21 3" /></>,
|
||||||
|
trash: <><polyline points="3 6 5 6 21 6" /><path d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6M10 11v6M14 11v6" /></>,
|
||||||
|
spark: <path d="M12 3l2.5 6.5L21 12l-6.5 2.5L12 21l-2.5-6.5L3 12l6.5-2.5L12 3z" />,
|
||||||
|
bolt: <path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z" />,
|
||||||
|
github: <path d="M12 2C6.48 2 2 6.48 2 12c0 4.42 2.87 8.17 6.84 9.5.5.08.66-.22.66-.48v-1.7c-2.78.6-3.36-1.34-3.36-1.34-.46-1.15-1.12-1.46-1.12-1.46-.92-.62.07-.6.07-.6 1.01.07 1.55 1.04 1.55 1.04.9 1.54 2.36 1.1 2.94.84.09-.65.35-1.1.64-1.35-2.22-.25-4.55-1.11-4.55-4.94 0-1.09.39-1.98 1.03-2.68-.1-.25-.45-1.27.1-2.65 0 0 .84-.27 2.75 1.02A9.5 9.5 0 0112 6.8c.85 0 1.7.11 2.5.33 1.9-1.3 2.75-1.02 2.75-1.02.55 1.38.2 2.4.1 2.65.64.7 1.03 1.6 1.03 2.68 0 3.84-2.34 4.69-4.57 4.93.36.31.68.92.68 1.85V21c0 .27.16.57.67.48A10 10 0 0022 12c0-5.52-4.48-10-10-10z" fill="currentColor" stroke="none" />,
|
||||||
|
google: <><path d="M21.35 11.1h-9.17v2.73h5.24c-.23 1.41-1.69 4.13-5.24 4.13-3.15 0-5.73-2.62-5.73-5.86 0-3.24 2.58-5.86 5.73-5.86 1.8 0 3 .77 3.69 1.43l2.5-2.4C16.95 3.74 14.8 2.8 12.18 2.8c-5.26 0-9.53 4.25-9.53 9.5s4.27 9.5 9.53 9.5c5.51 0 9.15-3.87 9.15-9.32 0-.63-.07-1.1-.15-1.38z" fill="currentColor" stroke="none" /></>,
|
||||||
|
share: <><circle cx="18" cy="5" r="3" /><circle cx="6" cy="12" r="3" /><circle cx="18" cy="19" r="3" /><path d="M8.59 13.51l6.83 3.98M15.41 6.51l-6.82 3.98" /></>,
|
||||||
|
download: <><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4" /><polyline points="7 10 12 15 17 10" /><line x1="12" y1="15" x2="12" y2="3" /></>,
|
||||||
|
zoom_in: <><circle cx="11" cy="11" r="7" /><line x1="21" y1="21" x2="16.65" y2="16.65" /><line x1="11" y1="8" x2="11" y2="14" /><line x1="8" y1="11" x2="14" y2="11" /></>,
|
||||||
|
zoom_out: <><circle cx="11" cy="11" r="7" /><line x1="21" y1="21" x2="16.65" y2="16.65" /><line x1="8" y1="11" x2="14" y2="11" /></>,
|
||||||
|
maximize: <><path d="M8 3H5a2 2 0 00-2 2v3M21 8V5a2 2 0 00-2-2h-3M3 16v3a2 2 0 002 2h3M16 21h3a2 2 0 002-2v-3" /></>,
|
||||||
|
slack: <><rect x="13" y="2" width="3" height="8" rx="1.5" /><rect x="2" y="13" width="8" height="3" rx="1.5" /><rect x="14" y="14" width="8" height="3" rx="1.5" /><rect x="8" y="8" width="3" height="8" rx="1.5" /></>,
|
||||||
|
database: <><ellipse cx="12" cy="5" rx="9" ry="3" /><path d="M3 5v7c0 1.66 4.03 3 9 3s9-1.34 9-3V5M3 12v7c0 1.66 4.03 3 9 3s9-1.34 9-3v-7" /></>,
|
||||||
|
mail: <><rect x="2" y="4" width="20" height="16" rx="2" /><path d="M2 6l10 7 10-7" /></>,
|
||||||
|
filter: <path d="M3 4h18l-7 9v6l-4-2v-4L3 4z" />,
|
||||||
|
star: <polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />,
|
||||||
|
linear: <><rect x="3" y="3" width="18" height="18" rx="4" /><path d="M7 11l5 5M7 15l3 3M7 7l10 10M11 7l6 6M15 7l2 2" /></>,
|
||||||
|
clock: <><circle cx="12" cy="12" r="9" /><polyline points="12 7 12 12 16 14" /></>,
|
||||||
|
send: <><path d="M22 2L11 13M22 2l-7 20-4-9-9-4 20-7z" /></>,
|
||||||
|
terminal: <><path d="M4 17l6-6-6-6M12 19h8" /></>,
|
||||||
|
logout: <><path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4M16 17l5-5-5-5M21 12H9" /></>,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={stroke} strokeLinecap="round" strokeLinejoin="round" style={{display: 'block', flexShrink: 0}}>
|
||||||
|
{paths[name]}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Arcrun wordmark — custom "arc" glyph made of an arc stroke + ascending dot/node
|
||||||
|
const Logo = ({ size = 'md', onClick }) => {
|
||||||
|
const dims = size === 'sm' ? { w: 18, h: 18, f: 10 } : size === 'lg' ? { w: 28, h: 28, f: 14 } : { w: 22, h: 22, f: 12 };
|
||||||
|
return (
|
||||||
|
<div className="logo" onClick={onClick}>
|
||||||
|
<span className="logo-mark" style={{width: dims.w, height: dims.h}}>
|
||||||
|
<svg width={dims.w} height={dims.h} viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M5 17 Q 12 4, 19 17" stroke="white" strokeWidth="2.4" strokeLinecap="round" fill="none" opacity="0.95" />
|
||||||
|
<circle cx="19" cy="17" r="2.2" fill="white" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span>Arcrun</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// App icon with gradient background
|
||||||
|
const AppIcon = ({ tone = 'indigo', children, size = 38 }) => {
|
||||||
|
const tones = {
|
||||||
|
indigo: 'linear-gradient(135deg, #6366F1, #8B5CF6)',
|
||||||
|
orange: 'linear-gradient(135deg, #F59E0B, #EF4444)',
|
||||||
|
green: 'linear-gradient(135deg, #10B981, #22C55E)',
|
||||||
|
pink: 'linear-gradient(135deg, #EC4899, #8B5CF6)',
|
||||||
|
blue: 'linear-gradient(135deg, #3B82F6, #06B6D4)',
|
||||||
|
slate: 'linear-gradient(135deg, #475569, #334155)',
|
||||||
|
amber: 'linear-gradient(135deg, #F59E0B, #D97706)',
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div className="app-icon" style={{ background: tones[tone], width: size, height: size, color: 'white' }}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
Object.assign(window, { Icon, Logo, AppIcon });
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
# Chat
|
||||||
|
|
||||||
|
_Started 2026-04-23 04:37 UTC_
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## User
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## Assistant
|
||||||
|
|
||||||
|
I'll design Arcrun as a cohesive 5-screen system. Let me plan and build this as an interactive prototype with all screens navigable.
|
||||||
|
|
||||||
|
_[tool: update_todos]_
|
||||||
|
|
||||||
|
Let me plan the architecture. I'll build a single HTML file with React, using multiple JSX component files, with screen navigation via state.
|
||||||
|
|
||||||
|
Design system note: I'll use Inter, electric indigo #6366F1 primary, #0F0F0F bg, #1A1A1A cards, with subtle indigo→violet gradients on CTAs. Dark, minimal, developer-focused. "Arcrun" wordmark will be original — a stylized "↯" or arc mark paired with the name.
|
||||||
|
|
||||||
|
Let me start building.
|
||||||
|
|
||||||
|
_[tool: write_file]_
|
||||||
|
|
||||||
|
_[tool: write_file]_
|
||||||
|
|
||||||
|
Now the primitives and chrome components:
|
||||||
|
|
||||||
|
_[tool: write_file]_
|
||||||
|
|
||||||
|
_[tool: write_file]_
|
||||||
|
|
||||||
|
_[tool: snip]_
|
||||||
|
|
||||||
|
Now the 5 screens. Let me write them concisely.
|
||||||
|
|
||||||
|
_[tool: write_file]_
|
||||||
|
|
||||||
|
_[tool: write_file]_
|
||||||
|
|
||||||
|
_[tool: snip]_
|
||||||
|
|
||||||
|
_[tool: write_file]_
|
||||||
|
|
||||||
|
_[tool: write_file]_
|
||||||
|
|
||||||
|
_[tool: snip]_
|
||||||
|
|
||||||
|
_[tool: write_file]_
|
||||||
|
|
||||||
|
_[tool: write_file]_
|
||||||
|
|
||||||
|
_[tool: snip]_
|
||||||
|
|
||||||
|
_[tool: update_todos]_
|
||||||
|
|
||||||
|
_[tool: register_assets]_
|
||||||
|
|
||||||
|
_[tool: done]_
|
||||||
|
|
||||||
|
_[tool: snip]_
|
||||||
|
|
||||||
|
_[tool: list_files]_
|
||||||
|
|
||||||
|
The file exists. The error may have been transient. Let me verify by checking the page loads.
|
||||||
|
|
||||||
|
_[tool: show_html]_
|
||||||
|
|
||||||
|
_[tool: snip]_
|
||||||
|
|
||||||
|
_[tool: done]_
|
||||||
|
|
||||||
|
The CSS exists but isn't loading through the preview path. Let me inline it to eliminate the dependency.
|
||||||
|
|
||||||
|
_[tool: read_file]_
|
||||||
|
|
||||||
|
_[tool: run_script]_
|
||||||
|
|
||||||
|
_[tool: snip]_
|
||||||
|
|
||||||
|
_[tool: done]_
|
||||||
|
|
||||||
|
_[tool: fork_verifier_agent]_
|
||||||
|
|
||||||
|
Built **Arcrun** as a cohesive 5-screen dark-mode prototype — Landing, Auth, API Keys, Dashboard, and Workflow Viewer — all navigable via the floating screen switcher at the bottom.
|
||||||
|
|
||||||
|
**Highlights**
|
||||||
|
- **Landing** — hero with grid/glow bg, two-path split (developer terminal w/ npm·pip·bun tabs + Claude-style chat preview showing an Arcrun tool call)
|
||||||
|
- **Auth** — centered card, Sign in / Sign up tabs, GitHub + Google OAuth, email + password
|
||||||
|
- **API Keys** — first-time key callout with copy + hide-again warning, full keys table with live status toggles
|
||||||
|
- **Dashboard** — sidebar shell, 3-col apps grid (+ empty-state card), workflows table below
|
||||||
|
- **Workflow Viewer** — full-bleed dark canvas, 8 connected nodes with left-to-right bezier arrows, selectable with detail panel (I/O schema, editable triplet config for the AI node), minimap + zoom controls, prominent "Edit in Claude" CTA
|
||||||
|
|
||||||
|
Screen state persists in localStorage so reloads land you where you were.
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,128 @@
|
|||||||
|
const ApiKeys = ({ onNav }) => {
|
||||||
|
const [newKeyCopied, setNewKeyCopied] = React.useState(false);
|
||||||
|
const [keys, setKeys] = React.useState([
|
||||||
|
{ id: 'k_dev', name: 'Local Development', prefix: 'ar_dev_', created: 'Mar 12, 2026', lastUsed: '2 min ago', active: true },
|
||||||
|
{ id: 'k_prod', name: 'Production — Northwind API', prefix: 'ar_live_', created: 'Feb 3, 2026', lastUsed: '12 sec ago', active: true },
|
||||||
|
{ id: 'k_staging', name: 'Staging — Vercel', prefix: 'ar_test_', created: 'Jan 28, 2026', lastUsed: '4 hours ago', active: true },
|
||||||
|
{ id: 'k_ci', name: 'CI/CD (GitHub Actions)', prefix: 'ar_live_', created: 'Jan 10, 2026', lastUsed: 'Yesterday', active: false },
|
||||||
|
{ id: 'k_old', name: 'Legacy — Zapier import', prefix: 'ar_live_', created: 'Nov 4, 2025', lastUsed: '3 weeks ago', active: false, revoked: true },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const newKey = 'ar_live_sk_7x9Qf2vLm8nR4TpW6ZjKc3bEhN1aSyU5oP0dI';
|
||||||
|
|
||||||
|
const copyKey = () => {
|
||||||
|
setNewKeyCopied(true);
|
||||||
|
setTimeout(() => setNewKeyCopied(false), 1800);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleKey = (id) => {
|
||||||
|
setKeys(keys.map(k => k.id === id ? { ...k, active: !k.active } : k));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="shell">
|
||||||
|
<Sidebar current="keys" onNav={onNav} />
|
||||||
|
<div className="main">
|
||||||
|
<div className="main-head">
|
||||||
|
<div>
|
||||||
|
<div className="crumb">
|
||||||
|
<span>Workspace</span>
|
||||||
|
<span className="sep"><Icon name="chevron_right" size={11} /></span>
|
||||||
|
<span>Settings</span>
|
||||||
|
</div>
|
||||||
|
<h1>API Keys</h1>
|
||||||
|
<div className="sub">Scoped credentials for calling the Arcrun API from your code and CI.</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-8">
|
||||||
|
<button className="btn btn-secondary"><Icon name="book" size={14} /> API docs</button>
|
||||||
|
<button className="btn btn-primary"><Icon name="plus" size={14} /> Create new key</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="main-body" style={{maxWidth: 1080}}>
|
||||||
|
<div className="new-key-box">
|
||||||
|
<div className="warn-row">
|
||||||
|
<span className="warn-icon"><Icon name="warn" size={12} /></span>
|
||||||
|
<span><strong style={{color: '#FBBF24'}}>Save this key now.</strong> For security, we won't show it again — if you lose it, you'll need to create a new one.</span>
|
||||||
|
</div>
|
||||||
|
<h3>Your new API key</h3>
|
||||||
|
<p className="desc">Key named <strong style={{color: 'var(--text)'}}>"Production — Northwind API"</strong> · created just now · all scopes</p>
|
||||||
|
<div className="key-display">
|
||||||
|
<span className="key-val">{newKey}</span>
|
||||||
|
<button className={`copy-btn ${newKeyCopied ? 'copied' : ''}`} onClick={copyKey}>
|
||||||
|
<Icon name={newKeyCopied ? 'check' : 'copy'} size={12} />
|
||||||
|
{newKeyCopied ? 'Copied' : 'Copy'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div style={{marginTop: 14, display: 'flex', gap: 16, fontSize: 12, color: 'var(--text-mute)', alignItems: 'center'}}>
|
||||||
|
<span className="flex gap-6" style={{alignItems: 'center'}}><Icon name="check" size={12} /> Full workspace access</span>
|
||||||
|
<span className="flex gap-6" style={{alignItems: 'center'}}><Icon name="clock" size={12} /> Never expires</span>
|
||||||
|
<span style={{marginLeft: 'auto'}}><span className="link">Add expiry or restrict scopes →</span></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="section-head">
|
||||||
|
<div>
|
||||||
|
<h2>All keys</h2>
|
||||||
|
<div className="subtle" style={{marginTop: 2}}>{keys.filter(k => !k.revoked).length} active · {keys.filter(k => k.revoked).length} revoked</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-8">
|
||||||
|
<button className="btn btn-secondary btn-sm"><Icon name="filter" size={12} /> Filter</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="table-wrap">
|
||||||
|
<table className="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style={{width: '32%'}}>Name</th>
|
||||||
|
<th>Key</th>
|
||||||
|
<th>Created</th>
|
||||||
|
<th>Last used</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th style={{width: 60, textAlign: 'right'}}></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{keys.map(k => (
|
||||||
|
<tr key={k.id}>
|
||||||
|
<td>
|
||||||
|
<div style={{fontWeight: 500, fontSize: 13.5}}>{k.name}</div>
|
||||||
|
</td>
|
||||||
|
<td className="mono">{k.prefix}••••{k.id.slice(-4)}</td>
|
||||||
|
<td className="dim" style={{fontSize: 12.5}}>{k.created}</td>
|
||||||
|
<td className="dim" style={{fontSize: 12.5}}>{k.lastUsed}</td>
|
||||||
|
<td>
|
||||||
|
{k.revoked ? (
|
||||||
|
<span className="pill revoked"><span className="pdot" /> Revoked</span>
|
||||||
|
) : (
|
||||||
|
<div className="flex gap-8" style={{alignItems: 'center'}}>
|
||||||
|
<span className={`toggle ${k.active ? 'on' : ''}`} onClick={() => toggleKey(k.id)} />
|
||||||
|
<span className={`pill ${k.active ? 'active' : 'idle'}`}>
|
||||||
|
<span className="pdot" /> {k.active ? 'Active' : 'Paused'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td style={{textAlign: 'right'}}>
|
||||||
|
{!k.revoked && (
|
||||||
|
<button className="btn btn-danger-ghost btn-sm"><Icon name="trash" size={12} /></button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{marginTop: 18, fontSize: 12, color: 'var(--text-mute)', display: 'flex', alignItems: 'center', gap: 8}}>
|
||||||
|
<Icon name="warn" size={12} />
|
||||||
|
<span>Revoking a key stops all in-flight requests within 60 seconds. This cannot be undone.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.ApiKeys = ApiKeys;
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
const Auth = ({ onNav }) => {
|
||||||
|
const [mode, setMode] = React.useState('signin');
|
||||||
|
const [email, setEmail] = React.useState('');
|
||||||
|
const [pw, setPw] = React.useState('');
|
||||||
|
const [remember, setRemember] = React.useState(true);
|
||||||
|
|
||||||
|
const submit = (e) => { e.preventDefault(); onNav('dashboard'); };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="auth-wrap">
|
||||||
|
<div className="hero-bg" />
|
||||||
|
<div className="hero-bg-grid" />
|
||||||
|
|
||||||
|
<div style={{position: 'absolute', top: 24, left: 24, zIndex: 2}}>
|
||||||
|
<Logo onClick={() => onNav('landing')} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="auth-card">
|
||||||
|
<h2 className="auth-h1">{mode === 'signin' ? 'Welcome back' : 'Create your account'}</h2>
|
||||||
|
<p className="auth-sub">{mode === 'signin' ? 'Sign in to your Arcrun workspace.' : 'Start building AI workflows in minutes.'}</p>
|
||||||
|
|
||||||
|
<div className="tabs">
|
||||||
|
<button className={mode === 'signin' ? 'active' : ''} onClick={() => setMode('signin')}>Sign in</button>
|
||||||
|
<button className={mode === 'signup' ? 'active' : ''} onClick={() => setMode('signup')}>Sign up</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="oauth-row">
|
||||||
|
<button className="oauth-btn github" onClick={() => onNav('dashboard')}>
|
||||||
|
<Icon name="github" size={17} stroke={0} /> Continue with GitHub
|
||||||
|
</button>
|
||||||
|
<button className="oauth-btn google" onClick={() => onNav('dashboard')}>
|
||||||
|
<Icon name="google" size={15} stroke={0} /> Continue with Google
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="divider">or continue with email</div>
|
||||||
|
|
||||||
|
<form onSubmit={submit}>
|
||||||
|
{mode === 'signup' && (
|
||||||
|
<div className="field">
|
||||||
|
<label>Full name</label>
|
||||||
|
<input className="input" type="text" placeholder="Maya Rivera" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="field">
|
||||||
|
<label>Work email</label>
|
||||||
|
<input className="input" type="email" placeholder="you@company.com" value={email} onChange={e => setEmail(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<div className="field-row">
|
||||||
|
<label>Password</label>
|
||||||
|
{mode === 'signin' && <span className="link">Forgot password?</span>}
|
||||||
|
</div>
|
||||||
|
<input className="input" type="password" placeholder="••••••••••" value={pw} onChange={e => setPw(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{mode === 'signin' && (
|
||||||
|
<div style={{display: 'flex', alignItems: 'center', gap: 8, fontSize: 12.5, color: 'var(--text-dim)', marginBottom: 14}}>
|
||||||
|
<div onClick={() => setRemember(!remember)}
|
||||||
|
style={{width: 15, height: 15, borderRadius: 4, border: '1px solid var(--line-2)',
|
||||||
|
background: remember ? 'var(--primary)' : 'transparent',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer'}}>
|
||||||
|
{remember && <Icon name="check" size={11} />}
|
||||||
|
</div>
|
||||||
|
<span onClick={() => setRemember(!remember)} style={{cursor: 'pointer'}}>Keep me signed in for 30 days</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button className="btn btn-primary auth-submit btn-lg" type="submit">
|
||||||
|
{mode === 'signin' ? 'Sign in' : 'Create account'} <Icon name="arrow_right" size={14} />
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{mode === 'signup' && (
|
||||||
|
<p style={{fontSize: 11.5, color: 'var(--text-mute)', textAlign: 'center', marginTop: 14, lineHeight: 1.5}}>
|
||||||
|
By signing up, you agree to our <span className="link" style={{fontSize: 11.5}}>Terms</span> and <span className="link" style={{fontSize: 11.5}}>Privacy Policy</span>.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="auth-foot">
|
||||||
|
{mode === 'signin'
|
||||||
|
? <>New to Arcrun? <span className="link" onClick={() => setMode('signup')}>Create an account</span></>
|
||||||
|
: <>Already have an account? <span className="link" onClick={() => setMode('signin')}>Sign in</span></>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.Auth = Auth;
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
const Dashboard = ({ onNav }) => {
|
||||||
|
const apps = [
|
||||||
|
{ id: 'digest', name: 'Weekly Digest', desc: 'Summarize customer activity into a Monday email for the revenue team.', icon: 'mail', tone: 'indigo' },
|
||||||
|
{ id: 'triage', name: 'Support Triage', desc: 'Classify inbound tickets, attach context from the CRM, and route.', icon: 'filter', tone: 'orange' },
|
||||||
|
{ id: 'seo', name: 'SEO Brief Generator', desc: 'Turn a keyword into a draft brief with outline, FAQs, and SERP notes.', icon: 'search', tone: 'green' },
|
||||||
|
{ id: 'slack', name: 'Standup Bot', desc: 'Collect Linear updates and post a tidy engineering standup to Slack.', icon: 'slack', tone: 'pink' },
|
||||||
|
{ id: 'doc', name: 'Docs Sync', desc: 'Keep Notion runbooks in sync with the production API surface.', icon: 'book', tone: 'blue' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const workflows = [
|
||||||
|
{ id: 'digest_weekly', name: 'digest/weekly', nodes: 9, modified: '2 hours ago', runs: '147 runs', status: 'healthy' },
|
||||||
|
{ id: 'triage_inbound', name: 'triage/inbound-email', nodes: 14, modified: 'Yesterday', runs: '2,318 runs', status: 'healthy' },
|
||||||
|
{ id: 'seo_brief', name: 'seo/brief-from-keyword', nodes: 7, modified: '3 days ago', runs: '42 runs', status: 'healthy' },
|
||||||
|
{ id: 'standup', name: 'slack/standup-collector', nodes: 6, modified: '1 week ago', runs: '24 runs', status: 'idle' },
|
||||||
|
{ id: 'docs_sync', name: 'docs/sync-notion', nodes: 11, modified: '2 weeks ago', runs: '8 runs', status: 'failed' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="shell">
|
||||||
|
<Sidebar current="dashboard" onNav={onNav} />
|
||||||
|
<div className="main">
|
||||||
|
<div className="main-head">
|
||||||
|
<div>
|
||||||
|
<div className="crumb">
|
||||||
|
<span>Northwind</span>
|
||||||
|
<span className="sep"><Icon name="chevron_right" size={11} /></span>
|
||||||
|
<span>Dashboard</span>
|
||||||
|
</div>
|
||||||
|
<h1>Welcome back, Maya</h1>
|
||||||
|
<div className="sub">5 apps running · 12 workflows · 2,538 runs this week</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-8">
|
||||||
|
<button className="btn btn-secondary"><Icon name="book" size={14} /> Templates</button>
|
||||||
|
<button className="btn btn-primary"><Icon name="plus" size={14} /> New app</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="main-body">
|
||||||
|
{/* Apps grid */}
|
||||||
|
<div className="section-head">
|
||||||
|
<div>
|
||||||
|
<h2>My Apps</h2>
|
||||||
|
<div className="subtle" style={{marginTop: 2}}>Packaged workflows your team can run from chat or code</div>
|
||||||
|
</div>
|
||||||
|
<span className="subtle">{apps.length} apps</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="apps-grid">
|
||||||
|
{apps.map(a => (
|
||||||
|
<div key={a.id} className="app-card">
|
||||||
|
<AppIcon tone={a.tone}><Icon name={a.icon} size={17} /></AppIcon>
|
||||||
|
<h4>{a.name}</h4>
|
||||||
|
<p className="dsc">{a.desc}</p>
|
||||||
|
<div className="row">
|
||||||
|
<a className="open" onClick={() => onNav('workflow')}>Open app <Icon name="arrow_right" size={12} /></a>
|
||||||
|
<button className="chip-btn">
|
||||||
|
<Icon name="spark" size={11} /> Edit in Claude
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="app-card app-empty">
|
||||||
|
<div className="plus"><Icon name="plus" size={16} /></div>
|
||||||
|
<div style={{fontSize: 13, fontWeight: 500}}>Create new app</div>
|
||||||
|
<div style={{fontSize: 12, opacity: 0.75}}>Start from scratch or template</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Workflows */}
|
||||||
|
<div className="wf-table">
|
||||||
|
<div className="section-head">
|
||||||
|
<div>
|
||||||
|
<h2>My Workflows</h2>
|
||||||
|
<div className="subtle" style={{marginTop: 2}}>The graphs that power your apps</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-8">
|
||||||
|
<button className="btn btn-secondary btn-sm"><Icon name="filter" size={12} /> All workflows</button>
|
||||||
|
<button className="btn btn-secondary btn-sm" onClick={() => onNav('workflow')}><Icon name="plus" size={12} /> New</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="table-wrap">
|
||||||
|
<table className="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style={{width: '34%'}}>Workflow</th>
|
||||||
|
<th>Nodes</th>
|
||||||
|
<th>Last modified</th>
|
||||||
|
<th>Activity</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th style={{width: 100, textAlign: 'right'}}></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{workflows.map(w => (
|
||||||
|
<tr key={w.id}>
|
||||||
|
<td>
|
||||||
|
<div className="wf-row-name">
|
||||||
|
<span className="dot" />
|
||||||
|
<span className="mono" style={{fontSize: 13}}>{w.name}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="dim">{w.nodes}</td>
|
||||||
|
<td className="dim" style={{fontSize: 12.5}}>{w.modified}</td>
|
||||||
|
<td className="dim" style={{fontSize: 12.5}}>{w.runs}</td>
|
||||||
|
<td>
|
||||||
|
<span className={`pill ${w.status === 'healthy' ? 'active' : w.status === 'failed' ? 'revoked' : 'idle'}`}>
|
||||||
|
<span className="pdot" /> {w.status}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td style={{textAlign: 'right'}}>
|
||||||
|
<button className="btn btn-secondary btn-sm" onClick={() => onNav('workflow')}>View</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.Dashboard = Dashboard;
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
const Landing = ({ onNav }) => {
|
||||||
|
const [installer, setInstaller] = React.useState('npm');
|
||||||
|
const installCmds = {
|
||||||
|
npm: '$ npm install arcrun',
|
||||||
|
pip: '$ pip install arcrun',
|
||||||
|
bun: '$ bun add arcrun',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<TopNav onNav={onNav} current="landing" />
|
||||||
|
|
||||||
|
<div className="container">
|
||||||
|
<section className="hero">
|
||||||
|
<div className="hero-bg" />
|
||||||
|
<div className="hero-bg-grid" />
|
||||||
|
<div className="hero-eyebrow">
|
||||||
|
<span className="dot" />
|
||||||
|
<span>Now in public beta — MCP-native</span>
|
||||||
|
</div>
|
||||||
|
<h1>Build AI workflows<br/><span className="grad">without the glue code.</span></h1>
|
||||||
|
<p className="sub">Connect your tools, automate your work. Orchestrate workflows from Claude.ai, your IDE, or a few lines of code — Arcrun handles auth, retries, and state.</p>
|
||||||
|
<div className="hero-ctas">
|
||||||
|
<button className="btn btn-primary btn-lg" onClick={() => onNav('auth')}>
|
||||||
|
Start free <Icon name="arrow_right" size={15} />
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-secondary btn-lg">
|
||||||
|
<Icon name="book" size={14} /> Read the docs
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="paths">
|
||||||
|
{/* Developer path */}
|
||||||
|
<div className="path-card">
|
||||||
|
<div className="path-label">
|
||||||
|
<Icon name="terminal" size={13} /> For Developers
|
||||||
|
</div>
|
||||||
|
<h3>Three lines, any runtime.</h3>
|
||||||
|
<p className="lede">Install once, call Arcrun from Node, Python, or your edge runtime. OAuth, rate limits, and retries are handled.</p>
|
||||||
|
|
||||||
|
<div className="install-tabs">
|
||||||
|
{Object.keys(installCmds).map(k => (
|
||||||
|
<button key={k} className={installer === k ? 'active' : ''} onClick={() => setInstaller(k)}>{k}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="terminal" style={{marginBottom: 12}}>
|
||||||
|
<div className="terminal-head">
|
||||||
|
<div className="dots"><span/><span/><span/></div>
|
||||||
|
<div className="title">terminal</div>
|
||||||
|
</div>
|
||||||
|
<div className="terminal-body">
|
||||||
|
<div><span className="dim">{installCmds[installer]}</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="terminal">
|
||||||
|
<div className="terminal-head">
|
||||||
|
<div className="dots"><span/><span/><span/></div>
|
||||||
|
<div className="title">{installer === 'pip' ? 'app.py' : 'app.ts'}</div>
|
||||||
|
</div>
|
||||||
|
<div className="terminal-body">
|
||||||
|
{installer === 'pip' ? (
|
||||||
|
<>
|
||||||
|
<div><span className="c1">from</span> <span className="c2">arcrun</span> <span className="c1">import</span> <span className="c2">Arcrun</span></div>
|
||||||
|
<div className="sp-4"/>
|
||||||
|
<div><span className="c2">client</span> = <span className="c4">Arcrun</span>(<span className="c2">token</span>=<span className="c2">os</span>.<span className="c4">getenv</span>(<span className="c3">"ARCRUN_KEY"</span>))</div>
|
||||||
|
<div><span className="c2">run</span> = <span className="c2">client</span>.<span className="c4">run</span>(<span className="c3">"digest/weekly"</span>, <span className="c2">inputs</span>={'{'}<span className="c3">"user"</span>: <span className="c3">"u_219"</span>{'}'})</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div><span className="c1">import</span> {'{'} <span className="c2">Arcrun</span> {'}'} <span className="c1">from</span> <span className="c3">"arcrun"</span>;</div>
|
||||||
|
<div className="sp-4"/>
|
||||||
|
<div><span className="c1">const</span> <span className="c2">client</span> = <span className="c1">new</span> <span className="c4">Arcrun</span>({'{'} <span className="c2">token</span>: <span className="c2">process</span>.<span className="c2">env</span>.<span className="c2">ARCRUN_KEY</span> {'}'});</div>
|
||||||
|
<div><span className="c1">const</span> <span className="c2">run</span> = <span className="c1">await</span> <span className="c2">client</span>.<span className="c4">run</span>(<span className="c3">"digest/weekly"</span>, {'{'} <span className="c2">user</span>: <span className="c3">"u_219"</span> {'}'});</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sp-16" />
|
||||||
|
<div className="flex gap-12" style={{fontSize: 12.5, color: 'var(--text-mute)'}}>
|
||||||
|
<span className="flex gap-6" style={{alignItems: 'center'}}><Icon name="check" size={12} /> Typed SDKs</span>
|
||||||
|
<span className="flex gap-6" style={{alignItems: 'center'}}><Icon name="check" size={12} /> Idempotent runs</span>
|
||||||
|
<span className="flex gap-6" style={{alignItems: 'center'}}><Icon name="check" size={12} /> Self-host ready</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Everyone path */}
|
||||||
|
<div className="path-card">
|
||||||
|
<div className="path-label">
|
||||||
|
<Icon name="spark" size={13} /> For Everyone
|
||||||
|
</div>
|
||||||
|
<h3>Talk to your workflows.</h3>
|
||||||
|
<p className="lede">Install Arcrun inside your AI assistant and run your apps by asking. Trigger workflows, fetch data, or draft messages — in plain English.</p>
|
||||||
|
|
||||||
|
<div className="chat-preview">
|
||||||
|
<div className="chat-head">
|
||||||
|
<span className="brand-dot">AI</span>
|
||||||
|
<span>Your assistant — Arcrun connected</span>
|
||||||
|
<span style={{marginLeft: 'auto'}} className="pill active"><span className="pdot" />2 apps</span>
|
||||||
|
</div>
|
||||||
|
<div className="chat-body">
|
||||||
|
<div className="chat-msg user">
|
||||||
|
<div className="avatar">M</div>
|
||||||
|
<div className="bubble">Send this week's customer digest to the revenue team.</div>
|
||||||
|
</div>
|
||||||
|
<div className="chat-msg ai">
|
||||||
|
<div className="avatar">A</div>
|
||||||
|
<div className="bubble">
|
||||||
|
Running <span style={{color: 'var(--primary)', fontWeight: 500}}>digest/weekly</span> for 147 accounts, then posting to #revenue.
|
||||||
|
<div className="tool-card">
|
||||||
|
<div className="tool-icon">AR</div>
|
||||||
|
<div className="tool-meta">
|
||||||
|
<div className="tool-name">arcrun · digest/weekly</div>
|
||||||
|
<div className="tool-sub">4 of 5 steps complete · 00:12 elapsed</div>
|
||||||
|
</div>
|
||||||
|
<span className="pill active"><span className="pdot" />running</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="chat-input">
|
||||||
|
<span>Reply to your assistant…</span>
|
||||||
|
<span className="caret" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sp-16" />
|
||||||
|
<div className="flex gap-12" style={{fontSize: 12.5, color: 'var(--text-mute)'}}>
|
||||||
|
<span className="flex gap-6" style={{alignItems: 'center'}}><Icon name="check" size={12} /> One-click connect</span>
|
||||||
|
<span className="flex gap-6" style={{alignItems: 'center'}}><Icon name="check" size={12} /> Works in your IDE</span>
|
||||||
|
<span className="flex gap-6" style={{alignItems: 'center'}}><Icon name="check" size={12} /> Audit trail</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="strip">
|
||||||
|
<div className="cell">
|
||||||
|
<div className="ico"><Icon name="bolt" size={15} /></div>
|
||||||
|
<h4>Run anywhere</h4>
|
||||||
|
<p>Node, Python, Deno, Bun, Cloudflare Workers. One API, same semantics.</p>
|
||||||
|
</div>
|
||||||
|
<div className="cell">
|
||||||
|
<div className="ico"><Icon name="workflow" size={15} /></div>
|
||||||
|
<h4>Composable steps</h4>
|
||||||
|
<p>Model calls, HTTP, database, branching — wire them visually or in code.</p>
|
||||||
|
</div>
|
||||||
|
<div className="cell">
|
||||||
|
<div className="ico"><Icon name="key" size={15} /></div>
|
||||||
|
<h4>Scoped keys</h4>
|
||||||
|
<p>Per-workflow API keys with fine-grained scopes and live revocation.</p>
|
||||||
|
</div>
|
||||||
|
<div className="cell">
|
||||||
|
<div className="ico"><Icon name="eye" size={15} /></div>
|
||||||
|
<h4>Observable</h4>
|
||||||
|
<p>Every run is replayable. Inspect inputs, outputs, and token usage.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Footer onNav={onNav} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.Landing = Landing;
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
const WorkflowViewer = ({ onNav }) => {
|
||||||
|
const nodes = [
|
||||||
|
{ id: 'trigger', x: 60, y: 260, title: 'Weekly Schedule', type: 'trigger', badge: 'CRON', icon: 'clock', tone: '#22C55E',
|
||||||
|
inputs: [], outputs: [{k: 'timestamp', t: 'ISO8601'}, {k: 'runId', t: 'string'}] },
|
||||||
|
{ id: 'fetch', x: 320, y: 140, title: 'Fetch Accounts', type: 'database.query', badge: 'DB', icon: 'database', tone: '#3B82F6',
|
||||||
|
inputs: [{k: 'segment', t: 'string'}], outputs: [{k: 'accounts', t: 'Account[]'}, {k: 'count', t: 'number'}] },
|
||||||
|
{ id: 'events', x: 320, y: 380, title: 'Pull Events', type: 'segment.events', badge: 'API', icon: 'bolt', tone: '#F59E0B',
|
||||||
|
inputs: [{k: 'since', t: 'ISO8601'}], outputs: [{k: 'events', t: 'Event[]'}] },
|
||||||
|
{ id: 'summarize', x: 600, y: 260, title: 'Summarize with Claude', type: 'ai.completion', badge: 'AI', icon: 'spark', tone: '#8B5CF6',
|
||||||
|
inputs: [{k: 'accounts', t: 'Account[]'}, {k: 'events', t: 'Event[]'}, {k: 'prompt', t: 'string'}],
|
||||||
|
outputs: [{k: 'digest', t: 'Digest'}, {k: 'tokens', t: 'number'}] },
|
||||||
|
{ id: 'filter', x: 880, y: 160, title: 'Filter — priority ≥ 2', type: 'logic.filter', badge: 'IF', icon: 'filter', tone: '#64748B',
|
||||||
|
inputs: [{k: 'digest', t: 'Digest'}], outputs: [{k: 'items', t: 'Item[]'}] },
|
||||||
|
{ id: 'slack', x: 1140, y: 100, title: 'Post to #revenue', type: 'slack.message', badge: 'OUT', icon: 'slack', tone: '#EC4899',
|
||||||
|
inputs: [{k: 'channel', t: 'string'}, {k: 'blocks', t: 'Block[]'}], outputs: [{k: 'ts', t: 'string'}] },
|
||||||
|
{ id: 'mail', x: 1140, y: 260, title: 'Email Digest', type: 'mail.send', badge: 'OUT', icon: 'mail', tone: '#6366F1',
|
||||||
|
inputs: [{k: 'to', t: 'string[]'}, {k: 'subject', t: 'string'}, {k: 'html', t: 'string'}], outputs: [{k: 'messageId', t: 'string'}] },
|
||||||
|
{ id: 'log', x: 880, y: 400, title: 'Log run metadata', type: 'arcrun.log', badge: 'LOG', icon: 'terminal', tone: '#475569',
|
||||||
|
inputs: [{k: 'runId', t: 'string'}, {k: 'stats', t: 'Stats'}], outputs: [] },
|
||||||
|
];
|
||||||
|
|
||||||
|
const edges = [
|
||||||
|
['trigger', 'fetch'],
|
||||||
|
['trigger', 'events'],
|
||||||
|
['fetch', 'summarize'],
|
||||||
|
['events', 'summarize'],
|
||||||
|
['summarize', 'filter'],
|
||||||
|
['summarize', 'log'],
|
||||||
|
['filter', 'slack'],
|
||||||
|
['filter', 'mail'],
|
||||||
|
];
|
||||||
|
|
||||||
|
const [selectedId, setSelectedId] = React.useState('summarize');
|
||||||
|
const [title, setTitle] = React.useState('digest/weekly');
|
||||||
|
const [zoom, setZoom] = React.useState(100);
|
||||||
|
|
||||||
|
const selected = nodes.find(n => n.id === selectedId);
|
||||||
|
|
||||||
|
// Edit triplet inline (for the summarize node's prompt config)
|
||||||
|
const [triplet, setTriplet] = React.useState({
|
||||||
|
model: 'claude-haiku-4-5',
|
||||||
|
temperature: '0.3',
|
||||||
|
prompt: 'Summarize this week\'s account activity for the revenue team.',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Measure node widths for edge endpoint accuracy
|
||||||
|
const nodeRefs = React.useRef({});
|
||||||
|
const [sizes, setSizes] = React.useState({});
|
||||||
|
React.useEffect(() => {
|
||||||
|
const ns = {};
|
||||||
|
for (const n of nodes) {
|
||||||
|
const el = nodeRefs.current[n.id];
|
||||||
|
if (el) ns[n.id] = { w: el.offsetWidth, h: el.offsetHeight };
|
||||||
|
}
|
||||||
|
setSizes(ns);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const getPort = (id, side) => {
|
||||||
|
const n = nodes.find(x => x.id === id);
|
||||||
|
const sz = sizes[id] || { w: 200, h: 60 };
|
||||||
|
return {
|
||||||
|
x: side === 'out' ? n.x + sz.w : n.x,
|
||||||
|
y: n.y + sz.h / 2,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="wf-viewer">
|
||||||
|
<div className="wf-topbar">
|
||||||
|
<div className="back" onClick={() => onNav('dashboard')} title="Back to dashboard">
|
||||||
|
<Icon name="arrow_left" size={16} />
|
||||||
|
</div>
|
||||||
|
<Logo size="sm" onClick={() => onNav('landing')} />
|
||||||
|
<div className="sep" />
|
||||||
|
<div className="wf-breadcrumb">
|
||||||
|
<span className="cr" onClick={() => onNav('dashboard')}>Workflows</span>
|
||||||
|
<Icon name="chevron_right" size={11} />
|
||||||
|
<input
|
||||||
|
className="wf-title mono"
|
||||||
|
value={title}
|
||||||
|
onChange={e => setTitle(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="wf-saved">
|
||||||
|
<span style={{width: 6, height: 6, borderRadius: '50%', background: '#22C55E', boxShadow: '0 0 0 3px rgba(34,197,94,0.18)'}} />
|
||||||
|
Saved · 2m ago
|
||||||
|
</span>
|
||||||
|
<div className="spacer" />
|
||||||
|
<button className="btn btn-ghost btn-sm"><Icon name="share" size={13} /> Share</button>
|
||||||
|
<button className="btn btn-secondary btn-sm"><Icon name="download" size={13} /> Export YAML</button>
|
||||||
|
<button className="wf-edit-in-claude">
|
||||||
|
<Icon name="spark" size={13} /> Edit in Claude <Icon name="external" size={12} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="wf-canvas">
|
||||||
|
<svg className="wf-edges" width="100%" height="100%">
|
||||||
|
<defs>
|
||||||
|
<marker id="arrow" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
|
||||||
|
<path d="M0,0 L10,5 L0,10 z" fill="#6366F1" />
|
||||||
|
</marker>
|
||||||
|
<marker id="arrow-dim" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
|
||||||
|
<path d="M0,0 L10,5 L0,10 z" fill="#3a3a3a" />
|
||||||
|
</marker>
|
||||||
|
</defs>
|
||||||
|
{edges.map(([a, b], i) => {
|
||||||
|
const p1 = getPort(a, 'out');
|
||||||
|
const p2 = getPort(b, 'in');
|
||||||
|
const dx = Math.max(40, (p2.x - p1.x) * 0.5);
|
||||||
|
const d = `M ${p1.x} ${p1.y} C ${p1.x + dx} ${p1.y}, ${p2.x - dx} ${p2.y}, ${p2.x - 2} ${p2.y}`;
|
||||||
|
const highlight = a === selectedId || b === selectedId;
|
||||||
|
return (
|
||||||
|
<path key={i} d={d}
|
||||||
|
stroke={highlight ? '#6366F1' : '#3a3a3a'}
|
||||||
|
strokeWidth={highlight ? 2 : 1.5}
|
||||||
|
fill="none"
|
||||||
|
markerEnd={`url(#${highlight ? 'arrow' : 'arrow-dim'})`}
|
||||||
|
opacity={highlight ? 0.95 : 0.6} />
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<div className="wf-nodes">
|
||||||
|
{nodes.map(n => (
|
||||||
|
<div key={n.id}
|
||||||
|
ref={el => (nodeRefs.current[n.id] = el)}
|
||||||
|
className={`wf-node ${selectedId === n.id ? 'selected' : ''}`}
|
||||||
|
style={{left: n.x, top: n.y}}
|
||||||
|
onClick={() => setSelectedId(n.id)}>
|
||||||
|
{n.inputs.length > 0 && <span className="port in" />}
|
||||||
|
{n.outputs.length > 0 && <span className="port out" />}
|
||||||
|
<div className="node-row-top">
|
||||||
|
<span className="node-icon" style={{background: n.tone}}>
|
||||||
|
<Icon name={n.icon} size={12} />
|
||||||
|
</span>
|
||||||
|
<span className="node-title">{n.title}</span>
|
||||||
|
<span className="node-badge">{n.badge}</span>
|
||||||
|
</div>
|
||||||
|
<div className="node-sub">{n.type}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Detail panel */}
|
||||||
|
{selected && (
|
||||||
|
<div className="wf-detail">
|
||||||
|
<div className="dt-head">
|
||||||
|
<span className="dt-icon" style={{background: selected.tone}}>
|
||||||
|
<Icon name={selected.icon} size={15} />
|
||||||
|
</span>
|
||||||
|
<div className="dt-meta">
|
||||||
|
<h3>{selected.title}</h3>
|
||||||
|
<div className="dt-type">{selected.type}</div>
|
||||||
|
</div>
|
||||||
|
<button className="close-btn" onClick={() => setSelectedId(null)}>
|
||||||
|
<Icon name="close" size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="dt-body">
|
||||||
|
<div className="dt-section">
|
||||||
|
<h4>Input schema</h4>
|
||||||
|
{selected.inputs.length === 0 ? (
|
||||||
|
<div style={{fontSize: 12, color: 'var(--text-mute)', fontStyle: 'italic'}}>No inputs — this is a trigger.</div>
|
||||||
|
) : selected.inputs.map(f => (
|
||||||
|
<div key={f.k} className="schema-field">
|
||||||
|
<span className="k">{f.k}</span>
|
||||||
|
<span className="t">{f.t}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="dt-section">
|
||||||
|
<h4>Output schema</h4>
|
||||||
|
{selected.outputs.length === 0 ? (
|
||||||
|
<div style={{fontSize: 12, color: 'var(--text-mute)', fontStyle: 'italic'}}>No outputs — terminal node.</div>
|
||||||
|
) : selected.outputs.map(f => (
|
||||||
|
<div key={f.k} className="schema-field">
|
||||||
|
<span className="k">{f.k}</span>
|
||||||
|
<span className="t">{f.t}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selected.id === 'summarize' && (
|
||||||
|
<div className="dt-section">
|
||||||
|
<h4>Configuration</h4>
|
||||||
|
<div className="triplet">
|
||||||
|
<div className="trow">
|
||||||
|
<div className="tkey">model</div>
|
||||||
|
<input className="tval" value={triplet.model} onChange={e => setTriplet({...triplet, model: e.target.value})} />
|
||||||
|
</div>
|
||||||
|
<div className="trow">
|
||||||
|
<div className="tkey">temp</div>
|
||||||
|
<input className="tval" value={triplet.temperature} onChange={e => setTriplet({...triplet, temperature: e.target.value})} />
|
||||||
|
</div>
|
||||||
|
<div className="trow">
|
||||||
|
<div className="tkey">prompt</div>
|
||||||
|
<input className="tval" value={triplet.prompt} onChange={e => setTriplet({...triplet, prompt: e.target.value})} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="dt-section">
|
||||||
|
<h4>Last run</h4>
|
||||||
|
<div style={{display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8, fontSize: 12}}>
|
||||||
|
<div style={{background: 'rgba(255,255,255,0.02)', border: '1px solid var(--line)', borderRadius: 7, padding: '8px 10px'}}>
|
||||||
|
<div style={{color: 'var(--text-mute)', fontSize: 10.5, textTransform: 'uppercase', letterSpacing: '0.06em'}}>Duration</div>
|
||||||
|
<div style={{fontFamily: 'JetBrains Mono, monospace', marginTop: 3}}>2.4s</div>
|
||||||
|
</div>
|
||||||
|
<div style={{background: 'rgba(255,255,255,0.02)', border: '1px solid var(--line)', borderRadius: 7, padding: '8px 10px'}}>
|
||||||
|
<div style={{color: 'var(--text-mute)', fontSize: 10.5, textTransform: 'uppercase', letterSpacing: '0.06em'}}>Status</div>
|
||||||
|
<div style={{marginTop: 2}}><span className="pill active"><span className="pdot" />success</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button className="btn btn-primary" style={{width: '100%', marginTop: 4}}>
|
||||||
|
<Icon name="spark" size={13} /> Edit this node in Claude
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Minimap */}
|
||||||
|
<div className="wf-minimap">
|
||||||
|
<div className="mini-label">Overview</div>
|
||||||
|
{nodes.map(n => {
|
||||||
|
const sz = sizes[n.id] || {w: 180, h: 60};
|
||||||
|
return (
|
||||||
|
<div key={n.id} className="mini-box" style={{
|
||||||
|
left: 8 + (n.x / 1400) * 164,
|
||||||
|
top: 18 + (n.y / 500) * 80,
|
||||||
|
width: Math.max(6, (sz.w / 1400) * 164),
|
||||||
|
height: Math.max(4, (sz.h / 500) * 80),
|
||||||
|
opacity: selectedId === n.id ? 1 : 0.5,
|
||||||
|
background: selectedId === n.id ? 'var(--primary)' : 'var(--primary-soft)',
|
||||||
|
}} />
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Zoom controls */}
|
||||||
|
<div className="wf-controls">
|
||||||
|
<button onClick={() => setZoom(Math.max(40, zoom - 10))}><Icon name="zoom_out" size={13} /></button>
|
||||||
|
<div className="zoom-val">{zoom}%</div>
|
||||||
|
<button onClick={() => setZoom(Math.min(200, zoom + 10))}><Icon name="zoom_in" size={13} /></button>
|
||||||
|
<button><Icon name="maximize" size={13} /></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.WorkflowViewer = WorkflowViewer;
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
# Frontend Redesign — Design
|
||||||
|
|
||||||
|
> 讀此檔前請先讀 `requirements.md` 和 `design-source/index.html`。
|
||||||
|
> 視覺 spec 的 single source of truth 是 `design-source/`(Claude Design 匯出的 HTML/JSX prototype)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 架構總覽
|
||||||
|
|
||||||
|
```
|
||||||
|
landing/ (Next.js 15 App Router)
|
||||||
|
├── app/
|
||||||
|
│ ├── layout.tsx ← 全站 layout:next/font + design tokens + 全域 CSS 匯入
|
||||||
|
│ ├── globals.css ← 匯入 design-tokens.css;Tailwind @import
|
||||||
|
│ ├── design-tokens.css ← 新增:從 design-source 抽出的 CSS variables(:root {...})
|
||||||
|
│ ├── page.tsx ← Landing(RSC)
|
||||||
|
│ ├── auth/
|
||||||
|
│ │ └── page.tsx ← Auth("use client")
|
||||||
|
│ ├── dashboard/
|
||||||
|
│ │ └── page.tsx ← Dashboard("use client",仍靠 middleware 保護)
|
||||||
|
│ ├── keys/
|
||||||
|
│ │ └── page.tsx ← API Keys("use client")
|
||||||
|
│ ├── workflows/
|
||||||
|
│ │ ├── page.tsx ← Workflows 清單(redirect 到 dashboard 的 table,本身極簡)
|
||||||
|
│ │ └── [name]/page.tsx ← Workflow Viewer("use client")
|
||||||
|
│ ├── integrations/page.tsx ← 保留現有
|
||||||
|
│ ├── api-docs/page.tsx ← 保留現有
|
||||||
|
│ └── login/page.tsx ← 保留現有(redirect /auth 同義;見 §9 遷移策略)
|
||||||
|
├── components/
|
||||||
|
│ ├── shell/
|
||||||
|
│ │ ├── Logo.tsx
|
||||||
|
│ │ ├── Icon.tsx
|
||||||
|
│ │ ├── TopNav.tsx
|
||||||
|
│ │ ├── Footer.tsx
|
||||||
|
│ │ └── Sidebar.tsx
|
||||||
|
│ ├── primitives/
|
||||||
|
│ │ ├── Button.tsx ← btn / btn-primary / btn-secondary / btn-ghost 對應 class
|
||||||
|
│ │ ├── Pill.tsx
|
||||||
|
│ │ ├── Toggle.tsx
|
||||||
|
│ │ ├── Terminal.tsx ← landing hero 右卡用
|
||||||
|
│ │ └── ChatPreview.tsx ← landing hero 右卡用
|
||||||
|
│ └── workflow/
|
||||||
|
│ ├── Canvas.tsx ← wf-viewer 本體(節點 + SVG edges)
|
||||||
|
│ ├── NodeCard.tsx
|
||||||
|
│ ├── DetailPanel.tsx
|
||||||
|
│ ├── Minimap.tsx
|
||||||
|
│ └── ZoomControls.tsx
|
||||||
|
├── lib/
|
||||||
|
│ ├── api.ts ← typed fetch wrapper(fetch ${API_BASE}${path}, credentials: 'include')
|
||||||
|
│ ├── workflows.ts ← listWorkflows / getWorkflow / getWorkflowYaml
|
||||||
|
│ ├── apiKeys.ts ← listKeys / createKey / patchKey / deleteKey
|
||||||
|
│ └── me.ts ← 已存在邏輯,集中到此
|
||||||
|
├── middleware.ts ← 擴展 matcher(加 /keys, /workflows/*)
|
||||||
|
└── ...(既有 package.json / wrangler.toml 不變)
|
||||||
|
```
|
||||||
|
|
||||||
|
**路由對照設計稿的 5 screen**:
|
||||||
|
|
||||||
|
| Screen | Route |
|
||||||
|
|---|---|
|
||||||
|
| Landing | `/` |
|
||||||
|
| Auth | `/auth`(新增;`/login` 保留並內部 `redirect('/auth')`) |
|
||||||
|
| Dashboard | `/dashboard` |
|
||||||
|
| API Keys | `/keys` |
|
||||||
|
| Workflow Viewer | `/workflows/[name]` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Design tokens 對應
|
||||||
|
|
||||||
|
設計稿所有 CSS 變數抄進 `app/design-tokens.css`,**不解析、不改名**:
|
||||||
|
|
||||||
|
```css
|
||||||
|
:root {
|
||||||
|
--bg: #0F0F0F;
|
||||||
|
--bg-1: #141414;
|
||||||
|
--card: #1A1A1A;
|
||||||
|
--card-2: #222222;
|
||||||
|
--line: #262626;
|
||||||
|
--line-2: #303030;
|
||||||
|
--text: #EDEDED;
|
||||||
|
--text-dim: #A0A0A0;
|
||||||
|
--text-mute: #6B6B6B;
|
||||||
|
--primary: #6366F1;
|
||||||
|
--primary-2: #8B5CF6;
|
||||||
|
--primary-soft: rgba(99, 102, 241, 0.12);
|
||||||
|
--primary-ring: rgba(99, 102, 241, 0.32);
|
||||||
|
--success: #22C55E;
|
||||||
|
--warn: #F59E0B;
|
||||||
|
--danger: #EF4444;
|
||||||
|
--gradient: linear-gradient(135deg, #6366F1 0%, #8B5CF6 100%);
|
||||||
|
--gradient-soft: linear-gradient(135deg, rgba(99,102,241,0.16) 0%, rgba(139,92,246,0.16) 100%);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
並在 Tailwind v4 的 `@theme inline` block 內對應出:
|
||||||
|
|
||||||
|
```css
|
||||||
|
@theme inline {
|
||||||
|
--color-bg: var(--bg);
|
||||||
|
--color-card: var(--card);
|
||||||
|
--color-card-2: var(--card-2);
|
||||||
|
--color-line: var(--line);
|
||||||
|
--color-line-2: var(--line-2);
|
||||||
|
--color-text: var(--text);
|
||||||
|
--color-text-dim: var(--text-dim);
|
||||||
|
--color-text-mute: var(--text-mute);
|
||||||
|
--color-primary: var(--primary);
|
||||||
|
--color-primary-2: var(--primary-2);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
這樣 JSX 裡可用 `bg-bg / text-text-dim / border-line`,又保留 CSS 變數語義。
|
||||||
|
|
||||||
|
**現有的 `--background: #0a0a0a` 要換成 `#0F0F0F`**(視覺 breaking change;受影響:所有沿用 `bg-[#0a0a0a]` 的 inline 值)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 字型
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// app/layout.tsx
|
||||||
|
import { Inter, JetBrains_Mono } from 'next/font/google';
|
||||||
|
|
||||||
|
const inter = Inter({
|
||||||
|
subsets: ['latin'],
|
||||||
|
variable: '--font-inter',
|
||||||
|
weight: ['300', '400', '500', '600', '700', '800'],
|
||||||
|
});
|
||||||
|
const mono = JetBrains_Mono({
|
||||||
|
subsets: ['latin'],
|
||||||
|
variable: '--font-mono',
|
||||||
|
weight: ['400', '500', '600'],
|
||||||
|
});
|
||||||
|
|
||||||
|
// body class = `${inter.variable} ${mono.variable}`
|
||||||
|
```
|
||||||
|
|
||||||
|
`globals.css` 中的 `body { font-family: var(--font-inter), -apple-system, sans-serif; }`,`.mono` class 用 `font-family: var(--font-mono)`。
|
||||||
|
|
||||||
|
**移除**:
|
||||||
|
- `design-source/index.html` 第 7-9 行的 `<link rel="preconnect"> / <link href="fonts.googleapis.com">`(不寫入 production)。
|
||||||
|
- React / Babel standalone script 標籤(prototype 專用,不進 production)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 元件 porting 規則
|
||||||
|
|
||||||
|
Claude Design 用了 `window.Icon / window.Logo / window.AppIcon / window.TopNav ...` 的 globals 風格 — 那是 prototype 專用。Port 到 Next.js 時:
|
||||||
|
|
||||||
|
1. 每個元件拆單檔、具名 export。
|
||||||
|
2. 用 Tailwind + `className` 模板字串;共用 variant(如 btn)用 `cva`-style helper 即可(自己寫 5 行的 `clsx`-alike 函式),**不引入 class-variance-authority / clsx 套件**(避免新依賴)。
|
||||||
|
3. Icon 的 `paths` 直接搬,但每個 icon 拆成自己的 functional component 或集中在一個 `<Icon name="..." />`(沿用 design source 的 pattern)。
|
||||||
|
4. SVG arc wordmark 的 logo 直接 port。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 各 screen 實作細節
|
||||||
|
|
||||||
|
### 5.1 Landing — `app/page.tsx`
|
||||||
|
|
||||||
|
- 結構:`<TopNav />` + `<Hero />` + `<Paths />` + `<Strip />` + `<Footer />`。
|
||||||
|
- Hero:heading、eyebrow、CTA、radial grid bg(純 CSS)。
|
||||||
|
- Paths 左卡(Developer):install tabs (`npm` / `pip` / `bun`) + 兩個 terminal block;**code 範例用 dogfooding 範例**(`acr` CLI),不留 `Arcrun` SDK 假 API。
|
||||||
|
- Paths 右卡(Everyone):chat preview 結構保留;assistant 對話中的 tool call 用「arcrun · digest/weekly」不動。
|
||||||
|
- Strip:4 cell。
|
||||||
|
- `LandingClientTabs` 因為有 tabs state,需標 `"use client"`;外層保持 RSC。
|
||||||
|
|
||||||
|
### 5.2 Auth — `app/auth/page.tsx`
|
||||||
|
|
||||||
|
- `"use client"`。state:`mode: 'signin' | 'signup'`, `email`, `pw`, `remember`。
|
||||||
|
- Submit:`fetch(${API_BASE}/auth/password-login, { method: 'POST', credentials: 'include' })`(**若 cypher-executor 尚未支援 password auth,先顯示「Password 登入尚未開放,請用 OAuth」警示,不偽造成功流程**)。
|
||||||
|
- OAuth 按鈕:直接 `<a href={API_BASE}/auth/google/start?redirect=/dashboard>`,和現行 `/login` 同樣機制。
|
||||||
|
- 下標提示「By signing up, you agree to our Terms ...」保留 static 字串。
|
||||||
|
- 保留 `/login` 路由向後相容(RSC 裡 `redirect('/auth')`)。
|
||||||
|
|
||||||
|
### 5.3 Dashboard — `app/dashboard/page.tsx`
|
||||||
|
|
||||||
|
- `"use client"` 或 split(外層 RSC 抓 /me,內層 Client)。
|
||||||
|
- 由 `<Sidebar current="dashboard" />` + main。
|
||||||
|
- 主要區塊:
|
||||||
|
- Main head:breadcrumb「{email 的 domain} › Dashboard」、heading「Welcome back, {display_name}」、subtitle 顯示 app/workflow 總數(從 `/apps` + `/workflows` 計算;若 endpoint 404 → 顯示 `—`)。
|
||||||
|
- Apps Grid:`/apps` 的結果渲染;每列永遠有一個 `app-empty` 卡(新建 CTA)。
|
||||||
|
- Workflows Table:`/workflows` 的結果渲染;空時改為全寬「No workflows yet. Run `acr push` to add one.」內嵌指令框。
|
||||||
|
- 「Open app」「View」按鈕導向 `/workflows/[name]`。
|
||||||
|
- 「Edit in Claude」按鈕本次不做動作,僅保 UI(disabled + tooltip「Coming soon」)。
|
||||||
|
|
||||||
|
### 5.4 API Keys — `app/keys/page.tsx`
|
||||||
|
|
||||||
|
- `"use client"`。
|
||||||
|
- Fetch `/api-keys`:若回傳為空陣列但 `/me` 有 api_key,fallback 顯示 `/me.api_key` 為唯一一列(單 key 相容模式)。
|
||||||
|
- 頂部 new-key-box:只在「剛剛建立新 key」的一次性狀態顯示(`useState` + `sessionStorage` flag,reload 後消失)。
|
||||||
|
- 表格、toggle、trash:對應 PATCH / DELETE。
|
||||||
|
- 「Create new key」按鈕:呼叫 `POST /api-keys`,拿到後打 highlight box。
|
||||||
|
- Revoke 警告文字維持設計稿「within 60 seconds」。
|
||||||
|
|
||||||
|
### 5.5 Workflow Viewer — `app/workflows/[name]/page.tsx`
|
||||||
|
|
||||||
|
- `"use client"`,param `name` 來自動態路由。
|
||||||
|
- Mount 後呼叫 `GET /workflows/:name`:後端回傳 `{ name, nodes: Node[], edges: Edge[], yaml, last_run: {...} }`(若 endpoint 未實作 → 顯示「Workflow viewer 尚未啟用」empty state,不用假資料)。
|
||||||
|
- `<Canvas>` 內:
|
||||||
|
- SVG 的 `<marker>`, `<path>` 定義抄設計稿。
|
||||||
|
- Node 用絕對定位(x/y 直接用 API 資料;資料沒有 coord 時做自動 layout — 階段性做簡單 dagre-free 的「column by depth」排版,避免新依賴)。
|
||||||
|
- 點選節點 → 右側 detail panel 顯示 input/output schema;若 type 含 `ai.*`,顯示 triplet 編輯器(model / temp / prompt)— 編輯本次 **read-only**(disabled input + 「Edit via acr CLI」提示)。
|
||||||
|
- 「Export YAML」按 `GET /workflows/:name/yaml` → `download` blob。
|
||||||
|
- 「Edit in Claude」:本次只開新 tab 到 `https://claude.ai/new?q=...`(文案「coming soon」按鈕),避免偽裝已整合。
|
||||||
|
- Zoom controls、minimap:純 UI,`zoom` state 實際不套 transform(或簡單 `style={{ transform: scale(zoom/100) }}` 套在 `.wf-nodes` + svg)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. API wrapper(`lib/api.ts`)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export const API_BASE = process.env.NEXT_PUBLIC_API_BASE ?? 'https://cypher.arcrun.dev';
|
||||||
|
|
||||||
|
export async function arcrunFetch<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||||
|
const res = await fetch(`${API_BASE}${path}`, {
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { 'Accept': 'application/json', ...(init.headers ?? {}) },
|
||||||
|
...init,
|
||||||
|
});
|
||||||
|
if (res.status === 401 && typeof window !== 'undefined') {
|
||||||
|
window.location.href = `/auth?redirect=${encodeURIComponent(location.pathname)}`;
|
||||||
|
throw new Error('unauthenticated');
|
||||||
|
}
|
||||||
|
if (!res.ok) throw new Error(`arcrun ${path}: ${res.status}`);
|
||||||
|
return res.json() as Promise<T>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
所有頁面透過這個 wrapper。**禁止在 page.tsx 裡 hard-code `fetch('https://...')`**(測試可以 grep)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Middleware
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export const config = {
|
||||||
|
matcher: ['/dashboard/:path*', '/keys/:path*', '/workflows/:path*'],
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
現有邏輯(讀 `arcrun_session` cookie,沒有就 redirect `/login?redirect=...`)保留,`/login` 改為內部 redirect `/auth`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 不做的設計稿功能
|
||||||
|
|
||||||
|
| 設計元素 | 取捨 |
|
||||||
|
|---|---|
|
||||||
|
| 底部的 `proto-switch`(5 個 screen 切換 pill) | **刪**。那是 prototype 用的 demo 切換器,不進 production。 |
|
||||||
|
| Sidebar 的 `count` badge | 先保留;數字從 `/workflows` / `/apps` 的長度派生;無資料時藏起來。 |
|
||||||
|
| Sidebar bottom 的 avatar + "Maya Rivera / maya@northwind.co" | 換成 `{display_name} / {email}`(真資料)。 |
|
||||||
|
| Workflow Viewer 的 triplet 可編輯 | 本次 disabled,僅顯示。 |
|
||||||
|
| 「Edit in Claude」整合 | 按鈕保留,點擊開新 tab 到 claude.ai,不串 MCP/API。 |
|
||||||
|
| 多 workspace breadcrumb | 固定顯示用戶 email domain 或「Personal」。 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 既有頁面遷移
|
||||||
|
|
||||||
|
| 既有 | 處理 |
|
||||||
|
|---|---|
|
||||||
|
| `/page.tsx` | **rewrite**:沿用設計稿結構,code demo 字串改為 `acr` 實際指令(現有的 `auth.bind(...)` 寫法可保留在 Python tab) |
|
||||||
|
| `/login` | 改為 `redirect('/auth')`(Next.js RSC redirect),保留舊連結相容 |
|
||||||
|
| `/dashboard` | **rewrite**:舊 dashboard 變成 API Keys 獨立頁 + 新 Dashboard 總覽。原本 dashboard 裡的 Key 卡片搬到 `/keys`。 |
|
||||||
|
| `/api-docs` | 不動 |
|
||||||
|
| `/integrations` | 不動;在 Dashboard Apps Grid 旁提供 link |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 開發順序(高度相依)
|
||||||
|
|
||||||
|
見 `tasks.md`。總則:
|
||||||
|
|
||||||
|
1. 先做 design tokens + shell(Logo / Icon / Button / Sidebar / TopNav / Footer) — 其他頁面都吃這些。
|
||||||
|
2. 然後 Landing(可直接驗證視覺基準)。
|
||||||
|
3. 然後 Auth(獨立)。
|
||||||
|
4. 然後 API Keys(後端依賴少)。
|
||||||
|
5. 然後 Dashboard(依賴 `/workflows` + `/apps`,若未實作先 empty state)。
|
||||||
|
6. 最後 Workflow Viewer(依賴最重,多 endpoint)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 風險與未解
|
||||||
|
|
||||||
|
| 風險 | 緩解 |
|
||||||
|
|---|---|
|
||||||
|
| cypher-executor 尚未有 `/workflows`, `/apps`, `/api-keys` CRUD | 前端先做,統一走 404 → empty state;另開 task 去 cypher-executor SDD 增補。本次 SDD 不負責後端實作。 |
|
||||||
|
| Password auth 沒實作 | Auth 頁 email/password form 在 submit 時顯示「OAuth only」提示 |
|
||||||
|
| `acr push` 未記錄 node 座標 | Canvas 自動排版(by topological depth),不強制 YAML 載入 layout |
|
||||||
|
| `next-on-pages` 對 `"use client"` 大量頁面的 edge runtime 支援 | 本來就用 `next-on-pages`,問題不大;必要時 per-page `export const runtime = 'edge'` |
|
||||||
|
| 舊 `/dashboard` 的 bookmark 使用者 | 現行 `/dashboard` 的 Key 管理被搬走;保留 Key 區塊 + 顯示提示「New page: /keys」引導 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. 與封測的關係
|
||||||
|
|
||||||
|
此 SDD 的實作**不解除封測阻擋**(封測阻擋在 Credential Primitives WASM)。此重設計與 Phase 0.6 / 0.7 / 1-3 是並行軌道。richblack 可決定先後順序,但本 SDD 獨立可 ship。
|
||||||
|
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
# Frontend Redesign — Requirements
|
||||||
|
|
||||||
|
> 來源:Claude Design bundle `JAdpACs3cSyw_vN6Ketj1Q`(已歸檔於 `design-source/`)。
|
||||||
|
> 此 SDD 擴展 `../landing-page.md` 的範圍:landing 從「單頁 + OAuth + Dashboard」升級為「5 screen app shell」。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 背景
|
||||||
|
|
||||||
|
`arcrun/landing/` 目前只提供:
|
||||||
|
|
||||||
|
- `/` — Landing hero + code demo
|
||||||
|
- `/login` — Google / GitHub OAuth
|
||||||
|
- `/dashboard` — API Key 管理
|
||||||
|
- `/api-docs` — Swagger UI(外部)
|
||||||
|
- `/integrations` — 20 個 recipe 靜態清單
|
||||||
|
|
||||||
|
Claude Design 交付一套完整 5 screen 設計(Landing / Auth / API Keys / Dashboard / Workflow Viewer),與現有前端相比多了:
|
||||||
|
|
||||||
|
- **Dashboard 的 Apps Grid 與 Workflows Table**(現在沒有應用/workflow 清單 UI)
|
||||||
|
- **API Keys 獨立頁**(現在和 dashboard 混在一起)
|
||||||
|
- **Workflow Viewer**:node-based canvas,對應 YAML workflow 的視覺化(目前 acr push YAML 後只有 CLI 輸出)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. User Stories
|
||||||
|
|
||||||
|
### US-1:新訪客認識 arcrun
|
||||||
|
- 作為沒用過 arcrun 的工程師,我要在 landing 看到兩條路(For Developers / For Everyone),5 秒內判斷這是否符合我要的用法。
|
||||||
|
- 驗收:hero + 雙 path card + feature strip 在首屏呈現;CTA「Start free」可點擊到 `/auth`。
|
||||||
|
|
||||||
|
### US-2:取得 / 登入會員
|
||||||
|
- 作為訪客,我要用 GitHub / Google / Email 登入建立帳號。
|
||||||
|
- 驗收:`/auth` 支援 Sign in / Sign up tabs、GitHub + Google OAuth、Email + Password 表單。OAuth 成功後導向 `/dashboard`。
|
||||||
|
|
||||||
|
### US-3:看見應用全貌
|
||||||
|
- 作為已登入用戶,我要在 Dashboard 看到我已部署的 App(packaged workflow)和 Workflow 清單。
|
||||||
|
- 驗收:
|
||||||
|
- Apps Grid:每個 app 卡片顯示名稱、描述、「Open app」「Edit in Claude」。
|
||||||
|
- Workflows Table:顯示 workflow 名稱、節點數、最後修改、執行次數、狀態。
|
||||||
|
- 若 app / workflow 為空,顯示新建 CTA(非硬編 mock)。
|
||||||
|
|
||||||
|
### US-4:管理 API Keys
|
||||||
|
- 作為已登入用戶,我要建立、檢視、停用、刪除 API Key。
|
||||||
|
- 驗收:
|
||||||
|
- 剛產生的 Key 顯示在頂部高亮卡片(含警示文字「不會再顯示」)。
|
||||||
|
- 全部 Keys 在下方表格顯示(名稱、遮蔽後 key、建立時間、最後使用、狀態、toggle、trash)。
|
||||||
|
- Rotate / Revoke 立即生效(呼叫 cypher-executor)。
|
||||||
|
|
||||||
|
### US-5:檢視 Workflow graph
|
||||||
|
- 作為已登入用戶,我要點 workflow 進到 viewer,看到節點 DAG 與每個節點的 I/O schema 與參數。
|
||||||
|
- 驗收:
|
||||||
|
- Canvas 顯示節點(含 icon / type badge),節點以 bezier 曲線相連。
|
||||||
|
- 選中節點後右側面板顯示 input / output schema、configuration(針對 ai.completion 等節點顯示 triplet 編輯器)。
|
||||||
|
- 工具列含 Share / Export YAML / Edit in Claude。
|
||||||
|
- Minimap + zoom 控制顯示可用。
|
||||||
|
- 「Export YAML」呼叫 cypher-executor 取得該 workflow 的原始 YAML。
|
||||||
|
|
||||||
|
### US-6:Dogfooding
|
||||||
|
- 作為 arcrun 核心維護者,我要前端所有與服務打交道的動作都透過 arcrun 自家 API(cypher-executor)完成,不依賴第三方 OAuth / workflow / backend 服務。
|
||||||
|
- 驗收(見 §6)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 非功能需求
|
||||||
|
|
||||||
|
| 項目 | 規範 |
|
||||||
|
|---|---|
|
||||||
|
| 技術棧 | Next.js 15 App Router、React 19、Tailwind v4、TypeScript(沿用 `landing/` 現有堆疊) |
|
||||||
|
| 部署 | Cloudflare Pages(`@cloudflare/next-on-pages`),沿用 `landing/wrangler.toml` |
|
||||||
|
| 字型 | Inter、JetBrains Mono — 用 `next/font/google`,不拉 unpkg / fonts.googleapis.com `<link>` |
|
||||||
|
| 依賴 | 僅 Next / React / Tailwind;禁止 tRPC、React Query、SWR、Auth.js/Clerk、ReactFlow/XYFlow、Radix、shadcn CLI install、animation library(framer-motion)等第三方 |
|
||||||
|
| 狀態管理 | React 內建(useState / useReducer / Context);持久化用 `localStorage` 或 server session cookie |
|
||||||
|
| 國際化 | 延後;本次一律英文(與設計稿一致)。現有 `?lang=zh` 不擴展 |
|
||||||
|
| 無障礙 | 按鈕 `aria-label`、Form 控件有 `<label>`;鍵盤可完成登入 / 複製 key 流程 |
|
||||||
|
| 效能 | Landing 首屏無阻塞 JS(RSC);Dashboard / Workflow Viewer 可為 Client Component |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 視覺基準
|
||||||
|
|
||||||
|
| 項目 | 值(source of truth:`design-source/index.html` 的 CSS 變數) |
|
||||||
|
|---|---|
|
||||||
|
| 主背景 | `#0F0F0F`(現有是 `#0a0a0a` — 本次改為 `#0F0F0F`) |
|
||||||
|
| Card | `#1A1A1A` |
|
||||||
|
| Line | `#262626` / `#303030` |
|
||||||
|
| Primary | `#6366F1`(indigo) → `#8B5CF6`(violet)漸層 |
|
||||||
|
| Text | `#EDEDED` / dim `#A0A0A0` / mute `#6B6B6B` |
|
||||||
|
| 字型 | Inter 300/400/500/600/700/800;Mono 400/500/600 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 範圍界線
|
||||||
|
|
||||||
|
### 納入
|
||||||
|
- 5 screen(Landing、Auth、Dashboard、API Keys、Workflow Viewer)
|
||||||
|
- Logo(SVG arc wordmark)、Icon set(inline SVG,從 primitives.jsx port)
|
||||||
|
- TopNav / Footer / Sidebar 三個 shell 元件
|
||||||
|
|
||||||
|
### 不納入(本次 SDD)
|
||||||
|
- **Multi-tenant workspace 切換**(設計稿有 "Northwind" breadcrumb,本次純顯示用戶 email)
|
||||||
|
- **Multi-API-key CRUD 後端**(cypher-executor 現只支援每帳號一把 key,多 key table 先以「目前只支援一把」狀態呈現 — 見 §6.2)
|
||||||
|
- **Workflow 編輯**(只做 read-only viewer;編輯仍走 acr CLI / YAML)
|
||||||
|
- **即時執行狀態 stream**(minimap / zoom 僅 UI,不做真實 pan-zoom transform)
|
||||||
|
- **i18n 中英切換**、**Hall of Fame**、**Donate**
|
||||||
|
- **Swagger UI 頁(/api-docs)** — 保留現狀
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. API 依賴(全部打 `cypher.arcrun.dev`)
|
||||||
|
|
||||||
|
### 6.1 既有(已實作於 cypher-executor)
|
||||||
|
- `GET /me` — 取得登入用戶 + api_key(driver:dashboard 側欄 avatar / API Keys 頁的單把 key)
|
||||||
|
- `PUT /me/api-key/rotate`、`DELETE /me/api-key` — Rotate / Revoke
|
||||||
|
- `GET /auth/google/start`、`GET /auth/github/start` — OAuth 起點
|
||||||
|
- `POST /auth/logout`
|
||||||
|
- `POST /webhooks/named/{name}/trigger` — 觸發(給 Landing 文案展示)
|
||||||
|
|
||||||
|
### 6.2 需新增的 endpoint(阻擋項;**本 SDD 只定義契約,cypher-executor 實作歸屬於另一個 task**)
|
||||||
|
|
||||||
|
| Method | Path | 說明 | 用途 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| GET | `/workflows` | 列出當前 api_key 名下的 workflow(名稱、nodes、modified、run_count、status) | Dashboard / Workflows Table |
|
||||||
|
| GET | `/workflows/:name` | 取得 workflow 詳細(含 graph 節點 + edges + YAML) | Workflow Viewer |
|
||||||
|
| GET | `/workflows/:name/yaml` | 下載 raw YAML | Workflow Viewer Export |
|
||||||
|
| GET | `/apps` | 列出 app(= workflow 標上 icon/tone/description metadata) | Dashboard / Apps Grid |
|
||||||
|
| GET | `/api-keys` | 列出多把 key(若後端仍是單把,回傳單元素 array) | API Keys 頁 |
|
||||||
|
| POST | `/api-keys` | 建立新 key | API Keys 頁「Create new key」 |
|
||||||
|
| PATCH | `/api-keys/:id` | `{ active: boolean }` 切換 | API Keys 頁 toggle |
|
||||||
|
| DELETE | `/api-keys/:id` | 刪除 | API Keys 頁 trash |
|
||||||
|
|
||||||
|
**在後端尚未實作前**:前端用型別化 fetch wrapper 封裝呼叫;遇到 404 顯示 empty state(而非假資料)。本 SDD 明確禁止 hard-code mock fixture。
|
||||||
|
|
||||||
|
### 6.3 登入前後可用的公開資訊
|
||||||
|
- Integrations 清單(20 個 recipe):現有 `/integrations` 頁已有靜態清單,繼續複用。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Dogfooding 紅線
|
||||||
|
|
||||||
|
| 禁止 | 用哪個 arcrun 內部替代 |
|
||||||
|
|---|---|
|
||||||
|
| Auth0 / Clerk / Supabase Auth | cypher-executor `/auth/*` + session cookie(現行 `arcrun_session`) |
|
||||||
|
| Segment / PostHog | 不加,或用後續 arcrun `analytics` 零件 |
|
||||||
|
| Vercel KV / Upstash | Cloudflare KV(經 cypher-executor) |
|
||||||
|
| 直接寫第三方 API(Slack / Notion 等)作 dashboard demo | 透過 arcrun workflow + trigger 模擬 |
|
||||||
|
| ReactFlow / XYFlow | 純 SVG 手刻(設計稿本來就是手刻) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 驗收總清單
|
||||||
|
|
||||||
|
- [ ] 5 個 screen 全部在 `/`, `/auth`, `/dashboard`, `/keys`, `/workflows/[id]` 可達。
|
||||||
|
- [ ] 設計稿的 spacing / color / border-radius 100% 對得上(以 `design-source/index.html` CSS 變數為準)。
|
||||||
|
- [ ] middleware 保護 `/dashboard`, `/keys`, `/workflows/*`(未登入 → `/auth?redirect=...`)。
|
||||||
|
- [ ] 只打 `cypher.arcrun.dev`(可透過 `NEXT_PUBLIC_API_BASE` override),grep 結果不含其他外部 API host。
|
||||||
|
- [ ] `package.json` 新增依賴 = 0(本次不引入新 npm 套件)。
|
||||||
|
- [ ] 無 mock 資料:若後端未實作,顯示 loading 或 empty state,不編假陣列給 UI。
|
||||||
|
- [ ] `pnpm build` 通過,`next-on-pages` 輸出無 edge-runtime 錯誤。
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
# Frontend Redesign — Tasks
|
||||||
|
|
||||||
|
> 進度來源:本檔。完成一項立刻 `[x]`,不批次。
|
||||||
|
> 本 SDD 建立於 2026-04-23;richblack 尚未下令動工,**所有 task 預設 `[ ]`**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 0 — SDD 建立(本次)
|
||||||
|
|
||||||
|
- [x] 取得 Claude Design bundle,歸檔至 `design-source/`
|
||||||
|
- [x] 撰寫 `requirements.md`
|
||||||
|
- [x] 撰寫 `design.md`
|
||||||
|
- [x] 撰寫 `tasks.md`(本檔)
|
||||||
|
- [ ] richblack review + 認可 → 開 Phase 1
|
||||||
|
|
||||||
|
**等 richblack 明確說「開始動工」之前,不觸 `landing/` 任何檔案。**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1 — Tokens + Shell(無外部 API 依賴)
|
||||||
|
|
||||||
|
- [ ] 1.1 建 `landing/app/design-tokens.css`,抄 design-source CSS :root 變數
|
||||||
|
- [ ] 1.2 更新 `landing/app/globals.css`:`@import './design-tokens.css'`,`@theme inline` 對應 token → Tailwind color
|
||||||
|
- [ ] 1.3 `layout.tsx` 引入 `next/font/google` 的 Inter + JetBrains Mono;body bg 改 `var(--bg)`
|
||||||
|
- [ ] 1.4 建 `landing/components/shell/Icon.tsx`(港設計稿 primitives.jsx 的所有 icon)
|
||||||
|
- [ ] 1.5 建 `landing/components/shell/Logo.tsx`(SVG arc wordmark)
|
||||||
|
- [ ] 1.6 建 `landing/components/shell/TopNav.tsx`
|
||||||
|
- [ ] 1.7 建 `landing/components/shell/Footer.tsx`
|
||||||
|
- [ ] 1.8 建 `landing/components/shell/Sidebar.tsx`(含頭像、項目清單、登出按鈕)
|
||||||
|
- [ ] 1.9 建 `landing/components/primitives/Button.tsx`(variants: primary / secondary / ghost / danger-ghost / sm / lg)
|
||||||
|
- [ ] 1.10 建 `landing/components/primitives/Pill.tsx`、`Toggle.tsx`
|
||||||
|
- [ ] 1.11 Lint + build pass
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2 — Landing(`/`)
|
||||||
|
|
||||||
|
- [ ] 2.1 Rewrite `app/page.tsx`,結構照 design-source/screens/Landing.jsx
|
||||||
|
- [ ] 2.2 Hero:heading / eyebrow / CTA / radial-grid bg(CSS only)
|
||||||
|
- [ ] 2.3 Paths 左卡:install tabs 元件(Client Component)+ terminal code blocks(字串改 `acr` 實際指令)
|
||||||
|
- [ ] 2.4 Paths 右卡:chat preview(靜態)
|
||||||
|
- [ ] 2.5 Feature strip(4 cell)
|
||||||
|
- [ ] 2.6 TopNav / Footer 接上
|
||||||
|
- [ ] 2.7 Responsive(≤ 768px: paths 單欄、hero h1 縮 1 級)
|
||||||
|
- [ ] 2.8 視覺比對 design-source/index.html(截圖對比 / DOM spec 檢查)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3 — Auth(`/auth`,`/login` redirect)
|
||||||
|
|
||||||
|
- [ ] 3.1 建 `app/auth/page.tsx`(Client)
|
||||||
|
- [ ] 3.2 Sign in / Sign up tabs + OAuth buttons(Google / GitHub)接既有 `/auth/google/start` / `/auth/github/start`
|
||||||
|
- [ ] 3.3 Email / Password form — submit 顯示「OAuth only」提示(待後端支援)
|
||||||
|
- [ ] 3.4 `/login` 頁改為 `redirect('/auth')`
|
||||||
|
- [ ] 3.5 視覺比對 design-source/screens/Auth.jsx
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4 — API Keys(`/keys`)
|
||||||
|
|
||||||
|
- [ ] 4.1 建 `lib/api.ts` fetch wrapper
|
||||||
|
- [ ] 4.2 建 `lib/apiKeys.ts`(listKeys / createKey / patchKey / deleteKey)— 後端未實作時回 `[{ ...from /me }]` 的 fallback
|
||||||
|
- [ ] 4.3 建 `app/keys/page.tsx`:頂部 new-key-box(sessionStorage flag)、全表格
|
||||||
|
- [ ] 4.4 「Create new key」呼叫 `POST /api-keys`(後端未實作 → 顯示「coming soon」toast)
|
||||||
|
- [ ] 4.5 Toggle active / trash 接 PATCH / DELETE
|
||||||
|
- [ ] 4.6 middleware.ts matcher 加 `/keys/:path*`
|
||||||
|
- [ ] 4.7 原 `/dashboard` 頁的 Key 卡區塊移除,放提示「API Keys 已搬到 /keys」
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5 — Dashboard(`/dashboard`)
|
||||||
|
|
||||||
|
- [ ] 5.1 建 `lib/workflows.ts`、`lib/apps.ts`
|
||||||
|
- [ ] 5.2 Rewrite `app/dashboard/page.tsx`(保留 /me session 檢查)
|
||||||
|
- [ ] 5.3 Main head:breadcrumb / greeting / summary counters
|
||||||
|
- [ ] 5.4 Apps Grid + empty-state 卡(`/apps` 404 → 只顯示 empty-state)
|
||||||
|
- [ ] 5.5 Workflows Table(`/workflows` 404 → 顯示「no workflows — acr push」CTA)
|
||||||
|
- [ ] 5.6 Sidebar 接真實 user(email / display_name)、登出按鈕接 `/auth/logout`
|
||||||
|
- [ ] 5.7 視覺比對 design-source/screens/Dashboard.jsx
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 6 — Workflow Viewer(`/workflows/[name]`)
|
||||||
|
|
||||||
|
- [ ] 6.1 建 `app/workflows/[name]/page.tsx`(Client)
|
||||||
|
- [ ] 6.2 Topbar:back / logo / breadcrumb / title / saved indicator / share / export / edit-in-claude
|
||||||
|
- [ ] 6.3 `<Canvas>`:SVG defs + 節點定位 + bezier edges;資料從 `GET /workflows/:name`
|
||||||
|
- [ ] 6.4 Auto-layout:topological depth → columns,同 depth 平均分配 y
|
||||||
|
- [ ] 6.5 NodeCard 點擊 → DetailPanel
|
||||||
|
- [ ] 6.6 DetailPanel:input / output schema、triplet(disabled)、last run stats(可選)
|
||||||
|
- [ ] 6.7 Export YAML:`GET /workflows/:name/yaml` → blob download
|
||||||
|
- [ ] 6.8 Minimap(純顯示)、ZoomControls(簡單 scale)
|
||||||
|
- [ ] 6.9 middleware.ts matcher 加 `/workflows/:path*`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 7 — 清理 + 收尾
|
||||||
|
|
||||||
|
- [ ] 7.1 刪除舊 `/dashboard` 不再用的 code(rotate/revoke 若全搬到 /keys)
|
||||||
|
- [ ] 7.2 Grep 檢查:除 `cypher.arcrun.dev` 外無任何第三方 API host
|
||||||
|
- [ ] 7.3 Grep 檢查:無 `mock` / `fixture` / 硬編的假資料陣列(`app-empty` 的字串常數除外)
|
||||||
|
- [ ] 7.4 Grep 檢查:無新增 npm 依賴(`git diff landing/package.json` 應只改版本,不加條目)
|
||||||
|
- [ ] 7.5 `pnpm build` 通過;`next-on-pages` 通過
|
||||||
|
- [ ] 7.6 local `pnpm dev` 手工巡覽 5 screen,每個截圖比對 design-source
|
||||||
|
- [ ] 7.7 更新 `.agents/specs/arcrun/arcrun.md`,加一段「CLI 1.2.0 搭配新 landing」之類的進度註記
|
||||||
|
- [ ] 7.8 richblack 認可 → 合併 / deploy
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 需要 cypher-executor 搭配的 endpoint(不屬於本 SDD)
|
||||||
|
|
||||||
|
若 richblack 決定新 endpoint 要和前端同 PR 做:
|
||||||
|
|
||||||
|
- [ ] 後端:`GET /workflows`
|
||||||
|
- [ ] 後端:`GET /workflows/:name` + `/yaml`
|
||||||
|
- [ ] 後端:`GET /apps`
|
||||||
|
- [ ] 後端:`GET /api-keys`、`POST /api-keys`、`PATCH /api-keys/:id`、`DELETE /api-keys/:id`
|
||||||
|
|
||||||
|
否則:以 empty state 呈現,封測也能運作。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## KBDB 整合(配合 matrix/kbdb/.agents/specs/arcrun-key-auth/)
|
||||||
|
|
||||||
|
Arcrun 用戶的 `ak_xxx` Key 同時可用於 KBDB(捆綁服務,自動開通)。
|
||||||
|
cypher-executor 需在以下時機呼叫 KBDB:
|
||||||
|
|
||||||
|
- [ ] 後端:OAuth callback 成功 → `POST /partners`(建立 KBDB partner 記錄)
|
||||||
|
- [ ] 後端:`PUT /me/api-key/rotate` → 舊 partner revoke + 建新 partner 記錄
|
||||||
|
- [ ] 後端:`DELETE /me/api-key` → KBDB partner revoke
|
||||||
|
|
||||||
|
詳細設計見 `matrix/kbdb/.agents/specs/arcrun-key-auth/design.md`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 目前狀態
|
||||||
|
|
||||||
|
- **進度**:Phase 0 已完成(4/5;最後一項等 richblack 認可)。
|
||||||
|
- **阻擋**:richblack 認可 + 「開始動工」指令。
|
||||||
|
- **未啟動**:Phase 1-7 全部 `[ ]`。
|
||||||
@@ -123,10 +123,13 @@ async function runWasm(wasmModule: WebAssembly.Module, input: unknown): Promise<
|
|||||||
const instance = await WebAssembly.instantiate(wasmModule, wasi);
|
const instance = await WebAssembly.instantiate(wasmModule, wasi);
|
||||||
memory = instance.exports.memory as WebAssembly.Memory;
|
memory = instance.exports.memory as WebAssembly.Memory;
|
||||||
|
|
||||||
const start = (instance.exports._start ?? instance.exports.main) as () => void;
|
const promising = (WebAssembly as unknown as Record<string, unknown>)['promising'] as
|
||||||
if (typeof start !== 'function') throw new Error('WASM missing _start or main export');
|
((fn: () => void) => () => Promise<void>) | undefined;
|
||||||
|
const startFn = (instance.exports._start ?? instance.exports.main) as () => void;
|
||||||
try { start(); } catch (e) {
|
if (typeof startFn !== 'function') throw new Error('WASM missing _start or main export');
|
||||||
|
try {
|
||||||
|
if (promising) { await promising(startFn)(); } else { startFn(); }
|
||||||
|
} catch (e) {
|
||||||
if (!(e instanceof Error && e.message === 'wasm exit: 0')) throw e;
|
if (!(e instanceof Error && e.message === 'wasm exit: 0')) throw e;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -123,10 +123,13 @@ async function runWasm(wasmModule: WebAssembly.Module, input: unknown): Promise<
|
|||||||
const instance = await WebAssembly.instantiate(wasmModule, wasi);
|
const instance = await WebAssembly.instantiate(wasmModule, wasi);
|
||||||
memory = instance.exports.memory as WebAssembly.Memory;
|
memory = instance.exports.memory as WebAssembly.Memory;
|
||||||
|
|
||||||
const start = (instance.exports._start ?? instance.exports.main) as () => void;
|
const promising = (WebAssembly as unknown as Record<string, unknown>)['promising'] as
|
||||||
if (typeof start !== 'function') throw new Error('WASM missing _start or main export');
|
((fn: () => void) => () => Promise<void>) | undefined;
|
||||||
|
const startFn = (instance.exports._start ?? instance.exports.main) as () => void;
|
||||||
try { start(); } catch (e) {
|
if (typeof startFn !== 'function') throw new Error('WASM missing _start or main export');
|
||||||
|
try {
|
||||||
|
if (promising) { await promising(startFn)(); } else { startFn(); }
|
||||||
|
} catch (e) {
|
||||||
if (!(e instanceof Error && e.message === 'wasm exit: 0')) throw e;
|
if (!(e instanceof Error && e.message === 'wasm exit: 0')) throw e;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -123,10 +123,13 @@ async function runWasm(wasmModule: WebAssembly.Module, input: unknown): Promise<
|
|||||||
const instance = await WebAssembly.instantiate(wasmModule, wasi);
|
const instance = await WebAssembly.instantiate(wasmModule, wasi);
|
||||||
memory = instance.exports.memory as WebAssembly.Memory;
|
memory = instance.exports.memory as WebAssembly.Memory;
|
||||||
|
|
||||||
const start = (instance.exports._start ?? instance.exports.main) as () => void;
|
const promising = (WebAssembly as unknown as Record<string, unknown>)['promising'] as
|
||||||
if (typeof start !== 'function') throw new Error('WASM missing _start or main export');
|
((fn: () => void) => () => Promise<void>) | undefined;
|
||||||
|
const startFn = (instance.exports._start ?? instance.exports.main) as () => void;
|
||||||
try { start(); } catch (e) {
|
if (typeof startFn !== 'function') throw new Error('WASM missing _start or main export');
|
||||||
|
try {
|
||||||
|
if (promising) { await promising(startFn)(); } else { startFn(); }
|
||||||
|
} catch (e) {
|
||||||
if (!(e instanceof Error && e.message === 'wasm exit: 0')) throw e;
|
if (!(e instanceof Error && e.message === 'wasm exit: 0')) throw e;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1539
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"name": "arcrun-auth-oauth2",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"dependencies": {
|
||||||
|
"hono": "^4.7.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@cloudflare/workers-types": "^4.20250408.0",
|
||||||
|
"typescript": "^5.4.0",
|
||||||
|
"wrangler": "^4.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
/**
|
||||||
|
* arcrun auth_oauth2 Worker
|
||||||
|
*
|
||||||
|
* POST / → JSON input {action, api_key, service} → WASM (WASI preview1 stdin/stdout) → JSON output
|
||||||
|
*
|
||||||
|
* 額外 host functions(相較 auth_static_key):
|
||||||
|
* - kv_put:快取 access_token(短效,TTL 跟隨 expires_in)
|
||||||
|
* - http_request:POST token endpoint 換 access_token
|
||||||
|
*/
|
||||||
|
|
||||||
|
import componentWasm from '../component.wasm' assert { type: 'webassembly' };
|
||||||
|
import { Hono } from 'hono';
|
||||||
|
import { cors } from 'hono/cors';
|
||||||
|
import {
|
||||||
|
createWasiShim,
|
||||||
|
createArcrunHostFunctions,
|
||||||
|
type ArcrunHostEnv,
|
||||||
|
} from '../../../cypher-executor/src/lib/wasi-shim';
|
||||||
|
|
||||||
|
type Env = ArcrunHostEnv;
|
||||||
|
|
||||||
|
const app = new Hono<{ Bindings: Env }>();
|
||||||
|
app.use('*', cors());
|
||||||
|
|
||||||
|
app.get('/', (c) => c.json({ ok: true, component: 'auth_oauth2' }));
|
||||||
|
|
||||||
|
app.post('/', async (c) => {
|
||||||
|
let input: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
input = await c.req.json();
|
||||||
|
} catch {
|
||||||
|
return c.json({ success: false, error: 'request body must be JSON' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiKey = typeof input.api_key === 'string' ? input.api_key : '';
|
||||||
|
if (!apiKey) {
|
||||||
|
return c.json({ success: false, error: 'api_key 必填' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await runWasm(c.env, apiKey, input);
|
||||||
|
return c.json(result);
|
||||||
|
} catch (e) {
|
||||||
|
return c.json(
|
||||||
|
{ success: false, error: e instanceof Error ? e.message : String(e) },
|
||||||
|
500,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default app;
|
||||||
|
|
||||||
|
// ── WASM runner ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function runWasm(env: Env, apiKey: string, input: unknown): Promise<unknown> {
|
||||||
|
const stdinData = JSON.stringify(input);
|
||||||
|
const hostFunctions = createArcrunHostFunctions(env, apiKey);
|
||||||
|
|
||||||
|
// 加入 http_request(token endpoint 用)
|
||||||
|
hostFunctions.http_request = async (url, method, headersJSON, body) => {
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
try {
|
||||||
|
Object.assign(headers, JSON.parse(headersJSON || '{}'));
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
body: body || undefined,
|
||||||
|
});
|
||||||
|
return res.text();
|
||||||
|
};
|
||||||
|
|
||||||
|
const shim = createWasiShim(stdinData, hostFunctions);
|
||||||
|
|
||||||
|
const instance = await WebAssembly.instantiate(
|
||||||
|
componentWasm as WebAssembly.Module,
|
||||||
|
shim.imports,
|
||||||
|
);
|
||||||
|
shim.setMemory(instance.exports.memory as WebAssembly.Memory);
|
||||||
|
await shim.run(instance);
|
||||||
|
|
||||||
|
const stdout = shim.getStdout().trim();
|
||||||
|
if (!stdout) throw new Error('WASM component produced no output');
|
||||||
|
return JSON.parse(stdout);
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ES2022",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"types": ["@cloudflare/workers-types"],
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
name = "arcrun-auth-oauth2"
|
||||||
|
main = "src/index.ts"
|
||||||
|
compatibility_date = "2025-02-19"
|
||||||
|
compatibility_flags = ["nodejs_compat"]
|
||||||
|
|
||||||
|
[vars]
|
||||||
|
COMPONENT_ID = "auth_oauth2"
|
||||||
|
|
||||||
|
[[routes]]
|
||||||
|
pattern = "auth-oauth2.arcrun.dev/*"
|
||||||
|
zone_name = "arcrun.dev"
|
||||||
|
|
||||||
|
[[kv_namespaces]]
|
||||||
|
binding = "CREDENTIALS_KV"
|
||||||
|
id = "e7f4320f88d343f187e35e3543dd74c9"
|
||||||
|
|
||||||
|
[[kv_namespaces]]
|
||||||
|
binding = "RECIPES"
|
||||||
|
id = "9cf9db905c6241f78503199e58b2ffe0"
|
||||||
|
|
||||||
|
# ENCRYPTION_KEY 透過 wrangler secret put 設定
|
||||||
@@ -97,17 +97,7 @@ async function runWasm(env: Env, apiKey: string, input: unknown): Promise<unknow
|
|||||||
shim.imports,
|
shim.imports,
|
||||||
);
|
);
|
||||||
shim.setMemory(instance.exports.memory as WebAssembly.Memory);
|
shim.setMemory(instance.exports.memory as WebAssembly.Memory);
|
||||||
|
await shim.run(instance);
|
||||||
const start = (instance.exports._start ?? instance.exports.main) as () => void;
|
|
||||||
if (typeof start !== 'function') {
|
|
||||||
throw new Error('WASM missing _start or main export');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
start();
|
|
||||||
} catch (e) {
|
|
||||||
if (!(e instanceof Error && e.message === 'wasm exit: 0')) throw e;
|
|
||||||
}
|
|
||||||
|
|
||||||
const stdout = shim.getStdout().trim();
|
const stdout = shim.getStdout().trim();
|
||||||
if (!stdout) throw new Error('WASM component produced no output');
|
if (!stdout) throw new Error('WASM component produced no output');
|
||||||
|
|||||||
@@ -65,17 +65,7 @@ async function runWasm(env: Env, apiKey: string, input: unknown): Promise<unknow
|
|||||||
shim.imports,
|
shim.imports,
|
||||||
);
|
);
|
||||||
shim.setMemory(instance.exports.memory as WebAssembly.Memory);
|
shim.setMemory(instance.exports.memory as WebAssembly.Memory);
|
||||||
|
await shim.run(instance);
|
||||||
const start = (instance.exports._start ?? instance.exports.main) as () => void;
|
|
||||||
if (typeof start !== 'function') {
|
|
||||||
throw new Error('WASM missing _start or main export');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
start();
|
|
||||||
} catch (e) {
|
|
||||||
if (!(e instanceof Error && e.message === 'wasm exit: 0')) throw e;
|
|
||||||
}
|
|
||||||
|
|
||||||
const stdout = shim.getStdout().trim();
|
const stdout = shim.getStdout().trim();
|
||||||
if (!stdout) throw new Error('WASM component produced no output');
|
if (!stdout) throw new Error('WASM component produced no output');
|
||||||
|
|||||||
+1539
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"name": "arcrun-claude-api",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"dependencies": {
|
||||||
|
"hono": "^4.7.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@cloudflare/workers-types": "^4.20250408.0",
|
||||||
|
"typescript": "^5.4.0",
|
||||||
|
"wrangler": "^4.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
/**
|
||||||
|
* arcrun WASM 零件 Worker (claude_api v2)
|
||||||
|
* POST / → JSON input → WASM (WASI preview1) → JSON output
|
||||||
|
* SDD: polaris/mira/.agents/specs/mira-app/design.md §6
|
||||||
|
*
|
||||||
|
* v2 改打 Mira daemon (Hetzner) 而非直打 Anthropic Messages API
|
||||||
|
* 理由:OAuth token 在 Messages API 限制 system prompt → rate_limit
|
||||||
|
* Mira daemon 用 Claude Agent SDK,已內建 Mira persona
|
||||||
|
*/
|
||||||
|
|
||||||
|
import componentWasm from '../component.wasm' assert { type: 'webassembly' };
|
||||||
|
import { Hono } from 'hono';
|
||||||
|
import { cors } from 'hono/cors';
|
||||||
|
import { createWasiShim, type WasiHostFunctions } from '../../../cypher-executor/src/lib/wasi-shim';
|
||||||
|
|
||||||
|
type Env = {
|
||||||
|
MIRA_TOKEN?: string; // Mira daemon Bearer token
|
||||||
|
MIRA_URL?: string; // 預設 https://mira.uncle6.me
|
||||||
|
COMPONENT_ID: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const app = new Hono<{ Bindings: Env }>();
|
||||||
|
app.use('*', cors());
|
||||||
|
|
||||||
|
app.get('/', (c) => c.json({ ok: true, component: 'claude_api', version: 'v2-mira-daemon' }));
|
||||||
|
|
||||||
|
app.post('/', async (c) => {
|
||||||
|
let input: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
input = await c.req.json();
|
||||||
|
} catch {
|
||||||
|
return c.json({ success: false, error: 'request body must be JSON' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 用戶沒帶 token → 用 Worker secret fallback
|
||||||
|
if (!input.mira_token && c.env.MIRA_TOKEN) {
|
||||||
|
input.mira_token = c.env.MIRA_TOKEN;
|
||||||
|
}
|
||||||
|
if (!input.mira_url && c.env.MIRA_URL) {
|
||||||
|
input.mira_url = c.env.MIRA_URL;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await runWasm(input);
|
||||||
|
return c.json(result);
|
||||||
|
} catch (e) {
|
||||||
|
return c.json(
|
||||||
|
{ success: false, error: e instanceof Error ? e.message : String(e) },
|
||||||
|
500,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default app;
|
||||||
|
|
||||||
|
async function runWasm(input: unknown): Promise<unknown> {
|
||||||
|
const hostFunctions: WasiHostFunctions = {
|
||||||
|
http_request: async (url, method, headersJson, body) => {
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
if (headersJson) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(headersJson);
|
||||||
|
if (parsed && typeof parsed === 'object') {
|
||||||
|
for (const [k, v] of Object.entries(parsed as Record<string, unknown>)) {
|
||||||
|
if (typeof v === 'string') headers[k] = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
const init: RequestInit = { method, headers };
|
||||||
|
if (body && method.toUpperCase() !== 'GET' && method.toUpperCase() !== 'HEAD') {
|
||||||
|
init.body = body;
|
||||||
|
}
|
||||||
|
const res = await fetch(url, init);
|
||||||
|
return await res.text();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const shim = createWasiShim(JSON.stringify(input), hostFunctions);
|
||||||
|
const instance = await WebAssembly.instantiate(
|
||||||
|
componentWasm as WebAssembly.Module,
|
||||||
|
shim.imports,
|
||||||
|
);
|
||||||
|
shim.setMemory(instance.exports.memory as WebAssembly.Memory);
|
||||||
|
await shim.run(instance);
|
||||||
|
|
||||||
|
const stdout = shim.getStdout().trim();
|
||||||
|
const stderr = shim.getStderr().trim();
|
||||||
|
if (stderr) console.error('[claude_api wasm stderr]', stderr);
|
||||||
|
if (!stdout) throw new Error('WASM component produced no output');
|
||||||
|
return JSON.parse(stdout);
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ES2022",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"types": ["@cloudflare/workers-types"],
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
name = "arcrun-claude-api"
|
||||||
|
main = "src/index.ts"
|
||||||
|
compatibility_date = "2025-02-19"
|
||||||
|
compatibility_flags = ["nodejs_compat"]
|
||||||
|
|
||||||
|
[vars]
|
||||||
|
COMPONENT_ID = "claude_api"
|
||||||
|
|
||||||
|
[[routes]]
|
||||||
|
pattern = "claude-api.arcrun.dev/*"
|
||||||
|
zone_name = "arcrun.dev"
|
||||||
@@ -49,17 +49,7 @@ async function runWasm(input: unknown): Promise<unknown> {
|
|||||||
shim.imports,
|
shim.imports,
|
||||||
);
|
);
|
||||||
shim.setMemory(instance.exports.memory as WebAssembly.Memory);
|
shim.setMemory(instance.exports.memory as WebAssembly.Memory);
|
||||||
|
await shim.run(instance);
|
||||||
const start = (instance.exports._start ?? instance.exports.main) as () => void;
|
|
||||||
if (typeof start !== 'function') {
|
|
||||||
throw new Error('WASM missing _start or main export');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
start();
|
|
||||||
} catch (e) {
|
|
||||||
if (!(e instanceof Error && e.message === 'wasm exit: 0')) throw e;
|
|
||||||
}
|
|
||||||
|
|
||||||
const stdout = shim.getStdout().trim();
|
const stdout = shim.getStdout().trim();
|
||||||
if (!stdout) throw new Error('WASM component produced no output');
|
if (!stdout) throw new Error('WASM component produced no output');
|
||||||
|
|||||||
@@ -123,10 +123,13 @@ async function runWasm(wasmModule: WebAssembly.Module, input: unknown): Promise<
|
|||||||
const instance = await WebAssembly.instantiate(wasmModule, wasi);
|
const instance = await WebAssembly.instantiate(wasmModule, wasi);
|
||||||
memory = instance.exports.memory as WebAssembly.Memory;
|
memory = instance.exports.memory as WebAssembly.Memory;
|
||||||
|
|
||||||
const start = (instance.exports._start ?? instance.exports.main) as () => void;
|
const promising = (WebAssembly as unknown as Record<string, unknown>)['promising'] as
|
||||||
if (typeof start !== 'function') throw new Error('WASM missing _start or main export');
|
((fn: () => void) => () => Promise<void>) | undefined;
|
||||||
|
const startFn = (instance.exports._start ?? instance.exports.main) as () => void;
|
||||||
try { start(); } catch (e) {
|
if (typeof startFn !== 'function') throw new Error('WASM missing _start or main export');
|
||||||
|
try {
|
||||||
|
if (promising) { await promising(startFn)(); } else { startFn(); }
|
||||||
|
} catch (e) {
|
||||||
if (!(e instanceof Error && e.message === 'wasm exit: 0')) throw e;
|
if (!(e instanceof Error && e.message === 'wasm exit: 0')) throw e;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -123,10 +123,13 @@ async function runWasm(wasmModule: WebAssembly.Module, input: unknown): Promise<
|
|||||||
const instance = await WebAssembly.instantiate(wasmModule, wasi);
|
const instance = await WebAssembly.instantiate(wasmModule, wasi);
|
||||||
memory = instance.exports.memory as WebAssembly.Memory;
|
memory = instance.exports.memory as WebAssembly.Memory;
|
||||||
|
|
||||||
const start = (instance.exports._start ?? instance.exports.main) as () => void;
|
const promising = (WebAssembly as unknown as Record<string, unknown>)['promising'] as
|
||||||
if (typeof start !== 'function') throw new Error('WASM missing _start or main export');
|
((fn: () => void) => () => Promise<void>) | undefined;
|
||||||
|
const startFn = (instance.exports._start ?? instance.exports.main) as () => void;
|
||||||
try { start(); } catch (e) {
|
if (typeof startFn !== 'function') throw new Error('WASM missing _start or main export');
|
||||||
|
try {
|
||||||
|
if (promising) { await promising(startFn)(); } else { startFn(); }
|
||||||
|
} catch (e) {
|
||||||
if (!(e instanceof Error && e.message === 'wasm exit: 0')) throw e;
|
if (!(e instanceof Error && e.message === 'wasm exit: 0')) throw e;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -123,10 +123,13 @@ async function runWasm(wasmModule: WebAssembly.Module, input: unknown): Promise<
|
|||||||
const instance = await WebAssembly.instantiate(wasmModule, wasi);
|
const instance = await WebAssembly.instantiate(wasmModule, wasi);
|
||||||
memory = instance.exports.memory as WebAssembly.Memory;
|
memory = instance.exports.memory as WebAssembly.Memory;
|
||||||
|
|
||||||
const start = (instance.exports._start ?? instance.exports.main) as () => void;
|
const promising = (WebAssembly as unknown as Record<string, unknown>)['promising'] as
|
||||||
if (typeof start !== 'function') throw new Error('WASM missing _start or main export');
|
((fn: () => void) => () => Promise<void>) | undefined;
|
||||||
|
const startFn = (instance.exports._start ?? instance.exports.main) as () => void;
|
||||||
try { start(); } catch (e) {
|
if (typeof startFn !== 'function') throw new Error('WASM missing _start or main export');
|
||||||
|
try {
|
||||||
|
if (promising) { await promising(startFn)(); } else { startFn(); }
|
||||||
|
} catch (e) {
|
||||||
if (!(e instanceof Error && e.message === 'wasm exit: 0')) throw e;
|
if (!(e instanceof Error && e.message === 'wasm exit: 0')) throw e;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -77,17 +77,7 @@ async function runWasm(input: unknown): Promise<unknown> {
|
|||||||
shim.imports,
|
shim.imports,
|
||||||
);
|
);
|
||||||
shim.setMemory(instance.exports.memory as WebAssembly.Memory);
|
shim.setMemory(instance.exports.memory as WebAssembly.Memory);
|
||||||
|
await shim.run(instance);
|
||||||
const start = (instance.exports._start ?? instance.exports.main) as () => void;
|
|
||||||
if (typeof start !== 'function') {
|
|
||||||
throw new Error('WASM missing _start or main export');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
start();
|
|
||||||
} catch (e) {
|
|
||||||
if (!(e instanceof Error && e.message === 'wasm exit: 0')) throw e;
|
|
||||||
}
|
|
||||||
|
|
||||||
const stdout = shim.getStdout().trim();
|
const stdout = shim.getStdout().trim();
|
||||||
if (!stdout) throw new Error('WASM component produced no output');
|
if (!stdout) throw new Error('WASM component produced no output');
|
||||||
|
|||||||
@@ -77,17 +77,7 @@ async function runWasm(input: unknown): Promise<unknown> {
|
|||||||
shim.imports,
|
shim.imports,
|
||||||
);
|
);
|
||||||
shim.setMemory(instance.exports.memory as WebAssembly.Memory);
|
shim.setMemory(instance.exports.memory as WebAssembly.Memory);
|
||||||
|
await shim.run(instance);
|
||||||
const start = (instance.exports._start ?? instance.exports.main) as () => void;
|
|
||||||
if (typeof start !== 'function') {
|
|
||||||
throw new Error('WASM missing _start or main export');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
start();
|
|
||||||
} catch (e) {
|
|
||||||
if (!(e instanceof Error && e.message === 'wasm exit: 0')) throw e;
|
|
||||||
}
|
|
||||||
|
|
||||||
const stdout = shim.getStdout().trim();
|
const stdout = shim.getStdout().trim();
|
||||||
if (!stdout) throw new Error('WASM component produced no output');
|
if (!stdout) throw new Error('WASM component produced no output');
|
||||||
|
|||||||
@@ -3,12 +3,8 @@
|
|||||||
*
|
*
|
||||||
* POST / → JSON input → WASM (WASI preview1 stdin/stdout) → JSON output
|
* POST / → JSON input → WASM (WASI preview1 stdin/stdout) → JSON output
|
||||||
*
|
*
|
||||||
* 方案 A:import cypher-executor/src/lib/wasi-shim.ts 的 `createWasiShim`
|
* host function: http_request(fetch → 回 response body 原文)
|
||||||
* 提供單一 `http_request` host function(fetch → 回 response body 原文)。
|
* 此 Worker 不做 credential 注入,只提供通用 HTTP 能力。
|
||||||
* 這個 Worker 不需要 crypto / kv_get(無 credential 解密職責)。
|
|
||||||
*
|
|
||||||
* Credential 注入路徑:cypher-executor auth-dispatcher 會先 POST 到對應 auth primitive Worker,
|
|
||||||
* 把 auth headers 合進本 Worker 的 input(例:`_auth_headers`)。
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import componentWasm from '../component.wasm' assert { type: 'webassembly' };
|
import componentWasm from '../component.wasm' assert { type: 'webassembly' };
|
||||||
@@ -57,7 +53,7 @@ async function runWasm(input: unknown): Promise<unknown> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// 忽略 header parse 錯誤
|
// ignore header parse errors
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const init: RequestInit = { method, headers };
|
const init: RequestInit = { method, headers };
|
||||||
@@ -65,7 +61,6 @@ async function runWasm(input: unknown): Promise<unknown> {
|
|||||||
init.body = body;
|
init.body = body;
|
||||||
}
|
}
|
||||||
const res = await fetch(url, init);
|
const res = await fetch(url, init);
|
||||||
// WASM 端直接 json.Unmarshal 回傳內容,這裡回 response body 原文
|
|
||||||
return await res.text();
|
return await res.text();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -77,19 +72,11 @@ async function runWasm(input: unknown): Promise<unknown> {
|
|||||||
shim.imports,
|
shim.imports,
|
||||||
);
|
);
|
||||||
shim.setMemory(instance.exports.memory as WebAssembly.Memory);
|
shim.setMemory(instance.exports.memory as WebAssembly.Memory);
|
||||||
|
await shim.run(instance);
|
||||||
const start = (instance.exports._start ?? instance.exports.main) as () => void;
|
|
||||||
if (typeof start !== 'function') {
|
|
||||||
throw new Error('WASM missing _start or main export');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
start();
|
|
||||||
} catch (e) {
|
|
||||||
if (!(e instanceof Error && e.message === 'wasm exit: 0')) throw e;
|
|
||||||
}
|
|
||||||
|
|
||||||
const stdout = shim.getStdout().trim();
|
const stdout = shim.getStdout().trim();
|
||||||
|
const stderr = shim.getStderr().trim();
|
||||||
|
if (stderr) console.error('[http_request wasm stderr]', stderr);
|
||||||
if (!stdout) throw new Error('WASM component produced no output');
|
if (!stdout) throw new Error('WASM component produced no output');
|
||||||
return JSON.parse(stdout);
|
return JSON.parse(stdout);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,10 +123,13 @@ async function runWasm(wasmModule: WebAssembly.Module, input: unknown): Promise<
|
|||||||
const instance = await WebAssembly.instantiate(wasmModule, wasi);
|
const instance = await WebAssembly.instantiate(wasmModule, wasi);
|
||||||
memory = instance.exports.memory as WebAssembly.Memory;
|
memory = instance.exports.memory as WebAssembly.Memory;
|
||||||
|
|
||||||
const start = (instance.exports._start ?? instance.exports.main) as () => void;
|
const promising = (WebAssembly as unknown as Record<string, unknown>)['promising'] as
|
||||||
if (typeof start !== 'function') throw new Error('WASM missing _start or main export');
|
((fn: () => void) => () => Promise<void>) | undefined;
|
||||||
|
const startFn = (instance.exports._start ?? instance.exports.main) as () => void;
|
||||||
try { start(); } catch (e) {
|
if (typeof startFn !== 'function') throw new Error('WASM missing _start or main export');
|
||||||
|
try {
|
||||||
|
if (promising) { await promising(startFn)(); } else { startFn(); }
|
||||||
|
} catch (e) {
|
||||||
if (!(e instanceof Error && e.message === 'wasm exit: 0')) throw e;
|
if (!(e instanceof Error && e.message === 'wasm exit: 0')) throw e;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1539
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"name": "arcrun-kbdb-create-block",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"dependencies": {
|
||||||
|
"hono": "^4.7.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@cloudflare/workers-types": "^4.20250408.0",
|
||||||
|
"typescript": "^5.4.0",
|
||||||
|
"wrangler": "^4.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
/**
|
||||||
|
* arcrun WASM 零件 Worker (kbdb_create_block)
|
||||||
|
* POST / → JSON input → WASM (WASI preview1) → JSON output
|
||||||
|
* SDD: polaris/mira/.agents/specs/mira-app/design.md §6
|
||||||
|
*/
|
||||||
|
|
||||||
|
import componentWasm from '../component.wasm' assert { type: 'webassembly' };
|
||||||
|
import { Hono } from 'hono';
|
||||||
|
import { cors } from 'hono/cors';
|
||||||
|
import { createWasiShim, type WasiHostFunctions } from '../../../cypher-executor/src/lib/wasi-shim';
|
||||||
|
|
||||||
|
const app = new Hono();
|
||||||
|
app.use('*', cors());
|
||||||
|
|
||||||
|
app.get('/', (c) => c.json({ ok: true, component: 'kbdb_create_block' }));
|
||||||
|
|
||||||
|
app.post('/', async (c) => {
|
||||||
|
let input: unknown;
|
||||||
|
try {
|
||||||
|
input = await c.req.json();
|
||||||
|
} catch {
|
||||||
|
return c.json({ success: false, error: 'request body must be JSON' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await runWasm(input);
|
||||||
|
return c.json(result);
|
||||||
|
} catch (e) {
|
||||||
|
return c.json(
|
||||||
|
{ success: false, error: e instanceof Error ? e.message : String(e) },
|
||||||
|
500,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default app;
|
||||||
|
|
||||||
|
async function runWasm(input: unknown): Promise<unknown> {
|
||||||
|
const hostFunctions: WasiHostFunctions = {
|
||||||
|
http_request: async (url, method, headersJson, body) => {
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
if (headersJson) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(headersJson);
|
||||||
|
if (parsed && typeof parsed === 'object') {
|
||||||
|
for (const [k, v] of Object.entries(parsed as Record<string, unknown>)) {
|
||||||
|
if (typeof v === 'string') headers[k] = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
const init: RequestInit = { method, headers };
|
||||||
|
if (body && method.toUpperCase() !== 'GET' && method.toUpperCase() !== 'HEAD') {
|
||||||
|
init.body = body;
|
||||||
|
}
|
||||||
|
const res = await fetch(url, init);
|
||||||
|
return await res.text();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const shim = createWasiShim(JSON.stringify(input), hostFunctions);
|
||||||
|
const instance = await WebAssembly.instantiate(
|
||||||
|
componentWasm as WebAssembly.Module,
|
||||||
|
shim.imports,
|
||||||
|
);
|
||||||
|
shim.setMemory(instance.exports.memory as WebAssembly.Memory);
|
||||||
|
await shim.run(instance);
|
||||||
|
|
||||||
|
const stdout = shim.getStdout().trim();
|
||||||
|
const stderr = shim.getStderr().trim();
|
||||||
|
if (stderr) console.error('[kbdb_create_block wasm stderr]', stderr);
|
||||||
|
if (!stdout) throw new Error('WASM component produced no output');
|
||||||
|
return JSON.parse(stdout);
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ES2022",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"types": ["@cloudflare/workers-types"],
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
name = "arcrun-kbdb-create-block"
|
||||||
|
main = "src/index.ts"
|
||||||
|
compatibility_date = "2025-02-19"
|
||||||
|
compatibility_flags = ["nodejs_compat"]
|
||||||
|
|
||||||
|
[vars]
|
||||||
|
COMPONENT_ID = "kbdb_create_block"
|
||||||
|
|
||||||
|
[[routes]]
|
||||||
|
pattern = "kbdb-create-block.arcrun.dev/*"
|
||||||
|
zone_name = "arcrun.dev"
|
||||||
+1539
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"name": "arcrun-kbdb-ingest",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"dependencies": {
|
||||||
|
"hono": "^4.7.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@cloudflare/workers-types": "^4.20250408.0",
|
||||||
|
"typescript": "^5.4.0",
|
||||||
|
"wrangler": "^4.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
/**
|
||||||
|
* arcrun WASM 零件 Worker (kbdb_ingest)
|
||||||
|
*
|
||||||
|
* POST / → JSON input → WASM (WASI preview1 stdin/stdout) → JSON output
|
||||||
|
*
|
||||||
|
* host function: http_request(用於呼叫 KBDB API)
|
||||||
|
* SDD: polaris/mira/.agents/specs/mira-app/design.md §6(五個 P0 零件)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import componentWasm from '../component.wasm' assert { type: 'webassembly' };
|
||||||
|
import { Hono } from 'hono';
|
||||||
|
import { cors } from 'hono/cors';
|
||||||
|
import { createWasiShim, type WasiHostFunctions } from '../../../cypher-executor/src/lib/wasi-shim';
|
||||||
|
|
||||||
|
const app = new Hono();
|
||||||
|
app.use('*', cors());
|
||||||
|
|
||||||
|
app.get('/', (c) => c.json({ ok: true, component: 'kbdb_ingest' }));
|
||||||
|
|
||||||
|
app.post('/', async (c) => {
|
||||||
|
let input: unknown;
|
||||||
|
try {
|
||||||
|
input = await c.req.json();
|
||||||
|
} catch {
|
||||||
|
return c.json({ success: false, error: 'request body must be JSON' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await runWasm(input);
|
||||||
|
return c.json(result);
|
||||||
|
} catch (e) {
|
||||||
|
return c.json(
|
||||||
|
{ success: false, error: e instanceof Error ? e.message : String(e) },
|
||||||
|
500,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default app;
|
||||||
|
|
||||||
|
// ── WASM runner ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function runWasm(input: unknown): Promise<unknown> {
|
||||||
|
const hostFunctions: WasiHostFunctions = {
|
||||||
|
http_request: async (url, method, headersJson, body) => {
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
if (headersJson) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(headersJson);
|
||||||
|
if (parsed && typeof parsed === 'object') {
|
||||||
|
for (const [k, v] of Object.entries(parsed as Record<string, unknown>)) {
|
||||||
|
if (typeof v === 'string') headers[k] = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore header parse errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const init: RequestInit = { method, headers };
|
||||||
|
if (body && method.toUpperCase() !== 'GET' && method.toUpperCase() !== 'HEAD') {
|
||||||
|
init.body = body;
|
||||||
|
}
|
||||||
|
const res = await fetch(url, init);
|
||||||
|
return await res.text();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const shim = createWasiShim(JSON.stringify(input), hostFunctions);
|
||||||
|
|
||||||
|
const instance = await WebAssembly.instantiate(
|
||||||
|
componentWasm as WebAssembly.Module,
|
||||||
|
shim.imports,
|
||||||
|
);
|
||||||
|
shim.setMemory(instance.exports.memory as WebAssembly.Memory);
|
||||||
|
await shim.run(instance);
|
||||||
|
|
||||||
|
const stdout = shim.getStdout().trim();
|
||||||
|
const stderr = shim.getStderr().trim();
|
||||||
|
if (stderr) console.error('[kbdb_ingest wasm stderr]', stderr);
|
||||||
|
if (!stdout) throw new Error('WASM component produced no output');
|
||||||
|
return JSON.parse(stdout);
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ES2022",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"types": ["@cloudflare/workers-types"],
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
name = "arcrun-kbdb-ingest"
|
||||||
|
main = "src/index.ts"
|
||||||
|
compatibility_date = "2025-02-19"
|
||||||
|
compatibility_flags = ["nodejs_compat"]
|
||||||
|
|
||||||
|
[vars]
|
||||||
|
COMPONENT_ID = "kbdb_ingest"
|
||||||
|
|
||||||
|
[[routes]]
|
||||||
|
pattern = "kbdb-ingest.arcrun.dev/*"
|
||||||
|
zone_name = "arcrun.dev"
|
||||||
+1539
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"name": "arcrun-kbdb-patch-block",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"dependencies": {
|
||||||
|
"hono": "^4.7.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@cloudflare/workers-types": "^4.20250408.0",
|
||||||
|
"typescript": "^5.4.0",
|
||||||
|
"wrangler": "^4.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
/**
|
||||||
|
* arcrun WASM 零件 Worker (kbdb_patch_block)
|
||||||
|
*
|
||||||
|
* POST / → JSON input → WASM (WASI preview1 stdin/stdout) → JSON output
|
||||||
|
*
|
||||||
|
* host function: http_request(用於呼叫 KBDB PATCH /blocks/:id)
|
||||||
|
* SDD: polaris/mira/.agents/specs/mira-app/design.md §6(五個 P0 零件)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import componentWasm from '../component.wasm' assert { type: 'webassembly' };
|
||||||
|
import { Hono } from 'hono';
|
||||||
|
import { cors } from 'hono/cors';
|
||||||
|
import { createWasiShim, type WasiHostFunctions } from '../../../cypher-executor/src/lib/wasi-shim';
|
||||||
|
|
||||||
|
const app = new Hono();
|
||||||
|
app.use('*', cors());
|
||||||
|
|
||||||
|
app.get('/', (c) => c.json({ ok: true, component: 'kbdb_patch_block' }));
|
||||||
|
|
||||||
|
app.post('/', async (c) => {
|
||||||
|
let input: unknown;
|
||||||
|
try {
|
||||||
|
input = await c.req.json();
|
||||||
|
} catch {
|
||||||
|
return c.json({ success: false, error: 'request body must be JSON' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await runWasm(input);
|
||||||
|
return c.json(result);
|
||||||
|
} catch (e) {
|
||||||
|
return c.json(
|
||||||
|
{ success: false, error: e instanceof Error ? e.message : String(e) },
|
||||||
|
500,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default app;
|
||||||
|
|
||||||
|
async function runWasm(input: unknown): Promise<unknown> {
|
||||||
|
const hostFunctions: WasiHostFunctions = {
|
||||||
|
http_request: async (url, method, headersJson, body) => {
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
if (headersJson) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(headersJson);
|
||||||
|
if (parsed && typeof parsed === 'object') {
|
||||||
|
for (const [k, v] of Object.entries(parsed as Record<string, unknown>)) {
|
||||||
|
if (typeof v === 'string') headers[k] = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore header parse errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const init: RequestInit = { method, headers };
|
||||||
|
if (body && method.toUpperCase() !== 'GET' && method.toUpperCase() !== 'HEAD') {
|
||||||
|
init.body = body;
|
||||||
|
}
|
||||||
|
const res = await fetch(url, init);
|
||||||
|
return await res.text();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const shim = createWasiShim(JSON.stringify(input), hostFunctions);
|
||||||
|
|
||||||
|
const instance = await WebAssembly.instantiate(
|
||||||
|
componentWasm as WebAssembly.Module,
|
||||||
|
shim.imports,
|
||||||
|
);
|
||||||
|
shim.setMemory(instance.exports.memory as WebAssembly.Memory);
|
||||||
|
await shim.run(instance);
|
||||||
|
|
||||||
|
const stdout = shim.getStdout().trim();
|
||||||
|
const stderr = shim.getStderr().trim();
|
||||||
|
if (stderr) console.error('[kbdb_patch_block wasm stderr]', stderr);
|
||||||
|
if (!stdout) throw new Error('WASM component produced no output');
|
||||||
|
return JSON.parse(stdout);
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ES2022",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"types": ["@cloudflare/workers-types"],
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
name = "arcrun-kbdb-patch-block"
|
||||||
|
main = "src/index.ts"
|
||||||
|
compatibility_date = "2025-02-19"
|
||||||
|
compatibility_flags = ["nodejs_compat"]
|
||||||
|
|
||||||
|
[vars]
|
||||||
|
COMPONENT_ID = "kbdb_patch_block"
|
||||||
|
|
||||||
|
[[routes]]
|
||||||
|
pattern = "kbdb-patch-block.arcrun.dev/*"
|
||||||
|
zone_name = "arcrun.dev"
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"name": "arcrun-km-writer",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"dependencies": {
|
||||||
|
"hono": "^4.7.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@cloudflare/workers-types": "^4.20250408.0",
|
||||||
|
"typescript": "^5.4.0",
|
||||||
|
"wrangler": "^4.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
+898
@@ -0,0 +1,898 @@
|
|||||||
|
lockfileVersion: '9.0'
|
||||||
|
|
||||||
|
settings:
|
||||||
|
autoInstallPeers: true
|
||||||
|
excludeLinksFromLockfile: false
|
||||||
|
|
||||||
|
importers:
|
||||||
|
|
||||||
|
.:
|
||||||
|
dependencies:
|
||||||
|
hono:
|
||||||
|
specifier: ^4.7.0
|
||||||
|
version: 4.12.14
|
||||||
|
devDependencies:
|
||||||
|
'@cloudflare/workers-types':
|
||||||
|
specifier: ^4.20250408.0
|
||||||
|
version: 4.20260420.1
|
||||||
|
typescript:
|
||||||
|
specifier: ^5.4.0
|
||||||
|
version: 5.9.3
|
||||||
|
wrangler:
|
||||||
|
specifier: ^4.0.0
|
||||||
|
version: 4.83.0(@cloudflare/workers-types@4.20260420.1)
|
||||||
|
|
||||||
|
packages:
|
||||||
|
|
||||||
|
'@cloudflare/kv-asset-handler@0.4.2':
|
||||||
|
resolution: {integrity: sha512-SIOD2DxrRRwQ+jgzlXCqoEFiKOFqaPjhnNTGKXSRLvp1HiOvapLaFG2kEr9dYQTYe8rKrd9uvDUzmAITeNyaHQ==}
|
||||||
|
engines: {node: '>=18.0.0'}
|
||||||
|
|
||||||
|
'@cloudflare/unenv-preset@2.16.0':
|
||||||
|
resolution: {integrity: sha512-8ovsRpwzPoEqPUzoErAYVv8l3FMZNeBVQfJTvtzP4AgLSRGZISRfuChFxHWUQd3n6cnrwkuTGxT+2cGo8EsyYg==}
|
||||||
|
peerDependencies:
|
||||||
|
unenv: 2.0.0-rc.24
|
||||||
|
workerd: 1.20260301.1 || ~1.20260302.1 || ~1.20260303.1 || ~1.20260304.1 || >1.20260305.0 <2.0.0-0
|
||||||
|
peerDependenciesMeta:
|
||||||
|
workerd:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@cloudflare/workerd-darwin-64@1.20260415.1':
|
||||||
|
resolution: {integrity: sha512-dsxaKsQm3LnPGNPEdsRv09QN3Y4DqCw7kX5j6noKqbAtro2jTr95sVlYM1jUxZ5FkOl1f7SXgaKKB9t5H5Nkbg==}
|
||||||
|
engines: {node: '>=16'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@cloudflare/workerd-darwin-arm64@1.20260415.1':
|
||||||
|
resolution: {integrity: sha512-+JgSgVA49KyKteHRA1SnonE4Zn5Ei5zdAp5FQMxFmXI8qulZw4Hl7safXxRyK4i9sTO8gl7TFOKO5Q64VPvSDQ==}
|
||||||
|
engines: {node: '>=16'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@cloudflare/workerd-linux-64@1.20260415.1':
|
||||||
|
resolution: {integrity: sha512-tU+9pwsqCy8afOVlGtiWrWQc/fedQK4SRm4KPIAt+zOiQWDxWASm6YGBUJis5c648WN80yz47qnmdDi8DQNOcA==}
|
||||||
|
engines: {node: '>=16'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@cloudflare/workerd-linux-arm64@1.20260415.1':
|
||||||
|
resolution: {integrity: sha512-bR9uITnV19r5NQ14xnypi2xHXu2iQvfYV8cVgx0JouFUmWwTEEAwFVojDdssGq93VHX9hr/pi2IRUZeegbYBog==}
|
||||||
|
engines: {node: '>=16'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@cloudflare/workerd-windows-64@1.20260415.1':
|
||||||
|
resolution: {integrity: sha512-4NuMLlerI0Ijua3Ir8HXQ+qyNvCUDEG5gDco5Om+sAiK6rnWiz+aGoSlbB8W16yW9QAgzCstbmXLiVknUBflfQ==}
|
||||||
|
engines: {node: '>=16'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@cloudflare/workers-types@4.20260420.1':
|
||||||
|
resolution: {integrity: sha512-DHT9JnSn9cIiCSdL76OxW+Xvc1+ml1CWzWvgVwreoHQ+E604aeFxPPHp9X7nE+XRWm2NH4l0OgtxUI5T/nuI3g==}
|
||||||
|
|
||||||
|
'@cspotcode/source-map-support@0.8.1':
|
||||||
|
resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
'@emnapi/runtime@1.10.0':
|
||||||
|
resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==}
|
||||||
|
|
||||||
|
'@esbuild/aix-ppc64@0.27.3':
|
||||||
|
resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [ppc64]
|
||||||
|
os: [aix]
|
||||||
|
|
||||||
|
'@esbuild/android-arm64@0.27.3':
|
||||||
|
resolution: {integrity: sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [android]
|
||||||
|
|
||||||
|
'@esbuild/android-arm@0.27.3':
|
||||||
|
resolution: {integrity: sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [android]
|
||||||
|
|
||||||
|
'@esbuild/android-x64@0.27.3':
|
||||||
|
resolution: {integrity: sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [android]
|
||||||
|
|
||||||
|
'@esbuild/darwin-arm64@0.27.3':
|
||||||
|
resolution: {integrity: sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@esbuild/darwin-x64@0.27.3':
|
||||||
|
resolution: {integrity: sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@esbuild/freebsd-arm64@0.27.3':
|
||||||
|
resolution: {integrity: sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [freebsd]
|
||||||
|
|
||||||
|
'@esbuild/freebsd-x64@0.27.3':
|
||||||
|
resolution: {integrity: sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [freebsd]
|
||||||
|
|
||||||
|
'@esbuild/linux-arm64@0.27.3':
|
||||||
|
resolution: {integrity: sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@esbuild/linux-arm@0.27.3':
|
||||||
|
resolution: {integrity: sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@esbuild/linux-ia32@0.27.3':
|
||||||
|
resolution: {integrity: sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [ia32]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@esbuild/linux-loong64@0.27.3':
|
||||||
|
resolution: {integrity: sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [loong64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@esbuild/linux-mips64el@0.27.3':
|
||||||
|
resolution: {integrity: sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [mips64el]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@esbuild/linux-ppc64@0.27.3':
|
||||||
|
resolution: {integrity: sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [ppc64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@esbuild/linux-riscv64@0.27.3':
|
||||||
|
resolution: {integrity: sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [riscv64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@esbuild/linux-s390x@0.27.3':
|
||||||
|
resolution: {integrity: sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [s390x]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@esbuild/linux-x64@0.27.3':
|
||||||
|
resolution: {integrity: sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@esbuild/netbsd-arm64@0.27.3':
|
||||||
|
resolution: {integrity: sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [netbsd]
|
||||||
|
|
||||||
|
'@esbuild/netbsd-x64@0.27.3':
|
||||||
|
resolution: {integrity: sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [netbsd]
|
||||||
|
|
||||||
|
'@esbuild/openbsd-arm64@0.27.3':
|
||||||
|
resolution: {integrity: sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [openbsd]
|
||||||
|
|
||||||
|
'@esbuild/openbsd-x64@0.27.3':
|
||||||
|
resolution: {integrity: sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [openbsd]
|
||||||
|
|
||||||
|
'@esbuild/openharmony-arm64@0.27.3':
|
||||||
|
resolution: {integrity: sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [openharmony]
|
||||||
|
|
||||||
|
'@esbuild/sunos-x64@0.27.3':
|
||||||
|
resolution: {integrity: sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [sunos]
|
||||||
|
|
||||||
|
'@esbuild/win32-arm64@0.27.3':
|
||||||
|
resolution: {integrity: sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@esbuild/win32-ia32@0.27.3':
|
||||||
|
resolution: {integrity: sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [ia32]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@esbuild/win32-x64@0.27.3':
|
||||||
|
resolution: {integrity: sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@img/colour@1.1.0':
|
||||||
|
resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
'@img/sharp-darwin-arm64@0.34.5':
|
||||||
|
resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==}
|
||||||
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@img/sharp-darwin-x64@0.34.5':
|
||||||
|
resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==}
|
||||||
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@img/sharp-libvips-darwin-arm64@1.2.4':
|
||||||
|
resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@img/sharp-libvips-darwin-x64@1.2.4':
|
||||||
|
resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linux-arm64@1.2.4':
|
||||||
|
resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linux-arm@1.2.4':
|
||||||
|
resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linux-ppc64@1.2.4':
|
||||||
|
resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
|
||||||
|
cpu: [ppc64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linux-riscv64@1.2.4':
|
||||||
|
resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
|
||||||
|
cpu: [riscv64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linux-s390x@1.2.4':
|
||||||
|
resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
|
||||||
|
cpu: [s390x]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linux-x64@1.2.4':
|
||||||
|
resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
|
||||||
|
resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
|
||||||
|
resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
|
'@img/sharp-linux-arm64@0.34.5':
|
||||||
|
resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
|
||||||
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@img/sharp-linux-arm@0.34.5':
|
||||||
|
resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
|
||||||
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@img/sharp-linux-ppc64@0.34.5':
|
||||||
|
resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==}
|
||||||
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
|
cpu: [ppc64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@img/sharp-linux-riscv64@0.34.5':
|
||||||
|
resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==}
|
||||||
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
|
cpu: [riscv64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@img/sharp-linux-s390x@0.34.5':
|
||||||
|
resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==}
|
||||||
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
|
cpu: [s390x]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@img/sharp-linux-x64@0.34.5':
|
||||||
|
resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
|
||||||
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@img/sharp-linuxmusl-arm64@0.34.5':
|
||||||
|
resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
|
||||||
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
|
'@img/sharp-linuxmusl-x64@0.34.5':
|
||||||
|
resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
|
||||||
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
|
'@img/sharp-wasm32@0.34.5':
|
||||||
|
resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==}
|
||||||
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
|
cpu: [wasm32]
|
||||||
|
|
||||||
|
'@img/sharp-win32-arm64@0.34.5':
|
||||||
|
resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==}
|
||||||
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@img/sharp-win32-ia32@0.34.5':
|
||||||
|
resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==}
|
||||||
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
|
cpu: [ia32]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@img/sharp-win32-x64@0.34.5':
|
||||||
|
resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==}
|
||||||
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@jridgewell/resolve-uri@3.1.2':
|
||||||
|
resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
|
||||||
|
engines: {node: '>=6.0.0'}
|
||||||
|
|
||||||
|
'@jridgewell/sourcemap-codec@1.5.5':
|
||||||
|
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
|
||||||
|
|
||||||
|
'@jridgewell/trace-mapping@0.3.9':
|
||||||
|
resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==}
|
||||||
|
|
||||||
|
'@poppinss/colors@4.1.6':
|
||||||
|
resolution: {integrity: sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==}
|
||||||
|
|
||||||
|
'@poppinss/dumper@0.6.5':
|
||||||
|
resolution: {integrity: sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==}
|
||||||
|
|
||||||
|
'@poppinss/exception@1.2.3':
|
||||||
|
resolution: {integrity: sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==}
|
||||||
|
|
||||||
|
'@sindresorhus/is@7.2.0':
|
||||||
|
resolution: {integrity: sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
'@speed-highlight/core@1.2.15':
|
||||||
|
resolution: {integrity: sha512-BMq1K3DsElxDWawkX6eLg9+CKJrTVGCBAWVuHXVUV2u0s2711qiChLSId6ikYPfxhdYocLNt3wWwSvDiTvFabw==}
|
||||||
|
|
||||||
|
blake3-wasm@2.1.5:
|
||||||
|
resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==}
|
||||||
|
|
||||||
|
cookie@1.1.1:
|
||||||
|
resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
detect-libc@2.1.2:
|
||||||
|
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
error-stack-parser-es@1.0.5:
|
||||||
|
resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==}
|
||||||
|
|
||||||
|
esbuild@0.27.3:
|
||||||
|
resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
|
fsevents@2.3.3:
|
||||||
|
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
||||||
|
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
hono@4.12.14:
|
||||||
|
resolution: {integrity: sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w==}
|
||||||
|
engines: {node: '>=16.9.0'}
|
||||||
|
|
||||||
|
kleur@4.1.5:
|
||||||
|
resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==}
|
||||||
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
|
miniflare@4.20260415.0:
|
||||||
|
resolution: {integrity: sha512-JoExRWN4YBI2luA5BoSMFEgi8rQWXUGzo3mtE+58VXCLV3jj/Xnk5Yeqs/IXWz8Es5GJIaq6BtsixDvAxXSIng==}
|
||||||
|
engines: {node: '>=18.0.0'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
|
path-to-regexp@6.3.0:
|
||||||
|
resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==}
|
||||||
|
|
||||||
|
pathe@2.0.3:
|
||||||
|
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
|
||||||
|
|
||||||
|
semver@7.7.4:
|
||||||
|
resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==}
|
||||||
|
engines: {node: '>=10'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
|
sharp@0.34.5:
|
||||||
|
resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==}
|
||||||
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
|
|
||||||
|
supports-color@10.2.2:
|
||||||
|
resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
tslib@2.8.1:
|
||||||
|
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
||||||
|
|
||||||
|
typescript@5.9.3:
|
||||||
|
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
|
||||||
|
engines: {node: '>=14.17'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
|
undici@7.24.8:
|
||||||
|
resolution: {integrity: sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ==}
|
||||||
|
engines: {node: '>=20.18.1'}
|
||||||
|
|
||||||
|
unenv@2.0.0-rc.24:
|
||||||
|
resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==}
|
||||||
|
|
||||||
|
workerd@1.20260415.1:
|
||||||
|
resolution: {integrity: sha512-phyPjRnx+mQDfkhN9ENPioL1L0SdhYs4S0YmJK/xF9Oga+ykNfdSy1MHnsOj8yqnOV96zcVQMx32dJ0r3pq0jQ==}
|
||||||
|
engines: {node: '>=16'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
|
wrangler@4.83.0:
|
||||||
|
resolution: {integrity: sha512-gw5g3LCiuAqVWxaoKY6+quE0HzAUEFb/FV3oAlNkE1ttd4XP3FiV91XDkkzUCcdqxS4WjhQvPhIDBNdhEi8P0A==}
|
||||||
|
engines: {node: '>=20.3.0'}
|
||||||
|
hasBin: true
|
||||||
|
peerDependencies:
|
||||||
|
'@cloudflare/workers-types': ^4.20260415.1
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@cloudflare/workers-types':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
ws@8.18.0:
|
||||||
|
resolution: {integrity: sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==}
|
||||||
|
engines: {node: '>=10.0.0'}
|
||||||
|
peerDependencies:
|
||||||
|
bufferutil: ^4.0.1
|
||||||
|
utf-8-validate: '>=5.0.2'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
bufferutil:
|
||||||
|
optional: true
|
||||||
|
utf-8-validate:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
youch-core@0.3.3:
|
||||||
|
resolution: {integrity: sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==}
|
||||||
|
|
||||||
|
youch@4.1.0-beta.10:
|
||||||
|
resolution: {integrity: sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==}
|
||||||
|
|
||||||
|
snapshots:
|
||||||
|
|
||||||
|
'@cloudflare/kv-asset-handler@0.4.2': {}
|
||||||
|
|
||||||
|
'@cloudflare/unenv-preset@2.16.0(unenv@2.0.0-rc.24)(workerd@1.20260415.1)':
|
||||||
|
dependencies:
|
||||||
|
unenv: 2.0.0-rc.24
|
||||||
|
optionalDependencies:
|
||||||
|
workerd: 1.20260415.1
|
||||||
|
|
||||||
|
'@cloudflare/workerd-darwin-64@1.20260415.1':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@cloudflare/workerd-darwin-arm64@1.20260415.1':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@cloudflare/workerd-linux-64@1.20260415.1':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@cloudflare/workerd-linux-arm64@1.20260415.1':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@cloudflare/workerd-windows-64@1.20260415.1':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@cloudflare/workers-types@4.20260420.1': {}
|
||||||
|
|
||||||
|
'@cspotcode/source-map-support@0.8.1':
|
||||||
|
dependencies:
|
||||||
|
'@jridgewell/trace-mapping': 0.3.9
|
||||||
|
|
||||||
|
'@emnapi/runtime@1.10.0':
|
||||||
|
dependencies:
|
||||||
|
tslib: 2.8.1
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/aix-ppc64@0.27.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/android-arm64@0.27.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/android-arm@0.27.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/android-x64@0.27.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/darwin-arm64@0.27.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/darwin-x64@0.27.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/freebsd-arm64@0.27.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/freebsd-x64@0.27.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/linux-arm64@0.27.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/linux-arm@0.27.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/linux-ia32@0.27.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/linux-loong64@0.27.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/linux-mips64el@0.27.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/linux-ppc64@0.27.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/linux-riscv64@0.27.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/linux-s390x@0.27.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/linux-x64@0.27.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/netbsd-arm64@0.27.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/netbsd-x64@0.27.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/openbsd-arm64@0.27.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/openbsd-x64@0.27.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/openharmony-arm64@0.27.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/sunos-x64@0.27.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/win32-arm64@0.27.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/win32-ia32@0.27.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/win32-x64@0.27.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/colour@1.1.0': {}
|
||||||
|
|
||||||
|
'@img/sharp-darwin-arm64@0.34.5':
|
||||||
|
optionalDependencies:
|
||||||
|
'@img/sharp-libvips-darwin-arm64': 1.2.4
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-darwin-x64@0.34.5':
|
||||||
|
optionalDependencies:
|
||||||
|
'@img/sharp-libvips-darwin-x64': 1.2.4
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-libvips-darwin-arm64@1.2.4':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-libvips-darwin-x64@1.2.4':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linux-arm64@1.2.4':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linux-arm@1.2.4':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linux-ppc64@1.2.4':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linux-riscv64@1.2.4':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linux-s390x@1.2.4':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linux-x64@1.2.4':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-linux-arm64@0.34.5':
|
||||||
|
optionalDependencies:
|
||||||
|
'@img/sharp-libvips-linux-arm64': 1.2.4
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-linux-arm@0.34.5':
|
||||||
|
optionalDependencies:
|
||||||
|
'@img/sharp-libvips-linux-arm': 1.2.4
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-linux-ppc64@0.34.5':
|
||||||
|
optionalDependencies:
|
||||||
|
'@img/sharp-libvips-linux-ppc64': 1.2.4
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-linux-riscv64@0.34.5':
|
||||||
|
optionalDependencies:
|
||||||
|
'@img/sharp-libvips-linux-riscv64': 1.2.4
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-linux-s390x@0.34.5':
|
||||||
|
optionalDependencies:
|
||||||
|
'@img/sharp-libvips-linux-s390x': 1.2.4
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-linux-x64@0.34.5':
|
||||||
|
optionalDependencies:
|
||||||
|
'@img/sharp-libvips-linux-x64': 1.2.4
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-linuxmusl-arm64@0.34.5':
|
||||||
|
optionalDependencies:
|
||||||
|
'@img/sharp-libvips-linuxmusl-arm64': 1.2.4
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-linuxmusl-x64@0.34.5':
|
||||||
|
optionalDependencies:
|
||||||
|
'@img/sharp-libvips-linuxmusl-x64': 1.2.4
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-wasm32@0.34.5':
|
||||||
|
dependencies:
|
||||||
|
'@emnapi/runtime': 1.10.0
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-win32-arm64@0.34.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-win32-ia32@0.34.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-win32-x64@0.34.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@jridgewell/resolve-uri@3.1.2': {}
|
||||||
|
|
||||||
|
'@jridgewell/sourcemap-codec@1.5.5': {}
|
||||||
|
|
||||||
|
'@jridgewell/trace-mapping@0.3.9':
|
||||||
|
dependencies:
|
||||||
|
'@jridgewell/resolve-uri': 3.1.2
|
||||||
|
'@jridgewell/sourcemap-codec': 1.5.5
|
||||||
|
|
||||||
|
'@poppinss/colors@4.1.6':
|
||||||
|
dependencies:
|
||||||
|
kleur: 4.1.5
|
||||||
|
|
||||||
|
'@poppinss/dumper@0.6.5':
|
||||||
|
dependencies:
|
||||||
|
'@poppinss/colors': 4.1.6
|
||||||
|
'@sindresorhus/is': 7.2.0
|
||||||
|
supports-color: 10.2.2
|
||||||
|
|
||||||
|
'@poppinss/exception@1.2.3': {}
|
||||||
|
|
||||||
|
'@sindresorhus/is@7.2.0': {}
|
||||||
|
|
||||||
|
'@speed-highlight/core@1.2.15': {}
|
||||||
|
|
||||||
|
blake3-wasm@2.1.5: {}
|
||||||
|
|
||||||
|
cookie@1.1.1: {}
|
||||||
|
|
||||||
|
detect-libc@2.1.2: {}
|
||||||
|
|
||||||
|
error-stack-parser-es@1.0.5: {}
|
||||||
|
|
||||||
|
esbuild@0.27.3:
|
||||||
|
optionalDependencies:
|
||||||
|
'@esbuild/aix-ppc64': 0.27.3
|
||||||
|
'@esbuild/android-arm': 0.27.3
|
||||||
|
'@esbuild/android-arm64': 0.27.3
|
||||||
|
'@esbuild/android-x64': 0.27.3
|
||||||
|
'@esbuild/darwin-arm64': 0.27.3
|
||||||
|
'@esbuild/darwin-x64': 0.27.3
|
||||||
|
'@esbuild/freebsd-arm64': 0.27.3
|
||||||
|
'@esbuild/freebsd-x64': 0.27.3
|
||||||
|
'@esbuild/linux-arm': 0.27.3
|
||||||
|
'@esbuild/linux-arm64': 0.27.3
|
||||||
|
'@esbuild/linux-ia32': 0.27.3
|
||||||
|
'@esbuild/linux-loong64': 0.27.3
|
||||||
|
'@esbuild/linux-mips64el': 0.27.3
|
||||||
|
'@esbuild/linux-ppc64': 0.27.3
|
||||||
|
'@esbuild/linux-riscv64': 0.27.3
|
||||||
|
'@esbuild/linux-s390x': 0.27.3
|
||||||
|
'@esbuild/linux-x64': 0.27.3
|
||||||
|
'@esbuild/netbsd-arm64': 0.27.3
|
||||||
|
'@esbuild/netbsd-x64': 0.27.3
|
||||||
|
'@esbuild/openbsd-arm64': 0.27.3
|
||||||
|
'@esbuild/openbsd-x64': 0.27.3
|
||||||
|
'@esbuild/openharmony-arm64': 0.27.3
|
||||||
|
'@esbuild/sunos-x64': 0.27.3
|
||||||
|
'@esbuild/win32-arm64': 0.27.3
|
||||||
|
'@esbuild/win32-ia32': 0.27.3
|
||||||
|
'@esbuild/win32-x64': 0.27.3
|
||||||
|
|
||||||
|
fsevents@2.3.3:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
hono@4.12.14: {}
|
||||||
|
|
||||||
|
kleur@4.1.5: {}
|
||||||
|
|
||||||
|
miniflare@4.20260415.0:
|
||||||
|
dependencies:
|
||||||
|
'@cspotcode/source-map-support': 0.8.1
|
||||||
|
sharp: 0.34.5
|
||||||
|
undici: 7.24.8
|
||||||
|
workerd: 1.20260415.1
|
||||||
|
ws: 8.18.0
|
||||||
|
youch: 4.1.0-beta.10
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- bufferutil
|
||||||
|
- utf-8-validate
|
||||||
|
|
||||||
|
path-to-regexp@6.3.0: {}
|
||||||
|
|
||||||
|
pathe@2.0.3: {}
|
||||||
|
|
||||||
|
semver@7.7.4: {}
|
||||||
|
|
||||||
|
sharp@0.34.5:
|
||||||
|
dependencies:
|
||||||
|
'@img/colour': 1.1.0
|
||||||
|
detect-libc: 2.1.2
|
||||||
|
semver: 7.7.4
|
||||||
|
optionalDependencies:
|
||||||
|
'@img/sharp-darwin-arm64': 0.34.5
|
||||||
|
'@img/sharp-darwin-x64': 0.34.5
|
||||||
|
'@img/sharp-libvips-darwin-arm64': 1.2.4
|
||||||
|
'@img/sharp-libvips-darwin-x64': 1.2.4
|
||||||
|
'@img/sharp-libvips-linux-arm': 1.2.4
|
||||||
|
'@img/sharp-libvips-linux-arm64': 1.2.4
|
||||||
|
'@img/sharp-libvips-linux-ppc64': 1.2.4
|
||||||
|
'@img/sharp-libvips-linux-riscv64': 1.2.4
|
||||||
|
'@img/sharp-libvips-linux-s390x': 1.2.4
|
||||||
|
'@img/sharp-libvips-linux-x64': 1.2.4
|
||||||
|
'@img/sharp-libvips-linuxmusl-arm64': 1.2.4
|
||||||
|
'@img/sharp-libvips-linuxmusl-x64': 1.2.4
|
||||||
|
'@img/sharp-linux-arm': 0.34.5
|
||||||
|
'@img/sharp-linux-arm64': 0.34.5
|
||||||
|
'@img/sharp-linux-ppc64': 0.34.5
|
||||||
|
'@img/sharp-linux-riscv64': 0.34.5
|
||||||
|
'@img/sharp-linux-s390x': 0.34.5
|
||||||
|
'@img/sharp-linux-x64': 0.34.5
|
||||||
|
'@img/sharp-linuxmusl-arm64': 0.34.5
|
||||||
|
'@img/sharp-linuxmusl-x64': 0.34.5
|
||||||
|
'@img/sharp-wasm32': 0.34.5
|
||||||
|
'@img/sharp-win32-arm64': 0.34.5
|
||||||
|
'@img/sharp-win32-ia32': 0.34.5
|
||||||
|
'@img/sharp-win32-x64': 0.34.5
|
||||||
|
|
||||||
|
supports-color@10.2.2: {}
|
||||||
|
|
||||||
|
tslib@2.8.1:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
typescript@5.9.3: {}
|
||||||
|
|
||||||
|
undici@7.24.8: {}
|
||||||
|
|
||||||
|
unenv@2.0.0-rc.24:
|
||||||
|
dependencies:
|
||||||
|
pathe: 2.0.3
|
||||||
|
|
||||||
|
workerd@1.20260415.1:
|
||||||
|
optionalDependencies:
|
||||||
|
'@cloudflare/workerd-darwin-64': 1.20260415.1
|
||||||
|
'@cloudflare/workerd-darwin-arm64': 1.20260415.1
|
||||||
|
'@cloudflare/workerd-linux-64': 1.20260415.1
|
||||||
|
'@cloudflare/workerd-linux-arm64': 1.20260415.1
|
||||||
|
'@cloudflare/workerd-windows-64': 1.20260415.1
|
||||||
|
|
||||||
|
wrangler@4.83.0(@cloudflare/workers-types@4.20260420.1):
|
||||||
|
dependencies:
|
||||||
|
'@cloudflare/kv-asset-handler': 0.4.2
|
||||||
|
'@cloudflare/unenv-preset': 2.16.0(unenv@2.0.0-rc.24)(workerd@1.20260415.1)
|
||||||
|
blake3-wasm: 2.1.5
|
||||||
|
esbuild: 0.27.3
|
||||||
|
miniflare: 4.20260415.0
|
||||||
|
path-to-regexp: 6.3.0
|
||||||
|
unenv: 2.0.0-rc.24
|
||||||
|
workerd: 1.20260415.1
|
||||||
|
optionalDependencies:
|
||||||
|
'@cloudflare/workers-types': 4.20260420.1
|
||||||
|
fsevents: 2.3.3
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- bufferutil
|
||||||
|
- utf-8-validate
|
||||||
|
|
||||||
|
ws@8.18.0: {}
|
||||||
|
|
||||||
|
youch-core@0.3.3:
|
||||||
|
dependencies:
|
||||||
|
'@poppinss/exception': 1.2.3
|
||||||
|
error-stack-parser-es: 1.0.5
|
||||||
|
|
||||||
|
youch@4.1.0-beta.10:
|
||||||
|
dependencies:
|
||||||
|
'@poppinss/colors': 4.1.6
|
||||||
|
'@poppinss/dumper': 0.6.5
|
||||||
|
'@speed-highlight/core': 1.2.15
|
||||||
|
cookie: 1.1.1
|
||||||
|
youch-core: 0.3.3
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
/**
|
||||||
|
* arcrun API component Worker (km_writer)
|
||||||
|
*
|
||||||
|
* POST / → JSON input → WASM (WASI preview1 stdin/stdout) → JSON output
|
||||||
|
*
|
||||||
|
* 提供 http_request host function,讓 WASM 零件呼叫 Mira /km/* API。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import componentWasm from '../component.wasm' assert { type: 'webassembly' };
|
||||||
|
import { Hono } from 'hono';
|
||||||
|
import { cors } from 'hono/cors';
|
||||||
|
import { createWasiShim, type WasiHostFunctions } from '../../../cypher-executor/src/lib/wasi-shim';
|
||||||
|
|
||||||
|
const app = new Hono();
|
||||||
|
app.use('*', cors());
|
||||||
|
|
||||||
|
app.get('/', (c) => c.json({ ok: true, component: 'km_writer' }));
|
||||||
|
|
||||||
|
app.post('/', async (c) => {
|
||||||
|
let input: unknown;
|
||||||
|
try {
|
||||||
|
input = await c.req.json();
|
||||||
|
} catch {
|
||||||
|
return c.json({ success: false, error: 'request body must be JSON' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await runWasm(input);
|
||||||
|
return c.json(result);
|
||||||
|
} catch (e) {
|
||||||
|
return c.json(
|
||||||
|
{ success: false, error: e instanceof Error ? e.message : String(e) },
|
||||||
|
500,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default app;
|
||||||
|
|
||||||
|
async function runWasm(input: unknown): Promise<unknown> {
|
||||||
|
const hostFunctions: WasiHostFunctions = {
|
||||||
|
http_request: async (url, method, headersJson, body) => {
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
if (headersJson) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(headersJson);
|
||||||
|
if (parsed && typeof parsed === 'object') {
|
||||||
|
for (const [k, v] of Object.entries(parsed as Record<string, unknown>)) {
|
||||||
|
if (typeof v === 'string') headers[k] = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore header parse errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const init: RequestInit = { method, headers };
|
||||||
|
if (body && method.toUpperCase() !== 'GET' && method.toUpperCase() !== 'HEAD') {
|
||||||
|
init.body = body;
|
||||||
|
}
|
||||||
|
const res = await fetch(url, init);
|
||||||
|
return await res.text();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const shim = createWasiShim(JSON.stringify(input), hostFunctions);
|
||||||
|
|
||||||
|
const instance = await WebAssembly.instantiate(
|
||||||
|
componentWasm as WebAssembly.Module,
|
||||||
|
shim.imports,
|
||||||
|
);
|
||||||
|
shim.setMemory(instance.exports.memory as WebAssembly.Memory);
|
||||||
|
await shim.run(instance);
|
||||||
|
|
||||||
|
const stdout = shim.getStdout().trim();
|
||||||
|
if (!stdout) throw new Error('WASM component produced no output');
|
||||||
|
return JSON.parse(stdout);
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ES2022",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"types": ["@cloudflare/workers-types"],
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
name = "arcrun-km-writer"
|
||||||
|
main = "src/index.ts"
|
||||||
|
compatibility_date = "2025-02-19"
|
||||||
|
compatibility_flags = ["nodejs_compat"]
|
||||||
|
|
||||||
|
[vars]
|
||||||
|
COMPONENT_ID = "km_writer"
|
||||||
|
|
||||||
|
[[routes]]
|
||||||
|
pattern = "km-writer.arcrun.dev/*"
|
||||||
|
zone_name = "arcrun.dev"
|
||||||
@@ -77,17 +77,7 @@ async function runWasm(input: unknown): Promise<unknown> {
|
|||||||
shim.imports,
|
shim.imports,
|
||||||
);
|
);
|
||||||
shim.setMemory(instance.exports.memory as WebAssembly.Memory);
|
shim.setMemory(instance.exports.memory as WebAssembly.Memory);
|
||||||
|
await shim.run(instance);
|
||||||
const start = (instance.exports._start ?? instance.exports.main) as () => void;
|
|
||||||
if (typeof start !== 'function') {
|
|
||||||
throw new Error('WASM missing _start or main export');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
start();
|
|
||||||
} catch (e) {
|
|
||||||
if (!(e instanceof Error && e.message === 'wasm exit: 0')) throw e;
|
|
||||||
}
|
|
||||||
|
|
||||||
const stdout = shim.getStdout().trim();
|
const stdout = shim.getStdout().trim();
|
||||||
if (!stdout) throw new Error('WASM component produced no output');
|
if (!stdout) throw new Error('WASM component produced no output');
|
||||||
|
|||||||
@@ -123,10 +123,13 @@ async function runWasm(wasmModule: WebAssembly.Module, input: unknown): Promise<
|
|||||||
const instance = await WebAssembly.instantiate(wasmModule, wasi);
|
const instance = await WebAssembly.instantiate(wasmModule, wasi);
|
||||||
memory = instance.exports.memory as WebAssembly.Memory;
|
memory = instance.exports.memory as WebAssembly.Memory;
|
||||||
|
|
||||||
const start = (instance.exports._start ?? instance.exports.main) as () => void;
|
const promising = (WebAssembly as unknown as Record<string, unknown>)['promising'] as
|
||||||
if (typeof start !== 'function') throw new Error('WASM missing _start or main export');
|
((fn: () => void) => () => Promise<void>) | undefined;
|
||||||
|
const startFn = (instance.exports._start ?? instance.exports.main) as () => void;
|
||||||
try { start(); } catch (e) {
|
if (typeof startFn !== 'function') throw new Error('WASM missing _start or main export');
|
||||||
|
try {
|
||||||
|
if (promising) { await promising(startFn)(); } else { startFn(); }
|
||||||
|
} catch (e) {
|
||||||
if (!(e instanceof Error && e.message === 'wasm exit: 0')) throw e;
|
if (!(e instanceof Error && e.message === 'wasm exit: 0')) throw e;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -123,10 +123,13 @@ async function runWasm(wasmModule: WebAssembly.Module, input: unknown): Promise<
|
|||||||
const instance = await WebAssembly.instantiate(wasmModule, wasi);
|
const instance = await WebAssembly.instantiate(wasmModule, wasi);
|
||||||
memory = instance.exports.memory as WebAssembly.Memory;
|
memory = instance.exports.memory as WebAssembly.Memory;
|
||||||
|
|
||||||
const start = (instance.exports._start ?? instance.exports.main) as () => void;
|
const promising = (WebAssembly as unknown as Record<string, unknown>)['promising'] as
|
||||||
if (typeof start !== 'function') throw new Error('WASM missing _start or main export');
|
((fn: () => void) => () => Promise<void>) | undefined;
|
||||||
|
const startFn = (instance.exports._start ?? instance.exports.main) as () => void;
|
||||||
try { start(); } catch (e) {
|
if (typeof startFn !== 'function') throw new Error('WASM missing _start or main export');
|
||||||
|
try {
|
||||||
|
if (promising) { await promising(startFn)(); } else { startFn(); }
|
||||||
|
} catch (e) {
|
||||||
if (!(e instanceof Error && e.message === 'wasm exit: 0')) throw e;
|
if (!(e instanceof Error && e.message === 'wasm exit: 0')) throw e;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"name": "arcrun-platform-crypto",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"dependencies": {
|
||||||
|
"hono": "^4.7.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@cloudflare/workers-types": "^4.20250408.0",
|
||||||
|
"typescript": "^5.4.0",
|
||||||
|
"wrangler": "^4.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
+898
@@ -0,0 +1,898 @@
|
|||||||
|
lockfileVersion: '9.0'
|
||||||
|
|
||||||
|
settings:
|
||||||
|
autoInstallPeers: true
|
||||||
|
excludeLinksFromLockfile: false
|
||||||
|
|
||||||
|
importers:
|
||||||
|
|
||||||
|
.:
|
||||||
|
dependencies:
|
||||||
|
hono:
|
||||||
|
specifier: ^4.7.0
|
||||||
|
version: 4.12.17
|
||||||
|
devDependencies:
|
||||||
|
'@cloudflare/workers-types':
|
||||||
|
specifier: ^4.20250408.0
|
||||||
|
version: 4.20260505.1
|
||||||
|
typescript:
|
||||||
|
specifier: ^5.4.0
|
||||||
|
version: 5.9.3
|
||||||
|
wrangler:
|
||||||
|
specifier: ^4.0.0
|
||||||
|
version: 4.87.0(@cloudflare/workers-types@4.20260505.1)
|
||||||
|
|
||||||
|
packages:
|
||||||
|
|
||||||
|
'@cloudflare/kv-asset-handler@0.5.0':
|
||||||
|
resolution: {integrity: sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==}
|
||||||
|
engines: {node: '>=22.0.0'}
|
||||||
|
|
||||||
|
'@cloudflare/unenv-preset@2.16.1':
|
||||||
|
resolution: {integrity: sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==}
|
||||||
|
peerDependencies:
|
||||||
|
unenv: 2.0.0-rc.24
|
||||||
|
workerd: '>1.20260305.0 <2.0.0-0'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
workerd:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@cloudflare/workerd-darwin-64@1.20260430.1':
|
||||||
|
resolution: {integrity: sha512-ADohZUHf7NBvPp2PdZig2Opxx+hDkk3ve7jrTne3JRx9kDSB73zc4LzcEeEN8LKkbAcqZmvfRJfpChSlusu0lA==}
|
||||||
|
engines: {node: '>=16'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@cloudflare/workerd-darwin-arm64@1.20260430.1':
|
||||||
|
resolution: {integrity: sha512-/DoYC/1wHs+YRZzzqSQg1/EHB4hiv1yV5U8FnmapRRIzVaPtnt+ApeOXeMrIdKidgKOI8TqQzgBU8xbIM7Cl4Q==}
|
||||||
|
engines: {node: '>=16'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@cloudflare/workerd-linux-64@1.20260430.1':
|
||||||
|
resolution: {integrity: sha512-koJhBWvEVZPKCVFtMLp2iMHlYr+lFCF47wGbnlKdHVlemV0zTxJEyHI8aLlrhPLhBmOmYLp46rXw09/qJkRIhQ==}
|
||||||
|
engines: {node: '>=16'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@cloudflare/workerd-linux-arm64@1.20260430.1':
|
||||||
|
resolution: {integrity: sha512-hMdapNAzNQZDXGGkg4Slydc3fRJP5FUZLJVVcZCW/+imhhJro9Z1rv5n/wfR+txKoSWhTYR8eOp8Pyi2bzLzlw==}
|
||||||
|
engines: {node: '>=16'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@cloudflare/workerd-windows-64@1.20260430.1':
|
||||||
|
resolution: {integrity: sha512-jS3ffixjb5USOwz4frw4WzCz0HrjVxkgyU3WiYb06N7hBAfN6eOrveAJ4QRef0+suK4V1vQFoB1oKdRBsXe9Dw==}
|
||||||
|
engines: {node: '>=16'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@cloudflare/workers-types@4.20260505.1':
|
||||||
|
resolution: {integrity: sha512-Uz9D2hcwB4/pdnmCU7RsgknY8TQ5st0cQMMN6h/hvWt1TCt99GUkbi6dMgWdP7jXfIfh+S/EI5zQugI9RZn4Bw==}
|
||||||
|
|
||||||
|
'@cspotcode/source-map-support@0.8.1':
|
||||||
|
resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
'@emnapi/runtime@1.10.0':
|
||||||
|
resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==}
|
||||||
|
|
||||||
|
'@esbuild/aix-ppc64@0.27.3':
|
||||||
|
resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [ppc64]
|
||||||
|
os: [aix]
|
||||||
|
|
||||||
|
'@esbuild/android-arm64@0.27.3':
|
||||||
|
resolution: {integrity: sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [android]
|
||||||
|
|
||||||
|
'@esbuild/android-arm@0.27.3':
|
||||||
|
resolution: {integrity: sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [android]
|
||||||
|
|
||||||
|
'@esbuild/android-x64@0.27.3':
|
||||||
|
resolution: {integrity: sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [android]
|
||||||
|
|
||||||
|
'@esbuild/darwin-arm64@0.27.3':
|
||||||
|
resolution: {integrity: sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@esbuild/darwin-x64@0.27.3':
|
||||||
|
resolution: {integrity: sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@esbuild/freebsd-arm64@0.27.3':
|
||||||
|
resolution: {integrity: sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [freebsd]
|
||||||
|
|
||||||
|
'@esbuild/freebsd-x64@0.27.3':
|
||||||
|
resolution: {integrity: sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [freebsd]
|
||||||
|
|
||||||
|
'@esbuild/linux-arm64@0.27.3':
|
||||||
|
resolution: {integrity: sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@esbuild/linux-arm@0.27.3':
|
||||||
|
resolution: {integrity: sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@esbuild/linux-ia32@0.27.3':
|
||||||
|
resolution: {integrity: sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [ia32]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@esbuild/linux-loong64@0.27.3':
|
||||||
|
resolution: {integrity: sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [loong64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@esbuild/linux-mips64el@0.27.3':
|
||||||
|
resolution: {integrity: sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [mips64el]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@esbuild/linux-ppc64@0.27.3':
|
||||||
|
resolution: {integrity: sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [ppc64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@esbuild/linux-riscv64@0.27.3':
|
||||||
|
resolution: {integrity: sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [riscv64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@esbuild/linux-s390x@0.27.3':
|
||||||
|
resolution: {integrity: sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [s390x]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@esbuild/linux-x64@0.27.3':
|
||||||
|
resolution: {integrity: sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@esbuild/netbsd-arm64@0.27.3':
|
||||||
|
resolution: {integrity: sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [netbsd]
|
||||||
|
|
||||||
|
'@esbuild/netbsd-x64@0.27.3':
|
||||||
|
resolution: {integrity: sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [netbsd]
|
||||||
|
|
||||||
|
'@esbuild/openbsd-arm64@0.27.3':
|
||||||
|
resolution: {integrity: sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [openbsd]
|
||||||
|
|
||||||
|
'@esbuild/openbsd-x64@0.27.3':
|
||||||
|
resolution: {integrity: sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [openbsd]
|
||||||
|
|
||||||
|
'@esbuild/openharmony-arm64@0.27.3':
|
||||||
|
resolution: {integrity: sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [openharmony]
|
||||||
|
|
||||||
|
'@esbuild/sunos-x64@0.27.3':
|
||||||
|
resolution: {integrity: sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [sunos]
|
||||||
|
|
||||||
|
'@esbuild/win32-arm64@0.27.3':
|
||||||
|
resolution: {integrity: sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@esbuild/win32-ia32@0.27.3':
|
||||||
|
resolution: {integrity: sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [ia32]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@esbuild/win32-x64@0.27.3':
|
||||||
|
resolution: {integrity: sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@img/colour@1.1.0':
|
||||||
|
resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
'@img/sharp-darwin-arm64@0.34.5':
|
||||||
|
resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==}
|
||||||
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@img/sharp-darwin-x64@0.34.5':
|
||||||
|
resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==}
|
||||||
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@img/sharp-libvips-darwin-arm64@1.2.4':
|
||||||
|
resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@img/sharp-libvips-darwin-x64@1.2.4':
|
||||||
|
resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linux-arm64@1.2.4':
|
||||||
|
resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linux-arm@1.2.4':
|
||||||
|
resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linux-ppc64@1.2.4':
|
||||||
|
resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
|
||||||
|
cpu: [ppc64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linux-riscv64@1.2.4':
|
||||||
|
resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
|
||||||
|
cpu: [riscv64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linux-s390x@1.2.4':
|
||||||
|
resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
|
||||||
|
cpu: [s390x]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linux-x64@1.2.4':
|
||||||
|
resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
|
||||||
|
resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
|
||||||
|
resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
|
'@img/sharp-linux-arm64@0.34.5':
|
||||||
|
resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
|
||||||
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@img/sharp-linux-arm@0.34.5':
|
||||||
|
resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
|
||||||
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@img/sharp-linux-ppc64@0.34.5':
|
||||||
|
resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==}
|
||||||
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
|
cpu: [ppc64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@img/sharp-linux-riscv64@0.34.5':
|
||||||
|
resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==}
|
||||||
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
|
cpu: [riscv64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@img/sharp-linux-s390x@0.34.5':
|
||||||
|
resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==}
|
||||||
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
|
cpu: [s390x]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@img/sharp-linux-x64@0.34.5':
|
||||||
|
resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
|
||||||
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@img/sharp-linuxmusl-arm64@0.34.5':
|
||||||
|
resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
|
||||||
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
|
'@img/sharp-linuxmusl-x64@0.34.5':
|
||||||
|
resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
|
||||||
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
|
'@img/sharp-wasm32@0.34.5':
|
||||||
|
resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==}
|
||||||
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
|
cpu: [wasm32]
|
||||||
|
|
||||||
|
'@img/sharp-win32-arm64@0.34.5':
|
||||||
|
resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==}
|
||||||
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@img/sharp-win32-ia32@0.34.5':
|
||||||
|
resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==}
|
||||||
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
|
cpu: [ia32]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@img/sharp-win32-x64@0.34.5':
|
||||||
|
resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==}
|
||||||
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@jridgewell/resolve-uri@3.1.2':
|
||||||
|
resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
|
||||||
|
engines: {node: '>=6.0.0'}
|
||||||
|
|
||||||
|
'@jridgewell/sourcemap-codec@1.5.5':
|
||||||
|
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
|
||||||
|
|
||||||
|
'@jridgewell/trace-mapping@0.3.9':
|
||||||
|
resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==}
|
||||||
|
|
||||||
|
'@poppinss/colors@4.1.6':
|
||||||
|
resolution: {integrity: sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==}
|
||||||
|
|
||||||
|
'@poppinss/dumper@0.6.5':
|
||||||
|
resolution: {integrity: sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==}
|
||||||
|
|
||||||
|
'@poppinss/exception@1.2.3':
|
||||||
|
resolution: {integrity: sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==}
|
||||||
|
|
||||||
|
'@sindresorhus/is@7.2.0':
|
||||||
|
resolution: {integrity: sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
'@speed-highlight/core@1.2.15':
|
||||||
|
resolution: {integrity: sha512-BMq1K3DsElxDWawkX6eLg9+CKJrTVGCBAWVuHXVUV2u0s2711qiChLSId6ikYPfxhdYocLNt3wWwSvDiTvFabw==}
|
||||||
|
|
||||||
|
blake3-wasm@2.1.5:
|
||||||
|
resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==}
|
||||||
|
|
||||||
|
cookie@1.1.1:
|
||||||
|
resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
detect-libc@2.1.2:
|
||||||
|
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
error-stack-parser-es@1.0.5:
|
||||||
|
resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==}
|
||||||
|
|
||||||
|
esbuild@0.27.3:
|
||||||
|
resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
|
fsevents@2.3.3:
|
||||||
|
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
||||||
|
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
hono@4.12.17:
|
||||||
|
resolution: {integrity: sha512-FbJJNb/XgX7YW0hX/V8w5oYLztKEsRLykCMZWt1WdLtsfjzMvmoqWBA4H4t5norinq8/rh20oiZYr+WSl4UzAQ==}
|
||||||
|
engines: {node: '>=16.9.0'}
|
||||||
|
|
||||||
|
kleur@4.1.5:
|
||||||
|
resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==}
|
||||||
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
|
miniflare@4.20260430.0:
|
||||||
|
resolution: {integrity: sha512-MWvMm3Siho9Yj7lbJZidLs8hbrRvIcOrif2mnsHQZdvoKfedpea+GaN8XJxbpRcq0B2WzNI1BB1ihdnqes3/ZA==}
|
||||||
|
engines: {node: '>=22.0.0'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
|
path-to-regexp@6.3.0:
|
||||||
|
resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==}
|
||||||
|
|
||||||
|
pathe@2.0.3:
|
||||||
|
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
|
||||||
|
|
||||||
|
semver@7.7.4:
|
||||||
|
resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==}
|
||||||
|
engines: {node: '>=10'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
|
sharp@0.34.5:
|
||||||
|
resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==}
|
||||||
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
|
|
||||||
|
supports-color@10.2.2:
|
||||||
|
resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
tslib@2.8.1:
|
||||||
|
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
||||||
|
|
||||||
|
typescript@5.9.3:
|
||||||
|
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
|
||||||
|
engines: {node: '>=14.17'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
|
undici@7.24.8:
|
||||||
|
resolution: {integrity: sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ==}
|
||||||
|
engines: {node: '>=20.18.1'}
|
||||||
|
|
||||||
|
unenv@2.0.0-rc.24:
|
||||||
|
resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==}
|
||||||
|
|
||||||
|
workerd@1.20260430.1:
|
||||||
|
resolution: {integrity: sha512-KEgIWyiw3Jmn+DCd/L3ePo5fmiiYb/UcwKvDWPf/nLLOiwShDFzDSsegU5NY/JcwgvO/QsLHVi2FYrbkcXNY5Q==}
|
||||||
|
engines: {node: '>=16'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
|
wrangler@4.87.0:
|
||||||
|
resolution: {integrity: sha512-lfhfKwLfQlowwgV0xhlYgE9fU3n0I30d4ccGY/rTCEm/n42Mjvlr0Ng3ZPNqlsrsKBcDR531V7dsPkgELvrk/Q==}
|
||||||
|
engines: {node: '>=22.0.0'}
|
||||||
|
hasBin: true
|
||||||
|
peerDependencies:
|
||||||
|
'@cloudflare/workers-types': ^4.20260430.1
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@cloudflare/workers-types':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
ws@8.18.0:
|
||||||
|
resolution: {integrity: sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==}
|
||||||
|
engines: {node: '>=10.0.0'}
|
||||||
|
peerDependencies:
|
||||||
|
bufferutil: ^4.0.1
|
||||||
|
utf-8-validate: '>=5.0.2'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
bufferutil:
|
||||||
|
optional: true
|
||||||
|
utf-8-validate:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
youch-core@0.3.3:
|
||||||
|
resolution: {integrity: sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==}
|
||||||
|
|
||||||
|
youch@4.1.0-beta.10:
|
||||||
|
resolution: {integrity: sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==}
|
||||||
|
|
||||||
|
snapshots:
|
||||||
|
|
||||||
|
'@cloudflare/kv-asset-handler@0.5.0': {}
|
||||||
|
|
||||||
|
'@cloudflare/unenv-preset@2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260430.1)':
|
||||||
|
dependencies:
|
||||||
|
unenv: 2.0.0-rc.24
|
||||||
|
optionalDependencies:
|
||||||
|
workerd: 1.20260430.1
|
||||||
|
|
||||||
|
'@cloudflare/workerd-darwin-64@1.20260430.1':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@cloudflare/workerd-darwin-arm64@1.20260430.1':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@cloudflare/workerd-linux-64@1.20260430.1':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@cloudflare/workerd-linux-arm64@1.20260430.1':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@cloudflare/workerd-windows-64@1.20260430.1':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@cloudflare/workers-types@4.20260505.1': {}
|
||||||
|
|
||||||
|
'@cspotcode/source-map-support@0.8.1':
|
||||||
|
dependencies:
|
||||||
|
'@jridgewell/trace-mapping': 0.3.9
|
||||||
|
|
||||||
|
'@emnapi/runtime@1.10.0':
|
||||||
|
dependencies:
|
||||||
|
tslib: 2.8.1
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/aix-ppc64@0.27.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/android-arm64@0.27.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/android-arm@0.27.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/android-x64@0.27.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/darwin-arm64@0.27.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/darwin-x64@0.27.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/freebsd-arm64@0.27.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/freebsd-x64@0.27.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/linux-arm64@0.27.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/linux-arm@0.27.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/linux-ia32@0.27.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/linux-loong64@0.27.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/linux-mips64el@0.27.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/linux-ppc64@0.27.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/linux-riscv64@0.27.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/linux-s390x@0.27.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/linux-x64@0.27.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/netbsd-arm64@0.27.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/netbsd-x64@0.27.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/openbsd-arm64@0.27.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/openbsd-x64@0.27.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/openharmony-arm64@0.27.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/sunos-x64@0.27.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/win32-arm64@0.27.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/win32-ia32@0.27.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/win32-x64@0.27.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/colour@1.1.0': {}
|
||||||
|
|
||||||
|
'@img/sharp-darwin-arm64@0.34.5':
|
||||||
|
optionalDependencies:
|
||||||
|
'@img/sharp-libvips-darwin-arm64': 1.2.4
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-darwin-x64@0.34.5':
|
||||||
|
optionalDependencies:
|
||||||
|
'@img/sharp-libvips-darwin-x64': 1.2.4
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-libvips-darwin-arm64@1.2.4':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-libvips-darwin-x64@1.2.4':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linux-arm64@1.2.4':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linux-arm@1.2.4':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linux-ppc64@1.2.4':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linux-riscv64@1.2.4':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linux-s390x@1.2.4':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linux-x64@1.2.4':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-linux-arm64@0.34.5':
|
||||||
|
optionalDependencies:
|
||||||
|
'@img/sharp-libvips-linux-arm64': 1.2.4
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-linux-arm@0.34.5':
|
||||||
|
optionalDependencies:
|
||||||
|
'@img/sharp-libvips-linux-arm': 1.2.4
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-linux-ppc64@0.34.5':
|
||||||
|
optionalDependencies:
|
||||||
|
'@img/sharp-libvips-linux-ppc64': 1.2.4
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-linux-riscv64@0.34.5':
|
||||||
|
optionalDependencies:
|
||||||
|
'@img/sharp-libvips-linux-riscv64': 1.2.4
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-linux-s390x@0.34.5':
|
||||||
|
optionalDependencies:
|
||||||
|
'@img/sharp-libvips-linux-s390x': 1.2.4
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-linux-x64@0.34.5':
|
||||||
|
optionalDependencies:
|
||||||
|
'@img/sharp-libvips-linux-x64': 1.2.4
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-linuxmusl-arm64@0.34.5':
|
||||||
|
optionalDependencies:
|
||||||
|
'@img/sharp-libvips-linuxmusl-arm64': 1.2.4
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-linuxmusl-x64@0.34.5':
|
||||||
|
optionalDependencies:
|
||||||
|
'@img/sharp-libvips-linuxmusl-x64': 1.2.4
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-wasm32@0.34.5':
|
||||||
|
dependencies:
|
||||||
|
'@emnapi/runtime': 1.10.0
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-win32-arm64@0.34.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-win32-ia32@0.34.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-win32-x64@0.34.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@jridgewell/resolve-uri@3.1.2': {}
|
||||||
|
|
||||||
|
'@jridgewell/sourcemap-codec@1.5.5': {}
|
||||||
|
|
||||||
|
'@jridgewell/trace-mapping@0.3.9':
|
||||||
|
dependencies:
|
||||||
|
'@jridgewell/resolve-uri': 3.1.2
|
||||||
|
'@jridgewell/sourcemap-codec': 1.5.5
|
||||||
|
|
||||||
|
'@poppinss/colors@4.1.6':
|
||||||
|
dependencies:
|
||||||
|
kleur: 4.1.5
|
||||||
|
|
||||||
|
'@poppinss/dumper@0.6.5':
|
||||||
|
dependencies:
|
||||||
|
'@poppinss/colors': 4.1.6
|
||||||
|
'@sindresorhus/is': 7.2.0
|
||||||
|
supports-color: 10.2.2
|
||||||
|
|
||||||
|
'@poppinss/exception@1.2.3': {}
|
||||||
|
|
||||||
|
'@sindresorhus/is@7.2.0': {}
|
||||||
|
|
||||||
|
'@speed-highlight/core@1.2.15': {}
|
||||||
|
|
||||||
|
blake3-wasm@2.1.5: {}
|
||||||
|
|
||||||
|
cookie@1.1.1: {}
|
||||||
|
|
||||||
|
detect-libc@2.1.2: {}
|
||||||
|
|
||||||
|
error-stack-parser-es@1.0.5: {}
|
||||||
|
|
||||||
|
esbuild@0.27.3:
|
||||||
|
optionalDependencies:
|
||||||
|
'@esbuild/aix-ppc64': 0.27.3
|
||||||
|
'@esbuild/android-arm': 0.27.3
|
||||||
|
'@esbuild/android-arm64': 0.27.3
|
||||||
|
'@esbuild/android-x64': 0.27.3
|
||||||
|
'@esbuild/darwin-arm64': 0.27.3
|
||||||
|
'@esbuild/darwin-x64': 0.27.3
|
||||||
|
'@esbuild/freebsd-arm64': 0.27.3
|
||||||
|
'@esbuild/freebsd-x64': 0.27.3
|
||||||
|
'@esbuild/linux-arm': 0.27.3
|
||||||
|
'@esbuild/linux-arm64': 0.27.3
|
||||||
|
'@esbuild/linux-ia32': 0.27.3
|
||||||
|
'@esbuild/linux-loong64': 0.27.3
|
||||||
|
'@esbuild/linux-mips64el': 0.27.3
|
||||||
|
'@esbuild/linux-ppc64': 0.27.3
|
||||||
|
'@esbuild/linux-riscv64': 0.27.3
|
||||||
|
'@esbuild/linux-s390x': 0.27.3
|
||||||
|
'@esbuild/linux-x64': 0.27.3
|
||||||
|
'@esbuild/netbsd-arm64': 0.27.3
|
||||||
|
'@esbuild/netbsd-x64': 0.27.3
|
||||||
|
'@esbuild/openbsd-arm64': 0.27.3
|
||||||
|
'@esbuild/openbsd-x64': 0.27.3
|
||||||
|
'@esbuild/openharmony-arm64': 0.27.3
|
||||||
|
'@esbuild/sunos-x64': 0.27.3
|
||||||
|
'@esbuild/win32-arm64': 0.27.3
|
||||||
|
'@esbuild/win32-ia32': 0.27.3
|
||||||
|
'@esbuild/win32-x64': 0.27.3
|
||||||
|
|
||||||
|
fsevents@2.3.3:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
hono@4.12.17: {}
|
||||||
|
|
||||||
|
kleur@4.1.5: {}
|
||||||
|
|
||||||
|
miniflare@4.20260430.0:
|
||||||
|
dependencies:
|
||||||
|
'@cspotcode/source-map-support': 0.8.1
|
||||||
|
sharp: 0.34.5
|
||||||
|
undici: 7.24.8
|
||||||
|
workerd: 1.20260430.1
|
||||||
|
ws: 8.18.0
|
||||||
|
youch: 4.1.0-beta.10
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- bufferutil
|
||||||
|
- utf-8-validate
|
||||||
|
|
||||||
|
path-to-regexp@6.3.0: {}
|
||||||
|
|
||||||
|
pathe@2.0.3: {}
|
||||||
|
|
||||||
|
semver@7.7.4: {}
|
||||||
|
|
||||||
|
sharp@0.34.5:
|
||||||
|
dependencies:
|
||||||
|
'@img/colour': 1.1.0
|
||||||
|
detect-libc: 2.1.2
|
||||||
|
semver: 7.7.4
|
||||||
|
optionalDependencies:
|
||||||
|
'@img/sharp-darwin-arm64': 0.34.5
|
||||||
|
'@img/sharp-darwin-x64': 0.34.5
|
||||||
|
'@img/sharp-libvips-darwin-arm64': 1.2.4
|
||||||
|
'@img/sharp-libvips-darwin-x64': 1.2.4
|
||||||
|
'@img/sharp-libvips-linux-arm': 1.2.4
|
||||||
|
'@img/sharp-libvips-linux-arm64': 1.2.4
|
||||||
|
'@img/sharp-libvips-linux-ppc64': 1.2.4
|
||||||
|
'@img/sharp-libvips-linux-riscv64': 1.2.4
|
||||||
|
'@img/sharp-libvips-linux-s390x': 1.2.4
|
||||||
|
'@img/sharp-libvips-linux-x64': 1.2.4
|
||||||
|
'@img/sharp-libvips-linuxmusl-arm64': 1.2.4
|
||||||
|
'@img/sharp-libvips-linuxmusl-x64': 1.2.4
|
||||||
|
'@img/sharp-linux-arm': 0.34.5
|
||||||
|
'@img/sharp-linux-arm64': 0.34.5
|
||||||
|
'@img/sharp-linux-ppc64': 0.34.5
|
||||||
|
'@img/sharp-linux-riscv64': 0.34.5
|
||||||
|
'@img/sharp-linux-s390x': 0.34.5
|
||||||
|
'@img/sharp-linux-x64': 0.34.5
|
||||||
|
'@img/sharp-linuxmusl-arm64': 0.34.5
|
||||||
|
'@img/sharp-linuxmusl-x64': 0.34.5
|
||||||
|
'@img/sharp-wasm32': 0.34.5
|
||||||
|
'@img/sharp-win32-arm64': 0.34.5
|
||||||
|
'@img/sharp-win32-ia32': 0.34.5
|
||||||
|
'@img/sharp-win32-x64': 0.34.5
|
||||||
|
|
||||||
|
supports-color@10.2.2: {}
|
||||||
|
|
||||||
|
tslib@2.8.1:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
typescript@5.9.3: {}
|
||||||
|
|
||||||
|
undici@7.24.8: {}
|
||||||
|
|
||||||
|
unenv@2.0.0-rc.24:
|
||||||
|
dependencies:
|
||||||
|
pathe: 2.0.3
|
||||||
|
|
||||||
|
workerd@1.20260430.1:
|
||||||
|
optionalDependencies:
|
||||||
|
'@cloudflare/workerd-darwin-64': 1.20260430.1
|
||||||
|
'@cloudflare/workerd-darwin-arm64': 1.20260430.1
|
||||||
|
'@cloudflare/workerd-linux-64': 1.20260430.1
|
||||||
|
'@cloudflare/workerd-linux-arm64': 1.20260430.1
|
||||||
|
'@cloudflare/workerd-windows-64': 1.20260430.1
|
||||||
|
|
||||||
|
wrangler@4.87.0(@cloudflare/workers-types@4.20260505.1):
|
||||||
|
dependencies:
|
||||||
|
'@cloudflare/kv-asset-handler': 0.5.0
|
||||||
|
'@cloudflare/unenv-preset': 2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260430.1)
|
||||||
|
blake3-wasm: 2.1.5
|
||||||
|
esbuild: 0.27.3
|
||||||
|
miniflare: 4.20260430.0
|
||||||
|
path-to-regexp: 6.3.0
|
||||||
|
unenv: 2.0.0-rc.24
|
||||||
|
workerd: 1.20260430.1
|
||||||
|
optionalDependencies:
|
||||||
|
'@cloudflare/workers-types': 4.20260505.1
|
||||||
|
fsevents: 2.3.3
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- bufferutil
|
||||||
|
- utf-8-validate
|
||||||
|
|
||||||
|
ws@8.18.0: {}
|
||||||
|
|
||||||
|
youch-core@0.3.3:
|
||||||
|
dependencies:
|
||||||
|
'@poppinss/exception': 1.2.3
|
||||||
|
error-stack-parser-es: 1.0.5
|
||||||
|
|
||||||
|
youch@4.1.0-beta.10:
|
||||||
|
dependencies:
|
||||||
|
'@poppinss/colors': 4.1.6
|
||||||
|
'@poppinss/dumper': 0.6.5
|
||||||
|
'@speed-highlight/core': 1.2.15
|
||||||
|
cookie: 1.1.1
|
||||||
|
youch-core: 0.3.3
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
/**
|
||||||
|
* arcrun platform_crypto Worker
|
||||||
|
*
|
||||||
|
* POST / → JSON input {action, ...} → JSON output
|
||||||
|
*
|
||||||
|
* Actions:
|
||||||
|
* generate_api_key — HMAC-SHA256(email, ENCRYPTION_KEY) → ak_{hex[:32]}
|
||||||
|
* encrypt — AES-GCM(plaintext, ENCRYPTION_KEY) → {encrypted, iv}(base64)
|
||||||
|
* random_token — crypto random bytes → hex string
|
||||||
|
*
|
||||||
|
* 安全邊界:ENCRYPTION_KEY 只存在於 closure,永不進入外部(rule 02 §2.2)。
|
||||||
|
* 此 Worker 直接用 crypto.subtle 實作,不走 WASM runner。
|
||||||
|
* TinyGo WASM async host function 在 Cloudflare Workers 的 u6u namespace 不支援 Promise.
|
||||||
|
* WASM 零件 (registry/components/platform_crypto/) 保留作為 edge-Go 移植時的參考。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Hono } from 'hono';
|
||||||
|
import { cors } from 'hono/cors';
|
||||||
|
|
||||||
|
type Env = {
|
||||||
|
ENCRYPTION_KEY: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Input = {
|
||||||
|
action: string;
|
||||||
|
email?: string;
|
||||||
|
plaintext?: string;
|
||||||
|
bytes?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const app = new Hono<{ Bindings: Env }>();
|
||||||
|
app.use('*', cors());
|
||||||
|
|
||||||
|
app.get('/', (c) => c.json({ ok: true, component: 'platform_crypto' }));
|
||||||
|
|
||||||
|
app.post('/', async (c) => {
|
||||||
|
let input: Input;
|
||||||
|
try {
|
||||||
|
input = await c.req.json() as Input;
|
||||||
|
} catch {
|
||||||
|
return c.json({ success: false, error: 'request body must be JSON' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const encryptionKey = c.env.ENCRYPTION_KEY;
|
||||||
|
if (!encryptionKey) {
|
||||||
|
return c.json({ success: false, error: 'ENCRYPTION_KEY not configured' }, 503);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
switch (input.action) {
|
||||||
|
case 'generate_api_key': {
|
||||||
|
if (!input.email) return c.json({ success: false, error: 'email 必填' }, 400);
|
||||||
|
const apiKey = await generateApiKey(input.email, encryptionKey);
|
||||||
|
return c.json({ success: true, api_key: apiKey });
|
||||||
|
}
|
||||||
|
case 'encrypt': {
|
||||||
|
if (!input.plaintext) return c.json({ success: false, error: 'plaintext 必填' }, 400);
|
||||||
|
const { encrypted, iv } = await aesEncrypt(input.plaintext, encryptionKey);
|
||||||
|
return c.json({ success: true, encrypted, iv });
|
||||||
|
}
|
||||||
|
case 'random_token': {
|
||||||
|
const numBytes = (input.bytes ?? 32) > 0 ? (input.bytes ?? 32) : 32;
|
||||||
|
const token = randomHex(numBytes);
|
||||||
|
return c.json({ success: true, token });
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return c.json({ success: false, error: `不支援的 action: ${input.action}` }, 400);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
return c.json(
|
||||||
|
{ success: false, error: e instanceof Error ? e.message : String(e) },
|
||||||
|
500,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default app;
|
||||||
|
|
||||||
|
// ── Crypto implementations (rule 02 §2.2: crypto.subtle 只准在 wasi-shim.ts 或 platform_crypto) ──
|
||||||
|
|
||||||
|
async function generateApiKey(email: string, encryptionKey: string): Promise<string> {
|
||||||
|
const keyBytes = new TextEncoder().encode(encryptionKey.slice(0, 32));
|
||||||
|
const cryptoKey = await crypto.subtle.importKey(
|
||||||
|
'raw', keyBytes, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'],
|
||||||
|
);
|
||||||
|
const sig = await crypto.subtle.sign('HMAC', cryptoKey, new TextEncoder().encode(email));
|
||||||
|
const hex = Array.from(new Uint8Array(sig)).map(b => b.toString(16).padStart(2, '0')).join('');
|
||||||
|
return 'ak_' + hex.slice(0, 32);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function aesEncrypt(plaintext: string, encryptionKey: string): Promise<{ encrypted: string; iv: string }> {
|
||||||
|
const keyBytes = new TextEncoder().encode(encryptionKey.slice(0, 32));
|
||||||
|
const cryptoKey = await crypto.subtle.importKey('raw', keyBytes, { name: 'AES-GCM' }, false, ['encrypt']);
|
||||||
|
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||||
|
const enc = await crypto.subtle.encrypt(
|
||||||
|
{ name: 'AES-GCM', iv },
|
||||||
|
cryptoKey,
|
||||||
|
new TextEncoder().encode(plaintext),
|
||||||
|
);
|
||||||
|
const toB64 = (buf: ArrayBuffer | Uint8Array) =>
|
||||||
|
btoa(String.fromCharCode(...new Uint8Array(buf instanceof ArrayBuffer ? buf : buf)));
|
||||||
|
return { encrypted: toB64(enc), iv: toB64(iv) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function randomHex(numBytes: number): string {
|
||||||
|
const arr = crypto.getRandomValues(new Uint8Array(numBytes));
|
||||||
|
return Array.from(arr).map(b => b.toString(16).padStart(2, '0')).join('');
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ES2022",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"types": ["@cloudflare/workers-types"],
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
name = "arcrun-platform-crypto"
|
||||||
|
main = "src/index.ts"
|
||||||
|
compatibility_date = "2025-02-19"
|
||||||
|
compatibility_flags = ["nodejs_compat"]
|
||||||
|
|
||||||
|
[vars]
|
||||||
|
COMPONENT_ID = "platform_crypto"
|
||||||
|
|
||||||
|
[[routes]]
|
||||||
|
pattern = "platform-crypto.arcrun.dev/*"
|
||||||
|
zone_name = "arcrun.dev"
|
||||||
|
|
||||||
|
# ENCRYPTION_KEY 透過 wrangler secret set 設定
|
||||||
|
# wrangler secret put ENCRYPTION_KEY
|
||||||
@@ -123,10 +123,13 @@ async function runWasm(wasmModule: WebAssembly.Module, input: unknown): Promise<
|
|||||||
const instance = await WebAssembly.instantiate(wasmModule, wasi);
|
const instance = await WebAssembly.instantiate(wasmModule, wasi);
|
||||||
memory = instance.exports.memory as WebAssembly.Memory;
|
memory = instance.exports.memory as WebAssembly.Memory;
|
||||||
|
|
||||||
const start = (instance.exports._start ?? instance.exports.main) as () => void;
|
const promising = (WebAssembly as unknown as Record<string, unknown>)['promising'] as
|
||||||
if (typeof start !== 'function') throw new Error('WASM missing _start or main export');
|
((fn: () => void) => () => Promise<void>) | undefined;
|
||||||
|
const startFn = (instance.exports._start ?? instance.exports.main) as () => void;
|
||||||
try { start(); } catch (e) {
|
if (typeof startFn !== 'function') throw new Error('WASM missing _start or main export');
|
||||||
|
try {
|
||||||
|
if (promising) { await promising(startFn)(); } else { startFn(); }
|
||||||
|
} catch (e) {
|
||||||
if (!(e instanceof Error && e.message === 'wasm exit: 0')) throw e;
|
if (!(e instanceof Error && e.message === 'wasm exit: 0')) throw e;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -123,10 +123,13 @@ async function runWasm(wasmModule: WebAssembly.Module, input: unknown): Promise<
|
|||||||
const instance = await WebAssembly.instantiate(wasmModule, wasi);
|
const instance = await WebAssembly.instantiate(wasmModule, wasi);
|
||||||
memory = instance.exports.memory as WebAssembly.Memory;
|
memory = instance.exports.memory as WebAssembly.Memory;
|
||||||
|
|
||||||
const start = (instance.exports._start ?? instance.exports.main) as () => void;
|
const promising = (WebAssembly as unknown as Record<string, unknown>)['promising'] as
|
||||||
if (typeof start !== 'function') throw new Error('WASM missing _start or main export');
|
((fn: () => void) => () => Promise<void>) | undefined;
|
||||||
|
const startFn = (instance.exports._start ?? instance.exports.main) as () => void;
|
||||||
try { start(); } catch (e) {
|
if (typeof startFn !== 'function') throw new Error('WASM missing _start or main export');
|
||||||
|
try {
|
||||||
|
if (promising) { await promising(startFn)(); } else { startFn(); }
|
||||||
|
} catch (e) {
|
||||||
if (!(e instanceof Error && e.message === 'wasm exit: 0')) throw e;
|
if (!(e instanceof Error && e.message === 'wasm exit: 0')) throw e;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -123,10 +123,13 @@ async function runWasm(wasmModule: WebAssembly.Module, input: unknown): Promise<
|
|||||||
const instance = await WebAssembly.instantiate(wasmModule, wasi);
|
const instance = await WebAssembly.instantiate(wasmModule, wasi);
|
||||||
memory = instance.exports.memory as WebAssembly.Memory;
|
memory = instance.exports.memory as WebAssembly.Memory;
|
||||||
|
|
||||||
const start = (instance.exports._start ?? instance.exports.main) as () => void;
|
const promising = (WebAssembly as unknown as Record<string, unknown>)['promising'] as
|
||||||
if (typeof start !== 'function') throw new Error('WASM missing _start or main export');
|
((fn: () => void) => () => Promise<void>) | undefined;
|
||||||
|
const startFn = (instance.exports._start ?? instance.exports.main) as () => void;
|
||||||
try { start(); } catch (e) {
|
if (typeof startFn !== 'function') throw new Error('WASM missing _start or main export');
|
||||||
|
try {
|
||||||
|
if (promising) { await promising(startFn)(); } else { startFn(); }
|
||||||
|
} catch (e) {
|
||||||
if (!(e instanceof Error && e.message === 'wasm exit: 0')) throw e;
|
if (!(e instanceof Error && e.message === 'wasm exit: 0')) throw e;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -77,17 +77,7 @@ async function runWasm(input: unknown): Promise<unknown> {
|
|||||||
shim.imports,
|
shim.imports,
|
||||||
);
|
);
|
||||||
shim.setMemory(instance.exports.memory as WebAssembly.Memory);
|
shim.setMemory(instance.exports.memory as WebAssembly.Memory);
|
||||||
|
await shim.run(instance);
|
||||||
const start = (instance.exports._start ?? instance.exports.main) as () => void;
|
|
||||||
if (typeof start !== 'function') {
|
|
||||||
throw new Error('WASM missing _start or main export');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
start();
|
|
||||||
} catch (e) {
|
|
||||||
if (!(e instanceof Error && e.message === 'wasm exit: 0')) throw e;
|
|
||||||
}
|
|
||||||
|
|
||||||
const stdout = shim.getStdout().trim();
|
const stdout = shim.getStdout().trim();
|
||||||
if (!stdout) throw new Error('WASM component produced no output');
|
if (!stdout) throw new Error('WASM component produced no output');
|
||||||
|
|||||||
@@ -123,10 +123,13 @@ async function runWasm(wasmModule: WebAssembly.Module, input: unknown): Promise<
|
|||||||
const instance = await WebAssembly.instantiate(wasmModule, wasi);
|
const instance = await WebAssembly.instantiate(wasmModule, wasi);
|
||||||
memory = instance.exports.memory as WebAssembly.Memory;
|
memory = instance.exports.memory as WebAssembly.Memory;
|
||||||
|
|
||||||
const start = (instance.exports._start ?? instance.exports.main) as () => void;
|
const promising = (WebAssembly as unknown as Record<string, unknown>)['promising'] as
|
||||||
if (typeof start !== 'function') throw new Error('WASM missing _start or main export');
|
((fn: () => void) => () => Promise<void>) | undefined;
|
||||||
|
const startFn = (instance.exports._start ?? instance.exports.main) as () => void;
|
||||||
try { start(); } catch (e) {
|
if (typeof startFn !== 'function') throw new Error('WASM missing _start or main export');
|
||||||
|
try {
|
||||||
|
if (promising) { await promising(startFn)(); } else { startFn(); }
|
||||||
|
} catch (e) {
|
||||||
if (!(e instanceof Error && e.message === 'wasm exit: 0')) throw e;
|
if (!(e instanceof Error && e.message === 'wasm exit: 0')) throw e;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -123,10 +123,13 @@ async function runWasm(wasmModule: WebAssembly.Module, input: unknown): Promise<
|
|||||||
const instance = await WebAssembly.instantiate(wasmModule, wasi);
|
const instance = await WebAssembly.instantiate(wasmModule, wasi);
|
||||||
memory = instance.exports.memory as WebAssembly.Memory;
|
memory = instance.exports.memory as WebAssembly.Memory;
|
||||||
|
|
||||||
const start = (instance.exports._start ?? instance.exports.main) as () => void;
|
const promising = (WebAssembly as unknown as Record<string, unknown>)['promising'] as
|
||||||
if (typeof start !== 'function') throw new Error('WASM missing _start or main export');
|
((fn: () => void) => () => Promise<void>) | undefined;
|
||||||
|
const startFn = (instance.exports._start ?? instance.exports.main) as () => void;
|
||||||
try { start(); } catch (e) {
|
if (typeof startFn !== 'function') throw new Error('WASM missing _start or main export');
|
||||||
|
try {
|
||||||
|
if (promising) { await promising(startFn)(); } else { startFn(); }
|
||||||
|
} catch (e) {
|
||||||
if (!(e instanceof Error && e.message === 'wasm exit: 0')) throw e;
|
if (!(e instanceof Error && e.message === 'wasm exit: 0')) throw e;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -123,10 +123,13 @@ async function runWasm(wasmModule: WebAssembly.Module, input: unknown): Promise<
|
|||||||
const instance = await WebAssembly.instantiate(wasmModule, wasi);
|
const instance = await WebAssembly.instantiate(wasmModule, wasi);
|
||||||
memory = instance.exports.memory as WebAssembly.Memory;
|
memory = instance.exports.memory as WebAssembly.Memory;
|
||||||
|
|
||||||
const start = (instance.exports._start ?? instance.exports.main) as () => void;
|
const promising = (WebAssembly as unknown as Record<string, unknown>)['promising'] as
|
||||||
if (typeof start !== 'function') throw new Error('WASM missing _start or main export');
|
((fn: () => void) => () => Promise<void>) | undefined;
|
||||||
|
const startFn = (instance.exports._start ?? instance.exports.main) as () => void;
|
||||||
try { start(); } catch (e) {
|
if (typeof startFn !== 'function') throw new Error('WASM missing _start or main export');
|
||||||
|
try {
|
||||||
|
if (promising) { await promising(startFn)(); } else { startFn(); }
|
||||||
|
} catch (e) {
|
||||||
if (!(e instanceof Error && e.message === 'wasm exit: 0')) throw e;
|
if (!(e instanceof Error && e.message === 'wasm exit: 0')) throw e;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -249,6 +249,18 @@ jobs:
|
|||||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||||
run: pnpm exec wrangler deploy
|
run: pnpm exec wrangler deploy
|
||||||
|
|
||||||
|
# 部署成功後自動把 contract 灌進 component-registry index
|
||||||
|
# SDD: matrix/arcrun/.agents/specs/component-registry-canon/design.md Phase 2
|
||||||
|
# degraded mode:失敗只 warning 不擋部署
|
||||||
|
# 邏輯實作在 registry/scripts/register-component.sh(本地+CI 共用 SSOT)
|
||||||
|
- name: Register component in registry
|
||||||
|
if: success()
|
||||||
|
run: |
|
||||||
|
set -uo pipefail
|
||||||
|
python3 -c "import yaml" 2>/dev/null || pip install --quiet pyyaml
|
||||||
|
bash registry/scripts/register-component.sh "${{ matrix.worker.name }}" || \
|
||||||
|
echo "::warning::Registry 註冊失敗(degraded mode)"
|
||||||
|
|
||||||
# ── Job 2b:Tier 2 並行部署(orchestration Worker,可能有 service binding 相依於 tier1) ─
|
# ── Job 2b:Tier 2 並行部署(orchestration Worker,可能有 service binding 相依於 tier1) ─
|
||||||
# needs: deploy-tier1 → tier1 全綠才開始;首次部署時避免 service binding target 未存在。
|
# needs: deploy-tier1 → tier1 全綠才開始;首次部署時避免 service binding target 未存在。
|
||||||
deploy-tier2:
|
deploy-tier2:
|
||||||
|
|||||||
@@ -9,8 +9,8 @@
|
|||||||
* - 那些全部在 auth primitive WASM 零件內執行(透過 host function `crypto_decrypt` 等)
|
* - 那些全部在 auth primitive WASM 零件內執行(透過 host function `crypto_decrypt` 等)
|
||||||
* - 本檔只做「查 recipe 決定走哪個 primitive Worker」+「HTTP fetch 取回注入結果」
|
* - 本檔只做「查 recipe 決定走哪個 primitive Worker」+「HTTP fetch 取回注入結果」
|
||||||
*
|
*
|
||||||
* 目前階段(Phase 2)接上 `auth_static_key` + `auth_service_account`,
|
* 目前階段接上 `auth_static_key` + `auth_service_account` + `auth_oauth2`,
|
||||||
* Phase 4(封測後)加 `auth_oauth2` / `auth_mtls`。
|
* Phase 4 剩 `auth_mtls`(mTLS handshake 在 Worker runtime 層)。
|
||||||
*
|
*
|
||||||
* 執行時機:graph-executor 在節點 runner 執行前呼叫,取回的 ctx 會:
|
* 執行時機:graph-executor 在節點 runner 執行前呼叫,取回的 ctx 會:
|
||||||
* 1. 先試本 dispatcher(命中才 return enriched ctx)
|
* 1. 先試本 dispatcher(命中才 return enriched ctx)
|
||||||
@@ -22,7 +22,7 @@ import { resolveAuthRecipe } from '../routes/recipes';
|
|||||||
import { wasmWorkerUrl } from '../lib/component-loader';
|
import { wasmWorkerUrl } from '../lib/component-loader';
|
||||||
|
|
||||||
/** 對應 Phase 1-4 會部署的 auth primitive Worker */
|
/** 對應 Phase 1-4 會部署的 auth primitive Worker */
|
||||||
const SUPPORTED_PRIMITIVES = new Set(['static_key', 'service_account']);
|
const SUPPORTED_PRIMITIVES = new Set(['static_key', 'service_account', 'oauth2']);
|
||||||
|
|
||||||
/** auth primitive 本身的 componentId(避免自引用) */
|
/** auth primitive 本身的 componentId(避免自引用) */
|
||||||
const AUTH_PRIMITIVE_IDS = new Set([
|
const AUTH_PRIMITIVE_IDS = new Set([
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
// KBDB Partner 同步工具
|
||||||
|
// Arcrun 用戶登入/rotate/revoke 時,同步更新 KBDB partner 記錄
|
||||||
|
// 讓 ak_xxx Key 可以直接存取 KBDB(不需要第二把 Key)
|
||||||
|
|
||||||
|
type KbdbEnv = {
|
||||||
|
KBDB_INTERNAL_TOKEN?: string;
|
||||||
|
KBDB_BASE_URL?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function kbdbBase(env: KbdbEnv): string {
|
||||||
|
return (env.KBDB_BASE_URL ?? 'https://kbdb.finally.click').replace(/\/$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sha256Hex(input: string): Promise<string> {
|
||||||
|
const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(input));
|
||||||
|
return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在 KBDB 建立或更新 Arcrun 用戶的 partner 記錄。
|
||||||
|
* 失敗時靜默 log,不影響 Arcrun 登入流程。
|
||||||
|
*/
|
||||||
|
export async function ensureKbdbPartner(env: KbdbEnv, email: string, apiKey: string): Promise<void> {
|
||||||
|
const token = env.KBDB_INTERNAL_TOKEN;
|
||||||
|
if (!token) {
|
||||||
|
console.warn('[kbdb-partner] KBDB_INTERNAL_TOKEN not set, skipping');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const apiKeyHash = await sha256Hex(apiKey);
|
||||||
|
const base = kbdbBase(env);
|
||||||
|
|
||||||
|
const res = await fetch(`${base}/admin/partners/by-key-hash`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: `arcrun:${email}`,
|
||||||
|
org_namespace: `arcrun:${email}`,
|
||||||
|
api_key_hash: apiKeyHash,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.text().catch(() => '');
|
||||||
|
console.error(`[kbdb-partner] ensureKbdbPartner failed: ${res.status} ${body}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[kbdb-partner] ensureKbdbPartner error:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 撤銷 KBDB 中對應的 partner 記錄(使用舊的 api_key_hash 找到並刪除)。
|
||||||
|
* 失敗時靜默 log。
|
||||||
|
*/
|
||||||
|
export async function revokeKbdbPartner(env: KbdbEnv, oldApiKey: string): Promise<void> {
|
||||||
|
const token = env.KBDB_INTERNAL_TOKEN;
|
||||||
|
if (!token) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const oldHash = await sha256Hex(oldApiKey);
|
||||||
|
const partnerId = `partner-arcrun-${oldHash.slice(0, 16)}`;
|
||||||
|
const base = kbdbBase(env);
|
||||||
|
|
||||||
|
const res = await fetch(`${base}/admin/partners/${partnerId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Authorization': `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok && res.status !== 404) {
|
||||||
|
const body = await res.text().catch(() => '');
|
||||||
|
console.error(`[kbdb-partner] revokeKbdbPartner failed: ${res.status} ${body}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[kbdb-partner] revokeKbdbPartner error:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,6 +35,12 @@ export interface WasiShim {
|
|||||||
getStderr(): string;
|
getStderr(): string;
|
||||||
/** 注入 WebAssembly.Memory(instantiate 後呼叫) */
|
/** 注入 WebAssembly.Memory(instantiate 後呼叫) */
|
||||||
setMemory(memory: WebAssembly.Memory): void;
|
setMemory(memory: WebAssembly.Memory): void;
|
||||||
|
/**
|
||||||
|
* 執行 WASM _start,自動使用 WebAssembly.promising(JSPI)讓 async host
|
||||||
|
* function 能正確 suspend/resume。若 JSPI 不可用則 fallback 同步執行。
|
||||||
|
* 必須在 setMemory() 之後呼叫。
|
||||||
|
*/
|
||||||
|
run(instance: WebAssembly.Instance): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -50,10 +56,18 @@ export interface WasiHostFunctions {
|
|||||||
http_request?: (url: string, method: string, headers: string, body: string) => Promise<string>;
|
http_request?: (url: string, method: string, headers: string, body: string) => Promise<string>;
|
||||||
/** KV 讀取:key 前綴由 Worker 路由到對應 binding,並做越權檢查 */
|
/** KV 讀取:key 前綴由 Worker 路由到對應 binding,並做越權檢查 */
|
||||||
kv_get?: (key: string) => Promise<string | null>;
|
kv_get?: (key: string) => Promise<string | null>;
|
||||||
|
/** KV 寫入:用於快取 access_token 等短效值,ttlSeconds=0 表示不設 TTL */
|
||||||
|
kv_put?: (key: string, value: string, ttlSeconds: number) => Promise<void>;
|
||||||
/** AES-GCM 解密:encryption key 由 Worker 保管,不暴露給 WASM */
|
/** AES-GCM 解密:encryption key 由 Worker 保管,不暴露給 WASM */
|
||||||
crypto_decrypt?: (encryptedB64: string, ivB64: string) => Promise<string>;
|
crypto_decrypt?: (encryptedB64: string, ivB64: string) => Promise<string>;
|
||||||
/** RS256 簽章:用 crypto.subtle 做 RSASSA-PKCS1-v1_5 + SHA-256 */
|
/** RS256 簽章:用 crypto.subtle 做 RSASSA-PKCS1-v1_5 + SHA-256 */
|
||||||
crypto_sign_rs256?: (data: Uint8Array, pkcs8: Uint8Array) => Promise<Uint8Array>;
|
crypto_sign_rs256?: (data: Uint8Array, pkcs8: Uint8Array) => Promise<Uint8Array>;
|
||||||
|
/** HMAC-SHA256(data, ENCRYPTION_KEY) → raw bytes */
|
||||||
|
crypto_hmac_sha256?: (data: Uint8Array) => Promise<Uint8Array>;
|
||||||
|
/** AES-GCM 加密(plaintext, ENCRYPTION_KEY) → {encryptedB64, ivB64} */
|
||||||
|
crypto_aes_encrypt?: (plaintext: Uint8Array) => Promise<{ encryptedB64: string; ivB64: string }>;
|
||||||
|
/** crypto random bytes → hex string */
|
||||||
|
crypto_random_bytes?: (numBytes: number) => string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -157,6 +171,107 @@ export function createWasiShim(stdinData: string, hostFunctions?: WasiHostFuncti
|
|||||||
return WASI_ESUCCESS;
|
return WASI_ESUCCESS;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Asyncify protocol ──────────────────────────────────────────────────────
|
||||||
|
// TinyGo WASI target 永遠使用 asyncify scheduler。Asyncify 讓 WASM 能在呼叫 host
|
||||||
|
// function 時「unwind」(保存 call stack),待 async 工作完成後再「rewind」(恢復)。
|
||||||
|
//
|
||||||
|
// 協議流程(每次 async host function 呼叫):
|
||||||
|
// 1. WASM 呼叫 host import(例如 http_request)
|
||||||
|
// 2. Host 檢查 asyncify_get_state():
|
||||||
|
// - state=1(Unwinding): 正在展開,host 應直接回傳 0(佔位值)
|
||||||
|
// - state=2(Rewinding): 正在恢復,host 應回傳上一次 async 結果(已存在 asyncifyResult)
|
||||||
|
// - state=0(Normal): 正常執行,host 啟動 async 工作並呼叫 asyncify_start_unwind
|
||||||
|
// 3. WASM 的 _start 控制流回到 run()(asyncify 讓 _start 提前返回)
|
||||||
|
// 4. run() await async 工作,呼叫 asyncify_start_rewind,再次呼叫 _start
|
||||||
|
// 5. WASM 從 host import 返回點繼續執行,host 回傳儲存的結果
|
||||||
|
//
|
||||||
|
// 注意:每次 _start 呼叫只能處理一個 async 中斷點。若 WASM 有多個連續的 async host call,
|
||||||
|
// run() 會在 while 迴圈裡重複 rewind 直到 asyncify_get_state() == 0(Normal)。
|
||||||
|
|
||||||
|
// Asyncify 資料緩衝區設定(TinyGo asyncify 用於保存 call stack)
|
||||||
|
// 位址在 run() 中設定(WASM memory 末尾分配 1MB)
|
||||||
|
let asyncifyDataPtr = 0;
|
||||||
|
const ASYNCIFY_BUF_SIZE = 1024 * 1024; // 1MB stack buffer
|
||||||
|
|
||||||
|
// 儲存 async host function 的結果和 Promise
|
||||||
|
let asyncifyPendingPromise: Promise<number> | null = null;
|
||||||
|
let asyncifyResult: number = 0;
|
||||||
|
|
||||||
|
// asyncify exports(run() 設定後才可用)
|
||||||
|
let asyncifyExports: {
|
||||||
|
get_state: () => number;
|
||||||
|
start_unwind: (ptr: number) => void;
|
||||||
|
stop_unwind: () => void;
|
||||||
|
start_rewind: (ptr: number) => void;
|
||||||
|
stop_rewind: () => void;
|
||||||
|
} | null = null;
|
||||||
|
|
||||||
|
// JSPI helper:若環境支援 WebAssembly.Suspending,用它包裝 async import function
|
||||||
|
// 用於 scheduler=none 編譯的 WASM(無 asyncify exports)
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
function jspiSuspending<T extends (...args: any[]) => Promise<unknown>>(fn: T): T {
|
||||||
|
const SuspendingCtor = (WebAssembly as unknown as Record<string, unknown>)['Suspending'] as
|
||||||
|
(new (fn: T) => T) | undefined;
|
||||||
|
return SuspendingCtor ? new SuspendingCtor(fn) : fn;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 建立一個 asyncify-aware 的 host function wrapper
|
||||||
|
// 協議:Normal 時啟動 async 工作並呼叫 start_unwind;Rewinding 時回傳已存的結果
|
||||||
|
// 用於 scheduler=asyncify 編譯的 WASM(有 asyncify exports)
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
function asyncifyWrap(fn: (...args: any[]) => Promise<number>): (...args: any[]) => number {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
return (...args: any[]): number => {
|
||||||
|
if (!memory) return 1;
|
||||||
|
|
||||||
|
const ax = asyncifyExports;
|
||||||
|
if (!ax) return 0; // asyncify 尚未初始化(sync fallback)
|
||||||
|
|
||||||
|
const state = ax.get_state();
|
||||||
|
|
||||||
|
if (state === 2) {
|
||||||
|
// Rewinding:回傳上次 async 的真實結果
|
||||||
|
return asyncifyResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state === 1) {
|
||||||
|
// Unwinding 中:直接回傳 0(WASM 在 unwind,不使用此值)
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normal(state=0):啟動 async 工作,觸發 asyncify unwind
|
||||||
|
asyncifyPendingPromise = fn(...args);
|
||||||
|
|
||||||
|
// asyncify_start_unwind 設定 WASM 內部 unwind flag;
|
||||||
|
// host function 返回後 WASM 開始保存 call stack,最終 _start() 返回
|
||||||
|
ax.start_unwind(asyncifyDataPtr);
|
||||||
|
return 0; // WASM 忽略此值(正在 unwind)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根據 WASM 是否有 asyncify exports 決定使用哪種包裝方式
|
||||||
|
// JSPI mode: scheduler=none WASM + WebAssembly.Suspending
|
||||||
|
// asyncify mode: scheduler=asyncify WASM + asyncify protocol
|
||||||
|
// 初始化時先用 asyncifyWrap,run() 後若沒有 asyncify exports 就切換到 jspiSuspending
|
||||||
|
// 但因為 imports 在 instantiate 前就需要確定,這裡統一先用 asyncifyWrap
|
||||||
|
// run() 時若發現沒有 asyncify exports 且有 JSPI,則使用 JSPI 模式
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
function hostWrap(fn: (...args: any[]) => Promise<number>): (...args: any[]) => number | Promise<number> {
|
||||||
|
// 嘗試使用 JSPI Suspending(若環境支援)
|
||||||
|
const SuspendingCtor = (WebAssembly as unknown as Record<string, unknown>)['Suspending'] as
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
(new (fn: any) => any) | undefined;
|
||||||
|
|
||||||
|
if (SuspendingCtor) {
|
||||||
|
// JSPI 可用:包裝為 Suspending,讓 WASM 能 suspend 等待 async 結果
|
||||||
|
// 這適用於 scheduler=none 的 WASM(無 asyncify 干擾)
|
||||||
|
return new SuspendingCtor(fn);
|
||||||
|
}
|
||||||
|
|
||||||
|
// fallback:asyncify 協議(scheduler=asyncify WASM)
|
||||||
|
return asyncifyWrap(fn);
|
||||||
|
}
|
||||||
|
|
||||||
const shim: WasiShim = {
|
const shim: WasiShim = {
|
||||||
imports: {
|
imports: {
|
||||||
wasi_snapshot_preview1: { fd_write,
|
wasi_snapshot_preview1: { fd_write,
|
||||||
@@ -215,9 +330,10 @@ export function createWasiShim(stdinData: string, hostFunctions?: WasiHostFuncti
|
|||||||
},
|
},
|
||||||
// u6u host functions:讓 .wasm 零件透過 host function 呼叫外部服務
|
// u6u host functions:讓 .wasm 零件透過 host function 呼叫外部服務
|
||||||
// .wasm 零件用 //go:wasmimport u6u <name> 宣告
|
// .wasm 零件用 //go:wasmimport u6u <name> 宣告
|
||||||
|
// 所有 async host function 透過 asyncifyWrap 包裝,實作 asyncify 協議
|
||||||
u6u: {
|
u6u: {
|
||||||
http_request: hostFunctions?.http_request
|
http_request: hostFunctions?.http_request
|
||||||
? async (urlPtr: number, urlLen: number, methodPtr: number, methodLen: number,
|
? hostWrap(async (urlPtr: number, urlLen: number, methodPtr: number, methodLen: number,
|
||||||
headersPtr: number, headersLen: number, bodyPtr: number, bodyLen: number,
|
headersPtr: number, headersLen: number, bodyPtr: number, bodyLen: number,
|
||||||
outPtr: number, outLenPtr: number): Promise<number> => {
|
outPtr: number, outLenPtr: number): Promise<number> => {
|
||||||
if (!memory) return 1;
|
if (!memory) return 1;
|
||||||
@@ -235,28 +351,50 @@ export function createWasiShim(stdinData: string, hostFunctions?: WasiHostFuncti
|
|||||||
} catch {
|
} catch {
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
: () => 1,
|
: () => 1,
|
||||||
|
|
||||||
// kv_get(keyPtr, keyLen, outPtr, outLenPtr) → 0 成功;1 錯誤;2 找不到 key
|
// kv_get(keyPtr, keyLen, outPtr, outLenPtr) → 0 成功;1 錯誤;2 找不到 key
|
||||||
kv_get: hostFunctions?.kv_get
|
kv_get: hostFunctions?.kv_get
|
||||||
? async (keyPtr: number, keyLen: number, outPtr: number, outLenPtr: number): Promise<number> => {
|
? hostWrap(async (keyPtr: number, keyLen: number, outPtr: number, outLenPtr: number): Promise<number> => {
|
||||||
if (!memory) return 1;
|
if (!memory) { console.error('[kv_get] memory null'); return 1; }
|
||||||
const key = new TextDecoder().decode(new Uint8Array(memory.buffer, keyPtr, keyLen));
|
const key = new TextDecoder().decode(new Uint8Array(memory.buffer, keyPtr, keyLen));
|
||||||
|
console.error(`[kv_get] key="${key}" keyPtr=${keyPtr} keyLen=${keyLen} outPtr=${outPtr} outLenPtr=${outLenPtr}`);
|
||||||
try {
|
try {
|
||||||
const result = await hostFunctions!.kv_get!(key);
|
const result = await hostFunctions!.kv_get!(key);
|
||||||
|
console.error(`[kv_get] result=${result === null ? 'null' : result.slice(0, 80)}`);
|
||||||
if (result === null) return 2;
|
if (result === null) return 2;
|
||||||
return writeOut(memory.buffer, outPtr, outLenPtr, new TextEncoder().encode(result));
|
const encoded = new TextEncoder().encode(result);
|
||||||
|
const status = writeOut(memory.buffer, outPtr, outLenPtr, encoded);
|
||||||
|
console.error(`[kv_get] writeOut status=${status} encodedLen=${encoded.length} memBufLen=${memory.buffer.byteLength}`);
|
||||||
|
return status;
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[kv_get] error: ${e}`);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
: () => 1,
|
||||||
|
|
||||||
|
// kv_put(keyPtr, keyLen, valPtr, valLen, ttlSeconds) → 0 成功;1 錯誤
|
||||||
|
kv_put: hostFunctions?.kv_put
|
||||||
|
? hostWrap(async (keyPtr: number, keyLen: number, valPtr: number, valLen: number, ttlSeconds: number): Promise<number> => {
|
||||||
|
if (!memory) return 1;
|
||||||
|
const dec = new TextDecoder();
|
||||||
|
const key = dec.decode(new Uint8Array(memory.buffer, keyPtr, keyLen));
|
||||||
|
const value = dec.decode(new Uint8Array(memory.buffer, valPtr, valLen));
|
||||||
|
try {
|
||||||
|
await hostFunctions!.kv_put!(key, value, ttlSeconds);
|
||||||
|
return 0;
|
||||||
} catch {
|
} catch {
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
: () => 1,
|
: () => 1,
|
||||||
|
|
||||||
// crypto_decrypt(encPtr, encLen, ivPtr, ivLen, outPtr, outLenPtr) → 0 成功
|
// crypto_decrypt(encPtr, encLen, ivPtr, ivLen, outPtr, outLenPtr) → 0 成功
|
||||||
// 輸入皆為 base64 字串(WASM 從 KV 讀到什麼就送什麼)
|
// 輸入皆為 base64 字串(WASM 從 KV 讀到什麼就送什麼)
|
||||||
crypto_decrypt: hostFunctions?.crypto_decrypt
|
crypto_decrypt: hostFunctions?.crypto_decrypt
|
||||||
? async (encPtr: number, encLen: number, ivPtr: number, ivLen: number,
|
? hostWrap(async (encPtr: number, encLen: number, ivPtr: number, ivLen: number,
|
||||||
outPtr: number, outLenPtr: number): Promise<number> => {
|
outPtr: number, outLenPtr: number): Promise<number> => {
|
||||||
if (!memory) return 1;
|
if (!memory) return 1;
|
||||||
const dec = new TextDecoder();
|
const dec = new TextDecoder();
|
||||||
@@ -268,12 +406,12 @@ export function createWasiShim(stdinData: string, hostFunctions?: WasiHostFuncti
|
|||||||
} catch {
|
} catch {
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
: () => 1,
|
: () => 1,
|
||||||
|
|
||||||
// crypto_sign_rs256(dataPtr, dataLen, pkcs8Ptr, pkcs8Len, outPtr, outLenPtr) → 0 成功
|
// crypto_sign_rs256(dataPtr, dataLen, pkcs8Ptr, pkcs8Len, outPtr, outLenPtr) → 0 成功
|
||||||
crypto_sign_rs256: hostFunctions?.crypto_sign_rs256
|
crypto_sign_rs256: hostFunctions?.crypto_sign_rs256
|
||||||
? async (dataPtr: number, dataLen: number, pkcs8Ptr: number, pkcs8Len: number,
|
? hostWrap(async (dataPtr: number, dataLen: number, pkcs8Ptr: number, pkcs8Len: number,
|
||||||
outPtr: number, outLenPtr: number): Promise<number> => {
|
outPtr: number, outLenPtr: number): Promise<number> => {
|
||||||
if (!memory) return 1;
|
if (!memory) return 1;
|
||||||
// await 前複製 typed array(避免 memory grow 後 buffer 失效)
|
// await 前複製 typed array(避免 memory grow 後 buffer 失效)
|
||||||
@@ -285,6 +423,53 @@ export function createWasiShim(stdinData: string, hostFunctions?: WasiHostFuncti
|
|||||||
} catch {
|
} catch {
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
: () => 1,
|
||||||
|
|
||||||
|
// crypto_hmac_sha256(dataPtr, dataLen, outPtr, outLenPtr) → 0 成功,output = raw bytes
|
||||||
|
crypto_hmac_sha256: hostFunctions?.crypto_hmac_sha256
|
||||||
|
? hostWrap(async (dataPtr: number, dataLen: number, outPtr: number, outLenPtr: number): Promise<number> => {
|
||||||
|
if (!memory) return 1;
|
||||||
|
const data = new Uint8Array(new Uint8Array(memory.buffer, dataPtr, dataLen));
|
||||||
|
try {
|
||||||
|
const sig = await hostFunctions!.crypto_hmac_sha256!(data);
|
||||||
|
return writeOut(memory.buffer, outPtr, outLenPtr, sig);
|
||||||
|
} catch {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
: () => 1,
|
||||||
|
|
||||||
|
// crypto_aes_encrypt(plaintextPtr, plaintextLen, outEncPtr, outEncLenPtr, outIvPtr, outIvLenPtr) → 0 成功
|
||||||
|
crypto_aes_encrypt: hostFunctions?.crypto_aes_encrypt
|
||||||
|
? hostWrap(async (plaintextPtr: number, plaintextLen: number,
|
||||||
|
outEncPtr: number, outEncLenPtr: number,
|
||||||
|
outIvPtr: number, outIvLenPtr: number): Promise<number> => {
|
||||||
|
if (!memory) return 1;
|
||||||
|
const plaintext = new Uint8Array(new Uint8Array(memory.buffer, plaintextPtr, plaintextLen));
|
||||||
|
try {
|
||||||
|
const { encryptedB64, ivB64 } = await hostFunctions!.crypto_aes_encrypt!(plaintext);
|
||||||
|
const encBytes = new TextEncoder().encode(encryptedB64);
|
||||||
|
const ivBytes = new TextEncoder().encode(ivB64);
|
||||||
|
const s1 = writeOut(memory.buffer, outEncPtr, outEncLenPtr, encBytes);
|
||||||
|
const s2 = writeOut(memory.buffer, outIvPtr, outIvLenPtr, ivBytes);
|
||||||
|
return s1 !== 0 ? s1 : s2;
|
||||||
|
} catch {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
: () => 1,
|
||||||
|
|
||||||
|
// crypto_random_bytes(numBytes, outPtr, outLenPtr) → 0 成功,output = hex string
|
||||||
|
crypto_random_bytes: hostFunctions?.crypto_random_bytes
|
||||||
|
? (numBytes: number, outPtr: number, outLenPtr: number): number => {
|
||||||
|
if (!memory) return 1;
|
||||||
|
try {
|
||||||
|
const hexStr = hostFunctions!.crypto_random_bytes!(numBytes);
|
||||||
|
return writeOut(memory.buffer, outPtr, outLenPtr, new TextEncoder().encode(hexStr));
|
||||||
|
} catch {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
: () => 1,
|
: () => 1,
|
||||||
},
|
},
|
||||||
@@ -294,6 +479,102 @@ export function createWasiShim(stdinData: string, hostFunctions?: WasiHostFuncti
|
|||||||
memory = mem;
|
memory = mem;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async run(instance: WebAssembly.Instance): Promise<void> {
|
||||||
|
const exp = instance.exports as Record<string, unknown>;
|
||||||
|
const startFn = (exp._start ?? exp.main) as (() => void) | undefined;
|
||||||
|
if (typeof startFn !== 'function') throw new Error('WASM missing _start or main export');
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const promisingFn = (WebAssembly as unknown as Record<string, unknown>)['promising'] as
|
||||||
|
((fn: () => void) => () => Promise<void>) | undefined;
|
||||||
|
|
||||||
|
// 若環境支援 JSPI(Cloudflare Workers 2025+),優先使用 WebAssembly.promising
|
||||||
|
// hostWrap() 已將 imports 包裝為 WebAssembly.Suspending,不需要 asyncify 協議
|
||||||
|
if (promisingFn) {
|
||||||
|
try {
|
||||||
|
await promisingFn(startFn)();
|
||||||
|
} catch (e) {
|
||||||
|
if (!(e instanceof Error && e.message === 'wasm exit: 0')) throw e;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// JSPI 不可用:使用 asyncify 協議(需要 WASM 有 asyncify exports)
|
||||||
|
const asyncifyGetState = exp.asyncify_get_state as (() => number) | undefined;
|
||||||
|
const asyncifyStartUnwind = exp.asyncify_start_unwind as ((ptr: number) => void) | undefined;
|
||||||
|
const asyncifyStopUnwind = exp.asyncify_stop_unwind as (() => void) | undefined;
|
||||||
|
const asyncifyStartRewind = exp.asyncify_start_rewind as ((ptr: number) => void) | undefined;
|
||||||
|
const asyncifyStopRewind = exp.asyncify_stop_rewind as (() => void) | undefined;
|
||||||
|
|
||||||
|
if (asyncifyGetState && asyncifyStartUnwind && asyncifyStopUnwind &&
|
||||||
|
asyncifyStartRewind && asyncifyStopRewind) {
|
||||||
|
asyncifyExports = {
|
||||||
|
get_state: asyncifyGetState,
|
||||||
|
start_unwind: asyncifyStartUnwind,
|
||||||
|
stop_unwind: asyncifyStopUnwind,
|
||||||
|
start_rewind: asyncifyStartRewind,
|
||||||
|
stop_rewind: asyncifyStopRewind,
|
||||||
|
};
|
||||||
|
|
||||||
|
const mallocFn = exp.malloc as ((size: number) => number) | undefined;
|
||||||
|
if (mallocFn && memory) {
|
||||||
|
const totalSize = ASYNCIFY_BUF_SIZE;
|
||||||
|
asyncifyDataPtr = mallocFn(totalSize);
|
||||||
|
const view = new DataView(memory.buffer);
|
||||||
|
view.setInt32(asyncifyDataPtr, asyncifyDataPtr + 8, true);
|
||||||
|
view.setInt32(asyncifyDataPtr + 4, asyncifyDataPtr + totalSize, true);
|
||||||
|
} else if (memory) {
|
||||||
|
const memBytes = memory.buffer.byteLength;
|
||||||
|
asyncifyDataPtr = memBytes - ASYNCIFY_BUF_SIZE;
|
||||||
|
if (asyncifyDataPtr > 8) {
|
||||||
|
const view = new DataView(memory.buffer);
|
||||||
|
view.setInt32(asyncifyDataPtr, asyncifyDataPtr + 8, true);
|
||||||
|
view.setInt32(asyncifyDataPtr + 4, asyncifyDataPtr + ASYNCIFY_BUF_SIZE, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// JSPI 不可用且無 asyncify exports:同步執行(host function 不能 async)
|
||||||
|
if (!asyncifyExports) {
|
||||||
|
try { startFn(); } catch (e) {
|
||||||
|
if (!(e instanceof Error && e.message === 'wasm exit: 0')) throw e;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 主執行迴圈:每次呼叫 _start,若 asyncify 捕捉到 pending promise 就 await 再 rewind
|
||||||
|
let rewinding = false;
|
||||||
|
while (true) {
|
||||||
|
asyncifyPendingPromise = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (rewinding) {
|
||||||
|
asyncifyExports.start_rewind(asyncifyDataPtr);
|
||||||
|
startFn();
|
||||||
|
asyncifyExports.stop_rewind();
|
||||||
|
} else {
|
||||||
|
startFn();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Error && e.message === 'wasm exit: 0') break;
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 若 asyncifyWrap 觸發了 unwind,_start 會因 unwind 返回(沒有 exit)
|
||||||
|
// asyncifyWrap 已呼叫 start_unwind,這裡只需 stop_unwind 並 await promise
|
||||||
|
if (asyncifyPendingPromise !== null) {
|
||||||
|
asyncifyExports.stop_unwind();
|
||||||
|
asyncifyResult = await asyncifyPendingPromise;
|
||||||
|
asyncifyPendingPromise = null;
|
||||||
|
rewinding = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 沒有 pending promise 且沒有 exit → 正常完成
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
getStdout(): string {
|
getStdout(): string {
|
||||||
if (stdoutChunks.length === 0) return '';
|
if (stdoutChunks.length === 0) return '';
|
||||||
const total = stdoutChunks.reduce((n, c) => n + c.length, 0);
|
const total = stdoutChunks.reduce((n, c) => n + c.length, 0);
|
||||||
@@ -366,6 +647,20 @@ async function routedKvGet(env: ArcrunHostEnv, apiKey: string, key: string): Pro
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 依 key 前綴路由寫入 KV。只允許寫 oauth2 cache key(短效 access_token)。
|
||||||
|
* - `{apiKey}:oauth2:{service}:*` → env.CREDENTIALS_KV(越權檢查)
|
||||||
|
*/
|
||||||
|
async function routedKvPut(env: ArcrunHostEnv, apiKey: string, key: string, value: string, ttlSeconds: number): Promise<void> {
|
||||||
|
const oauth2Match = key.match(/^([^:]+):oauth2:.+$/);
|
||||||
|
if (oauth2Match && oauth2Match[1] === apiKey) {
|
||||||
|
const opts = ttlSeconds > 0 ? { expirationTtl: ttlSeconds } : undefined;
|
||||||
|
await env.CREDENTIALS_KV.put(key, value, opts);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 其他 key 前綴拒絕寫入(安全邊界)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AES-GCM 解密。encryption key 由 env.ENCRYPTION_KEY 在本 function 內讀取,
|
* AES-GCM 解密。encryption key 由 env.ENCRYPTION_KEY 在本 function 內讀取,
|
||||||
* 永不傳給 WASM。輸入為 base64 字串,輸出為 UTF-8 plaintext。
|
* 永不傳給 WASM。輸入為 base64 字串,輸出為 UTF-8 plaintext。
|
||||||
@@ -408,7 +703,39 @@ async function rsaPkcs1Sha256Sign(data: Uint8Array, pkcs8: Uint8Array): Promise<
|
|||||||
export function createArcrunHostFunctions(env: ArcrunHostEnv, apiKey: string): WasiHostFunctions {
|
export function createArcrunHostFunctions(env: ArcrunHostEnv, apiKey: string): WasiHostFunctions {
|
||||||
return {
|
return {
|
||||||
kv_get: (key: string) => routedKvGet(env, apiKey, key),
|
kv_get: (key: string) => routedKvGet(env, apiKey, key),
|
||||||
|
kv_put: (key: string, value: string, ttlSeconds: number) => routedKvPut(env, apiKey, key, value, ttlSeconds),
|
||||||
crypto_decrypt: (encB64: string, ivB64: string) => aesGcmDecrypt(env, encB64, ivB64),
|
crypto_decrypt: (encB64: string, ivB64: string) => aesGcmDecrypt(env, encB64, ivB64),
|
||||||
crypto_sign_rs256: (data: Uint8Array, pkcs8: Uint8Array) => rsaPkcs1Sha256Sign(data, pkcs8),
|
crypto_sign_rs256: (data: Uint8Array, pkcs8: Uint8Array) => rsaPkcs1Sha256Sign(data, pkcs8),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 建立 platform_crypto host functions。
|
||||||
|
* 不需要 apiKey 或 KV routing,只提供加密操作。
|
||||||
|
* ENCRYPTION_KEY 在 closure 內,永不傳給 WASM。
|
||||||
|
*/
|
||||||
|
export function createPlatformCryptoHostFunctions(encryptionKey: string): WasiHostFunctions {
|
||||||
|
const toB64 = (buf: ArrayBuffer): string => btoa(String.fromCharCode(...new Uint8Array(buf)));
|
||||||
|
|
||||||
|
return {
|
||||||
|
crypto_hmac_sha256: async (data: Uint8Array): Promise<Uint8Array> => {
|
||||||
|
const keyBytes = new TextEncoder().encode(encryptionKey.slice(0, 32));
|
||||||
|
const cryptoKey = await crypto.subtle.importKey('raw', keyBytes, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
|
||||||
|
const sig = await crypto.subtle.sign('HMAC', cryptoKey, data);
|
||||||
|
return new Uint8Array(sig);
|
||||||
|
},
|
||||||
|
|
||||||
|
crypto_aes_encrypt: async (plaintext: Uint8Array): Promise<{ encryptedB64: string; ivB64: string }> => {
|
||||||
|
const keyBytes = new TextEncoder().encode(encryptionKey.slice(0, 32));
|
||||||
|
const cryptoKey = await crypto.subtle.importKey('raw', keyBytes, { name: 'AES-GCM' }, false, ['encrypt']);
|
||||||
|
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||||
|
const enc = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, cryptoKey, plaintext);
|
||||||
|
return { encryptedB64: toB64(enc), ivB64: toB64(iv.buffer) };
|
||||||
|
},
|
||||||
|
|
||||||
|
crypto_random_bytes: (numBytes: number): string => {
|
||||||
|
const arr = crypto.getRandomValues(new Uint8Array(numBytes));
|
||||||
|
return Array.from(arr).map(b => b.toString(16).padStart(2, '0')).join('');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
|
|
||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import type { Bindings } from '../types';
|
import type { Bindings } from '../types';
|
||||||
|
import { ensureKbdbPartner, revokeKbdbPartner } from '../lib/kbdb-partner';
|
||||||
|
|
||||||
export const authRouter = new Hono<{ Bindings: Bindings }>();
|
export const authRouter = new Hono<{ Bindings: Bindings }>();
|
||||||
|
|
||||||
@@ -60,6 +61,24 @@ async function generateApiKey(email: string, encryptionKey: string): Promise<str
|
|||||||
return 'ak_' + hex.slice(0, 32);
|
return 'ak_' + hex.slice(0, 32);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** AES-GCM 加密,回傳 {encrypted, iv}(base64),與 SDK 格式相同 */
|
||||||
|
async function aesEncrypt(plaintext: string, encryptionKey: string): Promise<{ encrypted: string; iv: string }> {
|
||||||
|
const keyBytes = new TextEncoder().encode(encryptionKey.slice(0, 32));
|
||||||
|
const cryptoKey = await crypto.subtle.importKey('raw', keyBytes, { name: 'AES-GCM' }, false, ['encrypt']);
|
||||||
|
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||||
|
const enc = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, cryptoKey, new TextEncoder().encode(plaintext));
|
||||||
|
const toB64 = (buf: ArrayBuffer | Uint8Array) => btoa(String.fromCharCode(...new Uint8Array(buf instanceof ArrayBuffer ? buf : buf)));
|
||||||
|
return { encrypted: toB64(enc), iv: toB64(iv) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 幂等寫入 auth_recipe 到 RECIPES KV(若已存在相同版本則跳過) */
|
||||||
|
async function upsertAuthRecipe(recipes: KVNamespace, recipe: Record<string, unknown>): Promise<void> {
|
||||||
|
const key = `auth_recipe:${recipe.service}`;
|
||||||
|
const existing = await recipes.get(key);
|
||||||
|
if (existing) return; // 已存在,不覆蓋(用戶可能已自訂)
|
||||||
|
await recipes.put(key, JSON.stringify({ ...recipe, created_at: Date.now(), updated_at: Date.now() }));
|
||||||
|
}
|
||||||
|
|
||||||
/** 產生隨機 token(用於 session ID 和 state) */
|
/** 產生隨機 token(用於 session ID 和 state) */
|
||||||
function randomToken(bytes = 32): string {
|
function randomToken(bytes = 32): string {
|
||||||
const arr = new Uint8Array(bytes);
|
const arr = new Uint8Array(bytes);
|
||||||
@@ -129,7 +148,7 @@ authRouter.get('/auth/google/start', async (c) => {
|
|||||||
scope: 'openid profile email',
|
scope: 'openid profile email',
|
||||||
state,
|
state,
|
||||||
access_type: 'offline',
|
access_type: 'offline',
|
||||||
prompt: 'select_account',
|
prompt: 'consent',
|
||||||
});
|
});
|
||||||
|
|
||||||
return Response.redirect(`https://accounts.google.com/o/oauth2/v2/auth?${params}`, 302);
|
return Response.redirect(`https://accounts.google.com/o/oauth2/v2/auth?${params}`, 302);
|
||||||
@@ -207,7 +226,7 @@ authRouter.get('/auth/callback', async (c) => {
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
if (!tokenRes.ok) throw new Error('google token exchange failed');
|
if (!tokenRes.ok) throw new Error('google token exchange failed');
|
||||||
const tokenData = await tokenRes.json() as { access_token: string };
|
const tokenData = await tokenRes.json() as { access_token: string; refresh_token?: string };
|
||||||
|
|
||||||
// Get user info
|
// Get user info
|
||||||
const userRes = await fetch('https://www.googleapis.com/oauth2/v3/userinfo', {
|
const userRes = await fetch('https://www.googleapis.com/oauth2/v3/userinfo', {
|
||||||
@@ -222,6 +241,32 @@ authRouter.get('/auth/callback', async (c) => {
|
|||||||
avatarUrl = userInfo.picture;
|
avatarUrl = userInfo.picture;
|
||||||
providerId = userInfo.sub;
|
providerId = userInfo.sub;
|
||||||
|
|
||||||
|
// 存 Google refresh_token(加密)到 CREDENTIALS_KV,供 auth_oauth2 零件使用
|
||||||
|
// Google 只在首次授權時回傳 refresh_token,後續登入 tokenData.refresh_token 為 undefined
|
||||||
|
if (tokenData.refresh_token) {
|
||||||
|
const credKey = `${await generateApiKey(email, encryptionKey)}:cred:google_refresh_token`;
|
||||||
|
const encrypted = await aesEncrypt(tokenData.refresh_token, encryptionKey);
|
||||||
|
await c.env.CREDENTIALS_KV.put(credKey, JSON.stringify(encrypted));
|
||||||
|
|
||||||
|
// 種 auth_recipe:google_user(用戶自己的 Google OAuth2)
|
||||||
|
void upsertAuthRecipe(c.env.RECIPES, {
|
||||||
|
kind: 'auth_recipe',
|
||||||
|
service: 'google_user',
|
||||||
|
version: 1,
|
||||||
|
primitive: 'oauth2',
|
||||||
|
base_url: 'https://www.googleapis.com',
|
||||||
|
display_name: 'Google(用戶帳號)',
|
||||||
|
oauth2: {
|
||||||
|
token_endpoint: 'https://oauth2.googleapis.com/token',
|
||||||
|
client_id: c.env.GOOGLE_CLIENT_ID ?? '',
|
||||||
|
client_secret: c.env.GOOGLE_CLIENT_SECRET ?? '',
|
||||||
|
scopes: ['https://www.googleapis.com/auth/drive', 'https://www.googleapis.com/auth/spreadsheets'],
|
||||||
|
},
|
||||||
|
required_secrets: [{ key: 'google_refresh_token', label: 'Google Refresh Token' }],
|
||||||
|
inject: { header: { Authorization: 'Bearer {{runtime.access_token}}' } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
// GitHub: exchange code for token
|
// GitHub: exchange code for token
|
||||||
const tokenRes = await fetch('https://github.com/login/oauth/access_token', {
|
const tokenRes = await fetch('https://github.com/login/oauth/access_token', {
|
||||||
@@ -238,7 +283,7 @@ authRouter.get('/auth/callback', async (c) => {
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
if (!tokenRes.ok) throw new Error('github token exchange failed');
|
if (!tokenRes.ok) throw new Error('github token exchange failed');
|
||||||
const tokenData = await tokenRes.json() as { access_token: string };
|
const tokenData = await tokenRes.json() as { access_token: string; token_type?: string };
|
||||||
|
|
||||||
// Get user info
|
// Get user info
|
||||||
const userRes = await fetch('https://api.github.com/user', {
|
const userRes = await fetch('https://api.github.com/user', {
|
||||||
@@ -274,6 +319,26 @@ authRouter.get('/auth/callback', async (c) => {
|
|||||||
displayName = userInfo.name ?? userInfo.login;
|
displayName = userInfo.name ?? userInfo.login;
|
||||||
avatarUrl = userInfo.avatar_url;
|
avatarUrl = userInfo.avatar_url;
|
||||||
providerId = String(userInfo.id);
|
providerId = String(userInfo.id);
|
||||||
|
|
||||||
|
// 存 GitHub access_token(加密)到 CREDENTIALS_KV,供 auth_oauth2 零件使用
|
||||||
|
// GitHub 沒有 refresh_token,access_token 長效(直到 revoke)
|
||||||
|
if (tokenData.access_token) {
|
||||||
|
const credKey = `${await generateApiKey(email, encryptionKey)}:cred:github_access_token`;
|
||||||
|
const encrypted = await aesEncrypt(tokenData.access_token, encryptionKey);
|
||||||
|
await c.env.CREDENTIALS_KV.put(credKey, JSON.stringify(encrypted));
|
||||||
|
|
||||||
|
// GitHub access_token 長效無 refresh 概念,用 static_key primitive
|
||||||
|
void upsertAuthRecipe(c.env.RECIPES, {
|
||||||
|
kind: 'auth_recipe',
|
||||||
|
service: 'github_user',
|
||||||
|
version: 1,
|
||||||
|
primitive: 'static_key',
|
||||||
|
base_url: 'https://api.github.com',
|
||||||
|
display_name: 'GitHub(用戶帳號)',
|
||||||
|
required_secrets: [{ key: 'github_access_token', label: 'GitHub Access Token' }],
|
||||||
|
inject: { header: { Authorization: 'Bearer {{secret.github_access_token}}' } },
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Upsert user record
|
// Upsert user record
|
||||||
@@ -300,6 +365,9 @@ authRouter.get('/auth/callback', async (c) => {
|
|||||||
await c.env.USERS_KV.put(`apikey:${apiKey}`, userKey);
|
await c.env.USERS_KV.put(`apikey:${apiKey}`, userKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 同步 KBDB partner 記錄(允許 ak_xxx 直接存取 KBDB)
|
||||||
|
void ensureKbdbPartner(c.env, email, apiKey);
|
||||||
|
|
||||||
// Create session (TTL 7 days)
|
// Create session (TTL 7 days)
|
||||||
const sessionId = randomToken(32);
|
const sessionId = randomToken(32);
|
||||||
const session: SessionRecord = {
|
const session: SessionRecord = {
|
||||||
@@ -317,7 +385,7 @@ authRouter.get('/auth/callback', async (c) => {
|
|||||||
status: 302,
|
status: 302,
|
||||||
headers: {
|
headers: {
|
||||||
Location: `${landingOrigin}${redirectBack}`,
|
Location: `${landingOrigin}${redirectBack}`,
|
||||||
'Set-Cookie': `arcrun_session=${sessionId}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=${7 * 24 * 60 * 60}`,
|
'Set-Cookie': `arcrun_session=${sessionId}; Path=/; HttpOnly; Secure; SameSite=Lax; Domain=.arcrun.dev; Max-Age=${7 * 24 * 60 * 60}`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -339,7 +407,7 @@ authRouter.post('/auth/logout', async (c) => {
|
|||||||
status: 302,
|
status: 302,
|
||||||
headers: {
|
headers: {
|
||||||
Location: `${landingOrigin}/`,
|
Location: `${landingOrigin}/`,
|
||||||
'Set-Cookie': 'arcrun_session=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0',
|
'Set-Cookie': 'arcrun_session=; Path=/; HttpOnly; Secure; SameSite=Lax; Domain=.arcrun.dev; Max-Age=0',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -379,8 +447,9 @@ authRouter.put('/me/api-key/rotate', async (c) => {
|
|||||||
await c.env.USERS_KV.delete(`apikey:${oldKey}`);
|
await c.env.USERS_KV.delete(`apikey:${oldKey}`);
|
||||||
await c.env.USERS_KV.put(`apikey:${newKey}`, userKey);
|
await c.env.USERS_KV.put(`apikey:${newKey}`, userKey);
|
||||||
|
|
||||||
// Invalidate all current sessions for this user (simple: sessions will re-auth on next request)
|
// 更新 KBDB partner 記錄(舊 Key 撤銷,新 Key 建立)
|
||||||
// (Full invalidation would require listing all sessions, skip for now)
|
void revokeKbdbPartner(c.env, oldKey);
|
||||||
|
void ensureKbdbPartner(c.env, user.email, newKey);
|
||||||
|
|
||||||
return c.json({
|
return c.json({
|
||||||
success: true,
|
success: true,
|
||||||
@@ -400,6 +469,9 @@ authRouter.delete('/me/api-key', async (c) => {
|
|||||||
await c.env.USERS_KV.put(userKey, JSON.stringify(revoked));
|
await c.env.USERS_KV.put(userKey, JSON.stringify(revoked));
|
||||||
await c.env.USERS_KV.delete(`apikey:${user.api_key}`);
|
await c.env.USERS_KV.delete(`apikey:${user.api_key}`);
|
||||||
|
|
||||||
|
// 撤銷 KBDB partner 記錄
|
||||||
|
void revokeKbdbPartner(c.env, user.api_key);
|
||||||
|
|
||||||
// Clear session cookie
|
// Clear session cookie
|
||||||
const sessId = getSessionId(c.req.raw);
|
const sessId = getSessionId(c.req.raw);
|
||||||
if (sessId) await c.env.SESSIONS_KV.delete(`sess:${sessId}`);
|
if (sessId) await c.env.SESSIONS_KV.delete(`sess:${sessId}`);
|
||||||
@@ -408,7 +480,7 @@ authRouter.delete('/me/api-key', async (c) => {
|
|||||||
status: 200,
|
status: 200,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'Set-Cookie': 'arcrun_session=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0',
|
'Set-Cookie': 'arcrun_session=; Path=/; HttpOnly; Secure; SameSite=Lax; Domain=.arcrun.dev; Max-Age=0',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -102,6 +102,8 @@ service = "arcrun-ai-transform-run"
|
|||||||
ENVIRONMENT = "production"
|
ENVIRONMENT = "production"
|
||||||
# MULTI_TENANT = "true"
|
# MULTI_TENANT = "true"
|
||||||
# ENCRYPTION_KEY 透過 wrangler secret set 設定
|
# ENCRYPTION_KEY 透過 wrangler secret set 設定
|
||||||
|
KBDB_BASE_URL = "https://kbdb.finally.click"
|
||||||
|
# KBDB_INTERNAL_TOKEN 透過 wrangler secret set 設定
|
||||||
|
|
||||||
[[routes]]
|
[[routes]]
|
||||||
pattern = "cypher.arcrun.dev/*"
|
pattern = "cypher.arcrun.dev/*"
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"type": "service_account",
|
||||||
|
"project_id": "stanley-494303",
|
||||||
|
"private_key_id": "2f8ae8098cc8e1097502bea9d19d347c2555f2e5",
|
||||||
|
"private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCVobDw2q2isV5+\nOHJQ8BKFZUq3maeum/HtlYSxw0o5AMG3Rq2ZwxaMCIvCb0L1GJRidFhs+kEY34Mm\nWGTpuYHitl1mXY2R3P+JFYbji8xxJYX+CGeUwkWy4AKxr9rCQGQW5e/U8XiKOWcQ\nticSHwTYIINlEmwiwpGGsemCDi1XgIim6qP6gzWon4QqGpN732xUNB/mUUtE8bJT\naGTUX1SPGkjf3gelTyEW9HcrXqEXc9Q0W12AUkm0Ele2UkBdafA50UmqYOWnhkUM\nKPl+qzS++w2DFBUrdNqCdIdq1/6c8y8opUVK3vdkEB5oySANzVaSfHJtITTtFamL\npfd0+CozAgMBAAECggEAAneja/VPKQBKZ0PAWXv78jMqDj5RuNwIGzt8mqnLuYY9\nANarhNOQtZ0WdjhQyasuwd4v7xbD/V2s00m12vIc0C44UA3O3c7fxSo+X9Ytotf+\n46Ij2HIeUBrIMJc5FjxAP5hpt1Xb/6YpVZSLWbh6jfh18jQMOxkrk9BZ4gkgekbf\n2umGDh94qNQxGkVHDRp1eHPPK0nTxsAmlgoCRD3wZC+MfZ3+yuvop8A2JTK5Qzp9\nmBbxHOFiSDbekbXQ+mECD4NhUL4em9GnIL0PJZYQpSKLm9qorsOUG773O9DVjaf7\nEMnJO5X/evyeaLte+rz5NFmxJT3z0czfthq8cRZ8gQKBgQDEZclUDVxfpazkZ/3f\ngmU/ezXWZ7BjE25htPxmfl7pRNjXBcziye1ooIuErpbO1eezUPdS5DFTMwXx5R3P\nR49ogijf2NvBYkznJ0dXA9Z/RXqrVQXtu8AE6XFpQRkNrMCSdWLRXOOv6Mdwt+PC\nBw5QHl+L7LRBdnv2PgkdRR9BkwKBgQDDCqJs6ShjSQYQuX52c1rtdfxS3kEb201N\nksCR8D9woUmnljN5msf/7R+xj68TVVjD47zT2esmc4J/5VQFpgtiKkmeYQ1FSrCJ\nEss8dsDpx7WWM4TyMZRArKgT3t9Oye6ITasnU2UnWNUPmgxxlV2ZAyP399/UGCdR\noV21v1dY4QKBgFzqwyuO/qsJ2Rhe0s+SA5DbHAeKGvtk0A5N8DQViZXXSrfAOMT7\nP+UGP7vQVe0Zant9zOVcrLFuLFvbSBUvB/wryGbPVHjTpwqdnLTgTfT8zCKPITTu\nZNRuFYf0koPTvXANNWlUByzMdr8vYQQFDpJ9LDspC8cE5XUEuI8uirEdAoGAYwHe\n+dJRwjSrc4n1/EMKgUhHUfmoq29jimFYh7yhN5doQD4q2ywLIotIb2Y0xWepq6bL\nj+8rQ1WXhTzwrf1gAfDddhxmFCqZ+rsjmAngW8wZDaoRbrBkRYBfwdZ9HQ28nExw\n+YGH87VQUp8seewMm0PQ2mtln9CzBOkZHM2IlYECgYAl5JuN5iatSvOeErGRnM4Y\n6PqAHyZwUEFjROoL1gB583A19h8IbUyyzMlX5Q39fsM6my2r9lT6GR2HCyIQ7Gi4\nSet1NIM1hZaLjsMywB7dskU8RWzlQoaQTdLpt4SlYEs+/OpjhhFna4I3kYr2hwpy\n2ACng6qWC7/yzyT70ZGhUQ==\n-----END PRIVATE KEY-----\n",
|
||||||
|
"client_email": "n8n-stanley@stanley-494303.iam.gserviceaccount.com",
|
||||||
|
"client_id": "117786291283661214919",
|
||||||
|
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
|
||||||
|
"token_uri": "https://oauth2.googleapis.com/token",
|
||||||
|
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
|
||||||
|
"client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/n8n-stanley%40stanley-494303.iam.gserviceaccount.com",
|
||||||
|
"universe_domain": "googleapis.com"
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
// App root — screen switcher with persistent route
|
||||||
|
const { useState, useEffect } = React;
|
||||||
|
|
||||||
|
const SCREENS = [
|
||||||
|
{ id: 'landing', label: 'Landing' },
|
||||||
|
{ id: 'auth', label: 'Auth' },
|
||||||
|
{ id: 'dashboard', label: 'Dashboard' },
|
||||||
|
{ id: 'keys', label: 'API Keys' },
|
||||||
|
{ id: 'workflow', label: 'Workflow' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Synonyms from sidebar ids
|
||||||
|
const aliases = { apps: 'dashboard', workflows: 'dashboard', docs: 'landing', settings: 'keys' };
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const [screen, setScreen] = useState(() => {
|
||||||
|
const saved = localStorage.getItem('arcrun:screen');
|
||||||
|
return saved && SCREENS.some(s => s.id === saved) ? saved : 'landing';
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem('arcrun:screen', screen);
|
||||||
|
window.scrollTo(0, 0);
|
||||||
|
}, [screen]);
|
||||||
|
|
||||||
|
const nav = (id) => {
|
||||||
|
const resolved = aliases[id] || id;
|
||||||
|
if (SCREENS.some(s => s.id === resolved)) setScreen(resolved);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Current = {
|
||||||
|
landing: Landing,
|
||||||
|
auth: Auth,
|
||||||
|
dashboard: Dashboard,
|
||||||
|
keys: ApiKeys,
|
||||||
|
workflow: WorkflowViewer,
|
||||||
|
}[screen];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="app">
|
||||||
|
<Current onNav={nav} />
|
||||||
|
|
||||||
|
<div className="proto-switch" role="tablist" aria-label="Screen switcher">
|
||||||
|
{SCREENS.map(s => (
|
||||||
|
<button key={s.id}
|
||||||
|
className={screen === s.id ? 'active' : ''}
|
||||||
|
onClick={() => nav(s.id)}>
|
||||||
|
{s.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
// Top nav and sidebar
|
||||||
|
|
||||||
|
const TopNav = ({ onNav, current }) => {
|
||||||
|
const [scrolled, setScrolled] = React.useState(false);
|
||||||
|
React.useEffect(() => {
|
||||||
|
const onScroll = () => setScrolled(window.scrollY > 8);
|
||||||
|
window.addEventListener('scroll', onScroll);
|
||||||
|
return () => window.removeEventListener('scroll', onScroll);
|
||||||
|
}, []);
|
||||||
|
return (
|
||||||
|
<nav className={`topnav ${scrolled ? 'scrolled' : ''}`}>
|
||||||
|
<div className="flex gap-12" style={{alignItems: 'center'}}>
|
||||||
|
<Logo onClick={() => onNav('landing')} />
|
||||||
|
<div className="nav-links" style={{marginLeft: 20}}>
|
||||||
|
<a>Product</a>
|
||||||
|
<a>Docs</a>
|
||||||
|
<a>Pricing</a>
|
||||||
|
<a>Changelog</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-8" style={{alignItems: 'center'}}>
|
||||||
|
<button className="btn btn-ghost" onClick={() => onNav('auth')}>Log in</button>
|
||||||
|
<button className="btn btn-primary" onClick={() => onNav('auth')}>
|
||||||
|
Get started <Icon name="arrow_right" size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Footer = ({ onNav }) => (
|
||||||
|
<footer className="footer">
|
||||||
|
<div className="flex gap-12" style={{alignItems: 'center'}}>
|
||||||
|
<Logo size="sm" />
|
||||||
|
<span>© 2026 Arcrun Labs</span>
|
||||||
|
</div>
|
||||||
|
<div className="footer-links">
|
||||||
|
<a>Docs</a>
|
||||||
|
<a>Pricing</a>
|
||||||
|
<a>Changelog</a>
|
||||||
|
<a>Status</a>
|
||||||
|
<a>Privacy</a>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
|
||||||
|
// App shell with sidebar for logged-in screens
|
||||||
|
const Sidebar = ({ current, onNav }) => {
|
||||||
|
const items = [
|
||||||
|
{ id: 'dashboard', label: 'Dashboard', icon: 'home' },
|
||||||
|
{ id: 'apps', label: 'Apps', icon: 'grid', count: 6 },
|
||||||
|
{ id: 'workflows', label: 'Workflows', icon: 'workflow', count: 12 },
|
||||||
|
{ id: 'keys', label: 'API Keys', icon: 'key' },
|
||||||
|
{ id: 'docs', label: 'Docs', icon: 'book' },
|
||||||
|
];
|
||||||
|
const bottom = [
|
||||||
|
{ id: 'settings', label: 'Settings', icon: 'settings' },
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<aside className="sidebar">
|
||||||
|
<div className="sidebar-head">
|
||||||
|
<Logo size="md" onClick={() => onNav('landing')} />
|
||||||
|
</div>
|
||||||
|
<div className="sidebar-section">Workspace</div>
|
||||||
|
{items.map(it => (
|
||||||
|
<div key={it.id}
|
||||||
|
className={`sidebar-item ${current === it.id ? 'active' : ''}`}
|
||||||
|
onClick={() => onNav(it.id)}>
|
||||||
|
<span className="sb-ico"><Icon name={it.icon} size={15} /></span>
|
||||||
|
<span>{it.label}</span>
|
||||||
|
{it.count != null && <span className="sb-count">{it.count}</span>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div style={{flex: 1}} />
|
||||||
|
{bottom.map(it => (
|
||||||
|
<div key={it.id} className="sidebar-item" onClick={() => onNav(it.id)}>
|
||||||
|
<span className="sb-ico"><Icon name={it.icon} size={15} /></span>
|
||||||
|
<span>{it.label}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="sidebar-foot">
|
||||||
|
<div className="avatar-circ">MR</div>
|
||||||
|
<div className="meta">
|
||||||
|
<div className="name">Maya Rivera</div>
|
||||||
|
<div className="email">maya@northwind.co</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
Object.assign(window, { TopNav, Footer, Sidebar });
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
// Shared primitives: icons, logo, etc.
|
||||||
|
|
||||||
|
const Icon = ({ name, size = 16, stroke = 1.7 }) => {
|
||||||
|
const paths = {
|
||||||
|
arrow_right: <path d="M5 12h14M13 6l6 6-6 6" />,
|
||||||
|
arrow_left: <path d="M19 12H5M11 6l-6 6 6 6" />,
|
||||||
|
plus: <path d="M12 5v14M5 12h14" />,
|
||||||
|
copy: <><rect x="9" y="9" width="13" height="13" rx="2" /><path d="M5 15V5a2 2 0 0 1 2-2h10" /></>,
|
||||||
|
check: <path d="M20 6L9 17l-5-5" />,
|
||||||
|
close: <path d="M18 6L6 18M6 6l12 12" />,
|
||||||
|
eye: <><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" /><circle cx="12" cy="12" r="3" /></>,
|
||||||
|
search: <><circle cx="11" cy="11" r="7" /><path d="M21 21l-4.35-4.35" /></>,
|
||||||
|
warn: <><path d="M10.3 3.86L1.82 18a2 2 0 001.72 3h16.92a2 2 0 001.72-3L13.7 3.86a2 2 0 00-3.4 0z" /><line x1="12" y1="9" x2="12" y2="13" /><circle cx="12" cy="17" r="0.5" fill="currentColor" /></>,
|
||||||
|
home: <><path d="M3 10l9-7 9 7v10a2 2 0 01-2 2h-4a2 2 0 01-2-2v-5h-2v5a2 2 0 01-2 2H5a2 2 0 01-2-2V10z" /></>,
|
||||||
|
grid: <><rect x="3" y="3" width="7" height="7" rx="1" /><rect x="14" y="3" width="7" height="7" rx="1" /><rect x="3" y="14" width="7" height="7" rx="1" /><rect x="14" y="14" width="7" height="7" rx="1" /></>,
|
||||||
|
workflow: <><circle cx="5" cy="6" r="2" /><circle cx="19" cy="12" r="2" /><circle cx="5" cy="18" r="2" /><path d="M7 6h4a4 4 0 014 4v0m0 4a4 4 0 01-4 4H7" /></>,
|
||||||
|
key: <><circle cx="7.5" cy="15.5" r="4.5" /><path d="M10.68 12.32L21 2M17 6l3 3M15 8l3 3" /></>,
|
||||||
|
book: <><path d="M2 3h6a4 4 0 014 4v14a3 3 0 00-3-3H2zM22 3h-6a4 4 0 00-4 4v14a3 3 0 013-3h7z" /></>,
|
||||||
|
settings: <><circle cx="12" cy="12" r="3" /><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 01-2.83 2.83l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06a1.65 1.65 0 00.33-1.82 1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 012.83-2.83l.06.06A1.65 1.65 0 009 4.6a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09A1.65 1.65 0 0015 4.6a1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06A1.65 1.65 0 0019.4 9v0a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z" /></>,
|
||||||
|
chevron_right: <path d="M9 6l6 6-6 6" />,
|
||||||
|
chevron_down: <path d="M6 9l6 6 6-6" />,
|
||||||
|
external: <><path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6" /><path d="M15 3h6v6M10 14L21 3" /></>,
|
||||||
|
trash: <><polyline points="3 6 5 6 21 6" /><path d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6M10 11v6M14 11v6" /></>,
|
||||||
|
spark: <path d="M12 3l2.5 6.5L21 12l-6.5 2.5L12 21l-2.5-6.5L3 12l6.5-2.5L12 3z" />,
|
||||||
|
bolt: <path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z" />,
|
||||||
|
github: <path d="M12 2C6.48 2 2 6.48 2 12c0 4.42 2.87 8.17 6.84 9.5.5.08.66-.22.66-.48v-1.7c-2.78.6-3.36-1.34-3.36-1.34-.46-1.15-1.12-1.46-1.12-1.46-.92-.62.07-.6.07-.6 1.01.07 1.55 1.04 1.55 1.04.9 1.54 2.36 1.1 2.94.84.09-.65.35-1.1.64-1.35-2.22-.25-4.55-1.11-4.55-4.94 0-1.09.39-1.98 1.03-2.68-.1-.25-.45-1.27.1-2.65 0 0 .84-.27 2.75 1.02A9.5 9.5 0 0112 6.8c.85 0 1.7.11 2.5.33 1.9-1.3 2.75-1.02 2.75-1.02.55 1.38.2 2.4.1 2.65.64.7 1.03 1.6 1.03 2.68 0 3.84-2.34 4.69-4.57 4.93.36.31.68.92.68 1.85V21c0 .27.16.57.67.48A10 10 0 0022 12c0-5.52-4.48-10-10-10z" fill="currentColor" stroke="none" />,
|
||||||
|
google: <><path d="M21.35 11.1h-9.17v2.73h5.24c-.23 1.41-1.69 4.13-5.24 4.13-3.15 0-5.73-2.62-5.73-5.86 0-3.24 2.58-5.86 5.73-5.86 1.8 0 3 .77 3.69 1.43l2.5-2.4C16.95 3.74 14.8 2.8 12.18 2.8c-5.26 0-9.53 4.25-9.53 9.5s4.27 9.5 9.53 9.5c5.51 0 9.15-3.87 9.15-9.32 0-.63-.07-1.1-.15-1.38z" fill="currentColor" stroke="none" /></>,
|
||||||
|
share: <><circle cx="18" cy="5" r="3" /><circle cx="6" cy="12" r="3" /><circle cx="18" cy="19" r="3" /><path d="M8.59 13.51l6.83 3.98M15.41 6.51l-6.82 3.98" /></>,
|
||||||
|
download: <><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4" /><polyline points="7 10 12 15 17 10" /><line x1="12" y1="15" x2="12" y2="3" /></>,
|
||||||
|
zoom_in: <><circle cx="11" cy="11" r="7" /><line x1="21" y1="21" x2="16.65" y2="16.65" /><line x1="11" y1="8" x2="11" y2="14" /><line x1="8" y1="11" x2="14" y2="11" /></>,
|
||||||
|
zoom_out: <><circle cx="11" cy="11" r="7" /><line x1="21" y1="21" x2="16.65" y2="16.65" /><line x1="8" y1="11" x2="14" y2="11" /></>,
|
||||||
|
maximize: <><path d="M8 3H5a2 2 0 00-2 2v3M21 8V5a2 2 0 00-2-2h-3M3 16v3a2 2 0 002 2h3M16 21h3a2 2 0 002-2v-3" /></>,
|
||||||
|
slack: <><rect x="13" y="2" width="3" height="8" rx="1.5" /><rect x="2" y="13" width="8" height="3" rx="1.5" /><rect x="14" y="14" width="8" height="3" rx="1.5" /><rect x="8" y="8" width="3" height="8" rx="1.5" /></>,
|
||||||
|
database: <><ellipse cx="12" cy="5" rx="9" ry="3" /><path d="M3 5v7c0 1.66 4.03 3 9 3s9-1.34 9-3V5M3 12v7c0 1.66 4.03 3 9 3s9-1.34 9-3v-7" /></>,
|
||||||
|
mail: <><rect x="2" y="4" width="20" height="16" rx="2" /><path d="M2 6l10 7 10-7" /></>,
|
||||||
|
filter: <path d="M3 4h18l-7 9v6l-4-2v-4L3 4z" />,
|
||||||
|
star: <polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />,
|
||||||
|
linear: <><rect x="3" y="3" width="18" height="18" rx="4" /><path d="M7 11l5 5M7 15l3 3M7 7l10 10M11 7l6 6M15 7l2 2" /></>,
|
||||||
|
clock: <><circle cx="12" cy="12" r="9" /><polyline points="12 7 12 12 16 14" /></>,
|
||||||
|
send: <><path d="M22 2L11 13M22 2l-7 20-4-9-9-4 20-7z" /></>,
|
||||||
|
terminal: <><path d="M4 17l6-6-6-6M12 19h8" /></>,
|
||||||
|
logout: <><path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4M16 17l5-5-5-5M21 12H9" /></>,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={stroke} strokeLinecap="round" strokeLinejoin="round" style={{display: 'block', flexShrink: 0}}>
|
||||||
|
{paths[name]}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Arcrun wordmark — custom "arc" glyph made of an arc stroke + ascending dot/node
|
||||||
|
const Logo = ({ size = 'md', onClick }) => {
|
||||||
|
const dims = size === 'sm' ? { w: 18, h: 18, f: 10 } : size === 'lg' ? { w: 28, h: 28, f: 14 } : { w: 22, h: 22, f: 12 };
|
||||||
|
return (
|
||||||
|
<div className="logo" onClick={onClick}>
|
||||||
|
<span className="logo-mark" style={{width: dims.w, height: dims.h}}>
|
||||||
|
<svg width={dims.w} height={dims.h} viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M5 17 Q 12 4, 19 17" stroke="white" strokeWidth="2.4" strokeLinecap="round" fill="none" opacity="0.95" />
|
||||||
|
<circle cx="19" cy="17" r="2.2" fill="white" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span>Arcrun</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// App icon with gradient background
|
||||||
|
const AppIcon = ({ tone = 'indigo', children, size = 38 }) => {
|
||||||
|
const tones = {
|
||||||
|
indigo: 'linear-gradient(135deg, #6366F1, #8B5CF6)',
|
||||||
|
orange: 'linear-gradient(135deg, #F59E0B, #EF4444)',
|
||||||
|
green: 'linear-gradient(135deg, #10B981, #22C55E)',
|
||||||
|
pink: 'linear-gradient(135deg, #EC4899, #8B5CF6)',
|
||||||
|
blue: 'linear-gradient(135deg, #3B82F6, #06B6D4)',
|
||||||
|
slate: 'linear-gradient(135deg, #475569, #334155)',
|
||||||
|
amber: 'linear-gradient(135deg, #F59E0B, #D97706)',
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div className="app-icon" style={{ background: tones[tone], width: size, height: size, color: 'white' }}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
Object.assign(window, { Icon, Logo, AppIcon });
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,128 @@
|
|||||||
|
const ApiKeys = ({ onNav }) => {
|
||||||
|
const [newKeyCopied, setNewKeyCopied] = React.useState(false);
|
||||||
|
const [keys, setKeys] = React.useState([
|
||||||
|
{ id: 'k_dev', name: 'Local Development', prefix: 'ar_dev_', created: 'Mar 12, 2026', lastUsed: '2 min ago', active: true },
|
||||||
|
{ id: 'k_prod', name: 'Production — Northwind API', prefix: 'ar_live_', created: 'Feb 3, 2026', lastUsed: '12 sec ago', active: true },
|
||||||
|
{ id: 'k_staging', name: 'Staging — Vercel', prefix: 'ar_test_', created: 'Jan 28, 2026', lastUsed: '4 hours ago', active: true },
|
||||||
|
{ id: 'k_ci', name: 'CI/CD (GitHub Actions)', prefix: 'ar_live_', created: 'Jan 10, 2026', lastUsed: 'Yesterday', active: false },
|
||||||
|
{ id: 'k_old', name: 'Legacy — Zapier import', prefix: 'ar_live_', created: 'Nov 4, 2025', lastUsed: '3 weeks ago', active: false, revoked: true },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const newKey = 'ar_live_sk_7x9Qf2vLm8nR4TpW6ZjKc3bEhN1aSyU5oP0dI';
|
||||||
|
|
||||||
|
const copyKey = () => {
|
||||||
|
setNewKeyCopied(true);
|
||||||
|
setTimeout(() => setNewKeyCopied(false), 1800);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleKey = (id) => {
|
||||||
|
setKeys(keys.map(k => k.id === id ? { ...k, active: !k.active } : k));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="shell">
|
||||||
|
<Sidebar current="keys" onNav={onNav} />
|
||||||
|
<div className="main">
|
||||||
|
<div className="main-head">
|
||||||
|
<div>
|
||||||
|
<div className="crumb">
|
||||||
|
<span>Workspace</span>
|
||||||
|
<span className="sep"><Icon name="chevron_right" size={11} /></span>
|
||||||
|
<span>Settings</span>
|
||||||
|
</div>
|
||||||
|
<h1>API Keys</h1>
|
||||||
|
<div className="sub">Scoped credentials for calling the Arcrun API from your code and CI.</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-8">
|
||||||
|
<button className="btn btn-secondary"><Icon name="book" size={14} /> API docs</button>
|
||||||
|
<button className="btn btn-primary"><Icon name="plus" size={14} /> Create new key</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="main-body" style={{maxWidth: 1080}}>
|
||||||
|
<div className="new-key-box">
|
||||||
|
<div className="warn-row">
|
||||||
|
<span className="warn-icon"><Icon name="warn" size={12} /></span>
|
||||||
|
<span><strong style={{color: '#FBBF24'}}>Save this key now.</strong> For security, we won't show it again — if you lose it, you'll need to create a new one.</span>
|
||||||
|
</div>
|
||||||
|
<h3>Your new API key</h3>
|
||||||
|
<p className="desc">Key named <strong style={{color: 'var(--text)'}}>"Production — Northwind API"</strong> · created just now · all scopes</p>
|
||||||
|
<div className="key-display">
|
||||||
|
<span className="key-val">{newKey}</span>
|
||||||
|
<button className={`copy-btn ${newKeyCopied ? 'copied' : ''}`} onClick={copyKey}>
|
||||||
|
<Icon name={newKeyCopied ? 'check' : 'copy'} size={12} />
|
||||||
|
{newKeyCopied ? 'Copied' : 'Copy'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div style={{marginTop: 14, display: 'flex', gap: 16, fontSize: 12, color: 'var(--text-mute)', alignItems: 'center'}}>
|
||||||
|
<span className="flex gap-6" style={{alignItems: 'center'}}><Icon name="check" size={12} /> Full workspace access</span>
|
||||||
|
<span className="flex gap-6" style={{alignItems: 'center'}}><Icon name="clock" size={12} /> Never expires</span>
|
||||||
|
<span style={{marginLeft: 'auto'}}><span className="link">Add expiry or restrict scopes →</span></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="section-head">
|
||||||
|
<div>
|
||||||
|
<h2>All keys</h2>
|
||||||
|
<div className="subtle" style={{marginTop: 2}}>{keys.filter(k => !k.revoked).length} active · {keys.filter(k => k.revoked).length} revoked</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-8">
|
||||||
|
<button className="btn btn-secondary btn-sm"><Icon name="filter" size={12} /> Filter</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="table-wrap">
|
||||||
|
<table className="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style={{width: '32%'}}>Name</th>
|
||||||
|
<th>Key</th>
|
||||||
|
<th>Created</th>
|
||||||
|
<th>Last used</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th style={{width: 60, textAlign: 'right'}}></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{keys.map(k => (
|
||||||
|
<tr key={k.id}>
|
||||||
|
<td>
|
||||||
|
<div style={{fontWeight: 500, fontSize: 13.5}}>{k.name}</div>
|
||||||
|
</td>
|
||||||
|
<td className="mono">{k.prefix}••••{k.id.slice(-4)}</td>
|
||||||
|
<td className="dim" style={{fontSize: 12.5}}>{k.created}</td>
|
||||||
|
<td className="dim" style={{fontSize: 12.5}}>{k.lastUsed}</td>
|
||||||
|
<td>
|
||||||
|
{k.revoked ? (
|
||||||
|
<span className="pill revoked"><span className="pdot" /> Revoked</span>
|
||||||
|
) : (
|
||||||
|
<div className="flex gap-8" style={{alignItems: 'center'}}>
|
||||||
|
<span className={`toggle ${k.active ? 'on' : ''}`} onClick={() => toggleKey(k.id)} />
|
||||||
|
<span className={`pill ${k.active ? 'active' : 'idle'}`}>
|
||||||
|
<span className="pdot" /> {k.active ? 'Active' : 'Paused'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td style={{textAlign: 'right'}}>
|
||||||
|
{!k.revoked && (
|
||||||
|
<button className="btn btn-danger-ghost btn-sm"><Icon name="trash" size={12} /></button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{marginTop: 18, fontSize: 12, color: 'var(--text-mute)', display: 'flex', alignItems: 'center', gap: 8}}>
|
||||||
|
<Icon name="warn" size={12} />
|
||||||
|
<span>Revoking a key stops all in-flight requests within 60 seconds. This cannot be undone.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.ApiKeys = ApiKeys;
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
const Auth = ({ onNav }) => {
|
||||||
|
const [mode, setMode] = React.useState('signin');
|
||||||
|
const [email, setEmail] = React.useState('');
|
||||||
|
const [pw, setPw] = React.useState('');
|
||||||
|
const [remember, setRemember] = React.useState(true);
|
||||||
|
|
||||||
|
const submit = (e) => { e.preventDefault(); onNav('dashboard'); };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="auth-wrap">
|
||||||
|
<div className="hero-bg" />
|
||||||
|
<div className="hero-bg-grid" />
|
||||||
|
|
||||||
|
<div style={{position: 'absolute', top: 24, left: 24, zIndex: 2}}>
|
||||||
|
<Logo onClick={() => onNav('landing')} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="auth-card">
|
||||||
|
<h2 className="auth-h1">{mode === 'signin' ? 'Welcome back' : 'Create your account'}</h2>
|
||||||
|
<p className="auth-sub">{mode === 'signin' ? 'Sign in to your Arcrun workspace.' : 'Start building AI workflows in minutes.'}</p>
|
||||||
|
|
||||||
|
<div className="tabs">
|
||||||
|
<button className={mode === 'signin' ? 'active' : ''} onClick={() => setMode('signin')}>Sign in</button>
|
||||||
|
<button className={mode === 'signup' ? 'active' : ''} onClick={() => setMode('signup')}>Sign up</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="oauth-row">
|
||||||
|
<button className="oauth-btn github" onClick={() => onNav('dashboard')}>
|
||||||
|
<Icon name="github" size={17} stroke={0} /> Continue with GitHub
|
||||||
|
</button>
|
||||||
|
<button className="oauth-btn google" onClick={() => onNav('dashboard')}>
|
||||||
|
<Icon name="google" size={15} stroke={0} /> Continue with Google
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="divider">or continue with email</div>
|
||||||
|
|
||||||
|
<form onSubmit={submit}>
|
||||||
|
{mode === 'signup' && (
|
||||||
|
<div className="field">
|
||||||
|
<label>Full name</label>
|
||||||
|
<input className="input" type="text" placeholder="Maya Rivera" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="field">
|
||||||
|
<label>Work email</label>
|
||||||
|
<input className="input" type="email" placeholder="you@company.com" value={email} onChange={e => setEmail(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<div className="field-row">
|
||||||
|
<label>Password</label>
|
||||||
|
{mode === 'signin' && <span className="link">Forgot password?</span>}
|
||||||
|
</div>
|
||||||
|
<input className="input" type="password" placeholder="••••••••••" value={pw} onChange={e => setPw(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{mode === 'signin' && (
|
||||||
|
<div style={{display: 'flex', alignItems: 'center', gap: 8, fontSize: 12.5, color: 'var(--text-dim)', marginBottom: 14}}>
|
||||||
|
<div onClick={() => setRemember(!remember)}
|
||||||
|
style={{width: 15, height: 15, borderRadius: 4, border: '1px solid var(--line-2)',
|
||||||
|
background: remember ? 'var(--primary)' : 'transparent',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer'}}>
|
||||||
|
{remember && <Icon name="check" size={11} />}
|
||||||
|
</div>
|
||||||
|
<span onClick={() => setRemember(!remember)} style={{cursor: 'pointer'}}>Keep me signed in for 30 days</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button className="btn btn-primary auth-submit btn-lg" type="submit">
|
||||||
|
{mode === 'signin' ? 'Sign in' : 'Create account'} <Icon name="arrow_right" size={14} />
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{mode === 'signup' && (
|
||||||
|
<p style={{fontSize: 11.5, color: 'var(--text-mute)', textAlign: 'center', marginTop: 14, lineHeight: 1.5}}>
|
||||||
|
By signing up, you agree to our <span className="link" style={{fontSize: 11.5}}>Terms</span> and <span className="link" style={{fontSize: 11.5}}>Privacy Policy</span>.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="auth-foot">
|
||||||
|
{mode === 'signin'
|
||||||
|
? <>New to Arcrun? <span className="link" onClick={() => setMode('signup')}>Create an account</span></>
|
||||||
|
: <>Already have an account? <span className="link" onClick={() => setMode('signin')}>Sign in</span></>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.Auth = Auth;
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
const Dashboard = ({ onNav }) => {
|
||||||
|
const apps = [
|
||||||
|
{ id: 'digest', name: 'Weekly Digest', desc: 'Summarize customer activity into a Monday email for the revenue team.', icon: 'mail', tone: 'indigo' },
|
||||||
|
{ id: 'triage', name: 'Support Triage', desc: 'Classify inbound tickets, attach context from the CRM, and route.', icon: 'filter', tone: 'orange' },
|
||||||
|
{ id: 'seo', name: 'SEO Brief Generator', desc: 'Turn a keyword into a draft brief with outline, FAQs, and SERP notes.', icon: 'search', tone: 'green' },
|
||||||
|
{ id: 'slack', name: 'Standup Bot', desc: 'Collect Linear updates and post a tidy engineering standup to Slack.', icon: 'slack', tone: 'pink' },
|
||||||
|
{ id: 'doc', name: 'Docs Sync', desc: 'Keep Notion runbooks in sync with the production API surface.', icon: 'book', tone: 'blue' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const workflows = [
|
||||||
|
{ id: 'digest_weekly', name: 'digest/weekly', nodes: 9, modified: '2 hours ago', runs: '147 runs', status: 'healthy' },
|
||||||
|
{ id: 'triage_inbound', name: 'triage/inbound-email', nodes: 14, modified: 'Yesterday', runs: '2,318 runs', status: 'healthy' },
|
||||||
|
{ id: 'seo_brief', name: 'seo/brief-from-keyword', nodes: 7, modified: '3 days ago', runs: '42 runs', status: 'healthy' },
|
||||||
|
{ id: 'standup', name: 'slack/standup-collector', nodes: 6, modified: '1 week ago', runs: '24 runs', status: 'idle' },
|
||||||
|
{ id: 'docs_sync', name: 'docs/sync-notion', nodes: 11, modified: '2 weeks ago', runs: '8 runs', status: 'failed' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="shell">
|
||||||
|
<Sidebar current="dashboard" onNav={onNav} />
|
||||||
|
<div className="main">
|
||||||
|
<div className="main-head">
|
||||||
|
<div>
|
||||||
|
<div className="crumb">
|
||||||
|
<span>Northwind</span>
|
||||||
|
<span className="sep"><Icon name="chevron_right" size={11} /></span>
|
||||||
|
<span>Dashboard</span>
|
||||||
|
</div>
|
||||||
|
<h1>Welcome back, Maya</h1>
|
||||||
|
<div className="sub">5 apps running · 12 workflows · 2,538 runs this week</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-8">
|
||||||
|
<button className="btn btn-secondary"><Icon name="book" size={14} /> Templates</button>
|
||||||
|
<button className="btn btn-primary"><Icon name="plus" size={14} /> New app</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="main-body">
|
||||||
|
{/* Apps grid */}
|
||||||
|
<div className="section-head">
|
||||||
|
<div>
|
||||||
|
<h2>My Apps</h2>
|
||||||
|
<div className="subtle" style={{marginTop: 2}}>Packaged workflows your team can run from chat or code</div>
|
||||||
|
</div>
|
||||||
|
<span className="subtle">{apps.length} apps</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="apps-grid">
|
||||||
|
{apps.map(a => (
|
||||||
|
<div key={a.id} className="app-card">
|
||||||
|
<AppIcon tone={a.tone}><Icon name={a.icon} size={17} /></AppIcon>
|
||||||
|
<h4>{a.name}</h4>
|
||||||
|
<p className="dsc">{a.desc}</p>
|
||||||
|
<div className="row">
|
||||||
|
<a className="open" onClick={() => onNav('workflow')}>Open app <Icon name="arrow_right" size={12} /></a>
|
||||||
|
<button className="chip-btn">
|
||||||
|
<Icon name="spark" size={11} /> Edit in Claude
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="app-card app-empty">
|
||||||
|
<div className="plus"><Icon name="plus" size={16} /></div>
|
||||||
|
<div style={{fontSize: 13, fontWeight: 500}}>Create new app</div>
|
||||||
|
<div style={{fontSize: 12, opacity: 0.75}}>Start from scratch or template</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Workflows */}
|
||||||
|
<div className="wf-table">
|
||||||
|
<div className="section-head">
|
||||||
|
<div>
|
||||||
|
<h2>My Workflows</h2>
|
||||||
|
<div className="subtle" style={{marginTop: 2}}>The graphs that power your apps</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-8">
|
||||||
|
<button className="btn btn-secondary btn-sm"><Icon name="filter" size={12} /> All workflows</button>
|
||||||
|
<button className="btn btn-secondary btn-sm" onClick={() => onNav('workflow')}><Icon name="plus" size={12} /> New</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="table-wrap">
|
||||||
|
<table className="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style={{width: '34%'}}>Workflow</th>
|
||||||
|
<th>Nodes</th>
|
||||||
|
<th>Last modified</th>
|
||||||
|
<th>Activity</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th style={{width: 100, textAlign: 'right'}}></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{workflows.map(w => (
|
||||||
|
<tr key={w.id}>
|
||||||
|
<td>
|
||||||
|
<div className="wf-row-name">
|
||||||
|
<span className="dot" />
|
||||||
|
<span className="mono" style={{fontSize: 13}}>{w.name}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="dim">{w.nodes}</td>
|
||||||
|
<td className="dim" style={{fontSize: 12.5}}>{w.modified}</td>
|
||||||
|
<td className="dim" style={{fontSize: 12.5}}>{w.runs}</td>
|
||||||
|
<td>
|
||||||
|
<span className={`pill ${w.status === 'healthy' ? 'active' : w.status === 'failed' ? 'revoked' : 'idle'}`}>
|
||||||
|
<span className="pdot" /> {w.status}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td style={{textAlign: 'right'}}>
|
||||||
|
<button className="btn btn-secondary btn-sm" onClick={() => onNav('workflow')}>View</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.Dashboard = Dashboard;
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
const Landing = ({ onNav }) => {
|
||||||
|
const [installer, setInstaller] = React.useState('npm');
|
||||||
|
const installCmds = {
|
||||||
|
npm: '$ npm install arcrun',
|
||||||
|
pip: '$ pip install arcrun',
|
||||||
|
bun: '$ bun add arcrun',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<TopNav onNav={onNav} current="landing" />
|
||||||
|
|
||||||
|
<div className="container">
|
||||||
|
<section className="hero">
|
||||||
|
<div className="hero-bg" />
|
||||||
|
<div className="hero-bg-grid" />
|
||||||
|
<div className="hero-eyebrow">
|
||||||
|
<span className="dot" />
|
||||||
|
<span>Now in public beta — MCP-native</span>
|
||||||
|
</div>
|
||||||
|
<h1>Build AI workflows<br/><span className="grad">without the glue code.</span></h1>
|
||||||
|
<p className="sub">Connect your tools, automate your work. Orchestrate workflows from Claude.ai, your IDE, or a few lines of code — Arcrun handles auth, retries, and state.</p>
|
||||||
|
<div className="hero-ctas">
|
||||||
|
<button className="btn btn-primary btn-lg" onClick={() => onNav('auth')}>
|
||||||
|
Start free <Icon name="arrow_right" size={15} />
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-secondary btn-lg">
|
||||||
|
<Icon name="book" size={14} /> Read the docs
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="paths">
|
||||||
|
{/* Developer path */}
|
||||||
|
<div className="path-card">
|
||||||
|
<div className="path-label">
|
||||||
|
<Icon name="terminal" size={13} /> For Developers
|
||||||
|
</div>
|
||||||
|
<h3>Three lines, any runtime.</h3>
|
||||||
|
<p className="lede">Install once, call Arcrun from Node, Python, or your edge runtime. OAuth, rate limits, and retries are handled.</p>
|
||||||
|
|
||||||
|
<div className="install-tabs">
|
||||||
|
{Object.keys(installCmds).map(k => (
|
||||||
|
<button key={k} className={installer === k ? 'active' : ''} onClick={() => setInstaller(k)}>{k}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="terminal" style={{marginBottom: 12}}>
|
||||||
|
<div className="terminal-head">
|
||||||
|
<div className="dots"><span/><span/><span/></div>
|
||||||
|
<div className="title">terminal</div>
|
||||||
|
</div>
|
||||||
|
<div className="terminal-body">
|
||||||
|
<div><span className="dim">{installCmds[installer]}</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="terminal">
|
||||||
|
<div className="terminal-head">
|
||||||
|
<div className="dots"><span/><span/><span/></div>
|
||||||
|
<div className="title">{installer === 'pip' ? 'app.py' : 'app.ts'}</div>
|
||||||
|
</div>
|
||||||
|
<div className="terminal-body">
|
||||||
|
{installer === 'pip' ? (
|
||||||
|
<>
|
||||||
|
<div><span className="c1">from</span> <span className="c2">arcrun</span> <span className="c1">import</span> <span className="c2">Arcrun</span></div>
|
||||||
|
<div className="sp-4"/>
|
||||||
|
<div><span className="c2">client</span> = <span className="c4">Arcrun</span>(<span className="c2">token</span>=<span className="c2">os</span>.<span className="c4">getenv</span>(<span className="c3">"ARCRUN_KEY"</span>))</div>
|
||||||
|
<div><span className="c2">run</span> = <span className="c2">client</span>.<span className="c4">run</span>(<span className="c3">"digest/weekly"</span>, <span className="c2">inputs</span>={'{'}<span className="c3">"user"</span>: <span className="c3">"u_219"</span>{'}'})</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div><span className="c1">import</span> {'{'} <span className="c2">Arcrun</span> {'}'} <span className="c1">from</span> <span className="c3">"arcrun"</span>;</div>
|
||||||
|
<div className="sp-4"/>
|
||||||
|
<div><span className="c1">const</span> <span className="c2">client</span> = <span className="c1">new</span> <span className="c4">Arcrun</span>({'{'} <span className="c2">token</span>: <span className="c2">process</span>.<span className="c2">env</span>.<span className="c2">ARCRUN_KEY</span> {'}'});</div>
|
||||||
|
<div><span className="c1">const</span> <span className="c2">run</span> = <span className="c1">await</span> <span className="c2">client</span>.<span className="c4">run</span>(<span className="c3">"digest/weekly"</span>, {'{'} <span className="c2">user</span>: <span className="c3">"u_219"</span> {'}'});</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sp-16" />
|
||||||
|
<div className="flex gap-12" style={{fontSize: 12.5, color: 'var(--text-mute)'}}>
|
||||||
|
<span className="flex gap-6" style={{alignItems: 'center'}}><Icon name="check" size={12} /> Typed SDKs</span>
|
||||||
|
<span className="flex gap-6" style={{alignItems: 'center'}}><Icon name="check" size={12} /> Idempotent runs</span>
|
||||||
|
<span className="flex gap-6" style={{alignItems: 'center'}}><Icon name="check" size={12} /> Self-host ready</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Everyone path */}
|
||||||
|
<div className="path-card">
|
||||||
|
<div className="path-label">
|
||||||
|
<Icon name="spark" size={13} /> For Everyone
|
||||||
|
</div>
|
||||||
|
<h3>Talk to your workflows.</h3>
|
||||||
|
<p className="lede">Install Arcrun inside your AI assistant and run your apps by asking. Trigger workflows, fetch data, or draft messages — in plain English.</p>
|
||||||
|
|
||||||
|
<div className="chat-preview">
|
||||||
|
<div className="chat-head">
|
||||||
|
<span className="brand-dot">AI</span>
|
||||||
|
<span>Your assistant — Arcrun connected</span>
|
||||||
|
<span style={{marginLeft: 'auto'}} className="pill active"><span className="pdot" />2 apps</span>
|
||||||
|
</div>
|
||||||
|
<div className="chat-body">
|
||||||
|
<div className="chat-msg user">
|
||||||
|
<div className="avatar">M</div>
|
||||||
|
<div className="bubble">Send this week's customer digest to the revenue team.</div>
|
||||||
|
</div>
|
||||||
|
<div className="chat-msg ai">
|
||||||
|
<div className="avatar">A</div>
|
||||||
|
<div className="bubble">
|
||||||
|
Running <span style={{color: 'var(--primary)', fontWeight: 500}}>digest/weekly</span> for 147 accounts, then posting to #revenue.
|
||||||
|
<div className="tool-card">
|
||||||
|
<div className="tool-icon">AR</div>
|
||||||
|
<div className="tool-meta">
|
||||||
|
<div className="tool-name">arcrun · digest/weekly</div>
|
||||||
|
<div className="tool-sub">4 of 5 steps complete · 00:12 elapsed</div>
|
||||||
|
</div>
|
||||||
|
<span className="pill active"><span className="pdot" />running</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="chat-input">
|
||||||
|
<span>Reply to your assistant…</span>
|
||||||
|
<span className="caret" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sp-16" />
|
||||||
|
<div className="flex gap-12" style={{fontSize: 12.5, color: 'var(--text-mute)'}}>
|
||||||
|
<span className="flex gap-6" style={{alignItems: 'center'}}><Icon name="check" size={12} /> One-click connect</span>
|
||||||
|
<span className="flex gap-6" style={{alignItems: 'center'}}><Icon name="check" size={12} /> Works in your IDE</span>
|
||||||
|
<span className="flex gap-6" style={{alignItems: 'center'}}><Icon name="check" size={12} /> Audit trail</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="strip">
|
||||||
|
<div className="cell">
|
||||||
|
<div className="ico"><Icon name="bolt" size={15} /></div>
|
||||||
|
<h4>Run anywhere</h4>
|
||||||
|
<p>Node, Python, Deno, Bun, Cloudflare Workers. One API, same semantics.</p>
|
||||||
|
</div>
|
||||||
|
<div className="cell">
|
||||||
|
<div className="ico"><Icon name="workflow" size={15} /></div>
|
||||||
|
<h4>Composable steps</h4>
|
||||||
|
<p>Model calls, HTTP, database, branching — wire them visually or in code.</p>
|
||||||
|
</div>
|
||||||
|
<div className="cell">
|
||||||
|
<div className="ico"><Icon name="key" size={15} /></div>
|
||||||
|
<h4>Scoped keys</h4>
|
||||||
|
<p>Per-workflow API keys with fine-grained scopes and live revocation.</p>
|
||||||
|
</div>
|
||||||
|
<div className="cell">
|
||||||
|
<div className="ico"><Icon name="eye" size={15} /></div>
|
||||||
|
<h4>Observable</h4>
|
||||||
|
<p>Every run is replayable. Inspect inputs, outputs, and token usage.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Footer onNav={onNav} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.Landing = Landing;
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
const WorkflowViewer = ({ onNav }) => {
|
||||||
|
const nodes = [
|
||||||
|
{ id: 'trigger', x: 60, y: 260, title: 'Weekly Schedule', type: 'trigger', badge: 'CRON', icon: 'clock', tone: '#22C55E',
|
||||||
|
inputs: [], outputs: [{k: 'timestamp', t: 'ISO8601'}, {k: 'runId', t: 'string'}] },
|
||||||
|
{ id: 'fetch', x: 320, y: 140, title: 'Fetch Accounts', type: 'database.query', badge: 'DB', icon: 'database', tone: '#3B82F6',
|
||||||
|
inputs: [{k: 'segment', t: 'string'}], outputs: [{k: 'accounts', t: 'Account[]'}, {k: 'count', t: 'number'}] },
|
||||||
|
{ id: 'events', x: 320, y: 380, title: 'Pull Events', type: 'segment.events', badge: 'API', icon: 'bolt', tone: '#F59E0B',
|
||||||
|
inputs: [{k: 'since', t: 'ISO8601'}], outputs: [{k: 'events', t: 'Event[]'}] },
|
||||||
|
{ id: 'summarize', x: 600, y: 260, title: 'Summarize with Claude', type: 'ai.completion', badge: 'AI', icon: 'spark', tone: '#8B5CF6',
|
||||||
|
inputs: [{k: 'accounts', t: 'Account[]'}, {k: 'events', t: 'Event[]'}, {k: 'prompt', t: 'string'}],
|
||||||
|
outputs: [{k: 'digest', t: 'Digest'}, {k: 'tokens', t: 'number'}] },
|
||||||
|
{ id: 'filter', x: 880, y: 160, title: 'Filter — priority ≥ 2', type: 'logic.filter', badge: 'IF', icon: 'filter', tone: '#64748B',
|
||||||
|
inputs: [{k: 'digest', t: 'Digest'}], outputs: [{k: 'items', t: 'Item[]'}] },
|
||||||
|
{ id: 'slack', x: 1140, y: 100, title: 'Post to #revenue', type: 'slack.message', badge: 'OUT', icon: 'slack', tone: '#EC4899',
|
||||||
|
inputs: [{k: 'channel', t: 'string'}, {k: 'blocks', t: 'Block[]'}], outputs: [{k: 'ts', t: 'string'}] },
|
||||||
|
{ id: 'mail', x: 1140, y: 260, title: 'Email Digest', type: 'mail.send', badge: 'OUT', icon: 'mail', tone: '#6366F1',
|
||||||
|
inputs: [{k: 'to', t: 'string[]'}, {k: 'subject', t: 'string'}, {k: 'html', t: 'string'}], outputs: [{k: 'messageId', t: 'string'}] },
|
||||||
|
{ id: 'log', x: 880, y: 400, title: 'Log run metadata', type: 'arcrun.log', badge: 'LOG', icon: 'terminal', tone: '#475569',
|
||||||
|
inputs: [{k: 'runId', t: 'string'}, {k: 'stats', t: 'Stats'}], outputs: [] },
|
||||||
|
];
|
||||||
|
|
||||||
|
const edges = [
|
||||||
|
['trigger', 'fetch'],
|
||||||
|
['trigger', 'events'],
|
||||||
|
['fetch', 'summarize'],
|
||||||
|
['events', 'summarize'],
|
||||||
|
['summarize', 'filter'],
|
||||||
|
['summarize', 'log'],
|
||||||
|
['filter', 'slack'],
|
||||||
|
['filter', 'mail'],
|
||||||
|
];
|
||||||
|
|
||||||
|
const [selectedId, setSelectedId] = React.useState('summarize');
|
||||||
|
const [title, setTitle] = React.useState('digest/weekly');
|
||||||
|
const [zoom, setZoom] = React.useState(100);
|
||||||
|
|
||||||
|
const selected = nodes.find(n => n.id === selectedId);
|
||||||
|
|
||||||
|
// Edit triplet inline (for the summarize node's prompt config)
|
||||||
|
const [triplet, setTriplet] = React.useState({
|
||||||
|
model: 'claude-haiku-4-5',
|
||||||
|
temperature: '0.3',
|
||||||
|
prompt: 'Summarize this week\'s account activity for the revenue team.',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Measure node widths for edge endpoint accuracy
|
||||||
|
const nodeRefs = React.useRef({});
|
||||||
|
const [sizes, setSizes] = React.useState({});
|
||||||
|
React.useEffect(() => {
|
||||||
|
const ns = {};
|
||||||
|
for (const n of nodes) {
|
||||||
|
const el = nodeRefs.current[n.id];
|
||||||
|
if (el) ns[n.id] = { w: el.offsetWidth, h: el.offsetHeight };
|
||||||
|
}
|
||||||
|
setSizes(ns);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const getPort = (id, side) => {
|
||||||
|
const n = nodes.find(x => x.id === id);
|
||||||
|
const sz = sizes[id] || { w: 200, h: 60 };
|
||||||
|
return {
|
||||||
|
x: side === 'out' ? n.x + sz.w : n.x,
|
||||||
|
y: n.y + sz.h / 2,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="wf-viewer">
|
||||||
|
<div className="wf-topbar">
|
||||||
|
<div className="back" onClick={() => onNav('dashboard')} title="Back to dashboard">
|
||||||
|
<Icon name="arrow_left" size={16} />
|
||||||
|
</div>
|
||||||
|
<Logo size="sm" onClick={() => onNav('landing')} />
|
||||||
|
<div className="sep" />
|
||||||
|
<div className="wf-breadcrumb">
|
||||||
|
<span className="cr" onClick={() => onNav('dashboard')}>Workflows</span>
|
||||||
|
<Icon name="chevron_right" size={11} />
|
||||||
|
<input
|
||||||
|
className="wf-title mono"
|
||||||
|
value={title}
|
||||||
|
onChange={e => setTitle(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="wf-saved">
|
||||||
|
<span style={{width: 6, height: 6, borderRadius: '50%', background: '#22C55E', boxShadow: '0 0 0 3px rgba(34,197,94,0.18)'}} />
|
||||||
|
Saved · 2m ago
|
||||||
|
</span>
|
||||||
|
<div className="spacer" />
|
||||||
|
<button className="btn btn-ghost btn-sm"><Icon name="share" size={13} /> Share</button>
|
||||||
|
<button className="btn btn-secondary btn-sm"><Icon name="download" size={13} /> Export YAML</button>
|
||||||
|
<button className="wf-edit-in-claude">
|
||||||
|
<Icon name="spark" size={13} /> Edit in Claude <Icon name="external" size={12} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="wf-canvas">
|
||||||
|
<svg className="wf-edges" width="100%" height="100%">
|
||||||
|
<defs>
|
||||||
|
<marker id="arrow" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
|
||||||
|
<path d="M0,0 L10,5 L0,10 z" fill="#6366F1" />
|
||||||
|
</marker>
|
||||||
|
<marker id="arrow-dim" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
|
||||||
|
<path d="M0,0 L10,5 L0,10 z" fill="#3a3a3a" />
|
||||||
|
</marker>
|
||||||
|
</defs>
|
||||||
|
{edges.map(([a, b], i) => {
|
||||||
|
const p1 = getPort(a, 'out');
|
||||||
|
const p2 = getPort(b, 'in');
|
||||||
|
const dx = Math.max(40, (p2.x - p1.x) * 0.5);
|
||||||
|
const d = `M ${p1.x} ${p1.y} C ${p1.x + dx} ${p1.y}, ${p2.x - dx} ${p2.y}, ${p2.x - 2} ${p2.y}`;
|
||||||
|
const highlight = a === selectedId || b === selectedId;
|
||||||
|
return (
|
||||||
|
<path key={i} d={d}
|
||||||
|
stroke={highlight ? '#6366F1' : '#3a3a3a'}
|
||||||
|
strokeWidth={highlight ? 2 : 1.5}
|
||||||
|
fill="none"
|
||||||
|
markerEnd={`url(#${highlight ? 'arrow' : 'arrow-dim'})`}
|
||||||
|
opacity={highlight ? 0.95 : 0.6} />
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<div className="wf-nodes">
|
||||||
|
{nodes.map(n => (
|
||||||
|
<div key={n.id}
|
||||||
|
ref={el => (nodeRefs.current[n.id] = el)}
|
||||||
|
className={`wf-node ${selectedId === n.id ? 'selected' : ''}`}
|
||||||
|
style={{left: n.x, top: n.y}}
|
||||||
|
onClick={() => setSelectedId(n.id)}>
|
||||||
|
{n.inputs.length > 0 && <span className="port in" />}
|
||||||
|
{n.outputs.length > 0 && <span className="port out" />}
|
||||||
|
<div className="node-row-top">
|
||||||
|
<span className="node-icon" style={{background: n.tone}}>
|
||||||
|
<Icon name={n.icon} size={12} />
|
||||||
|
</span>
|
||||||
|
<span className="node-title">{n.title}</span>
|
||||||
|
<span className="node-badge">{n.badge}</span>
|
||||||
|
</div>
|
||||||
|
<div className="node-sub">{n.type}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Detail panel */}
|
||||||
|
{selected && (
|
||||||
|
<div className="wf-detail">
|
||||||
|
<div className="dt-head">
|
||||||
|
<span className="dt-icon" style={{background: selected.tone}}>
|
||||||
|
<Icon name={selected.icon} size={15} />
|
||||||
|
</span>
|
||||||
|
<div className="dt-meta">
|
||||||
|
<h3>{selected.title}</h3>
|
||||||
|
<div className="dt-type">{selected.type}</div>
|
||||||
|
</div>
|
||||||
|
<button className="close-btn" onClick={() => setSelectedId(null)}>
|
||||||
|
<Icon name="close" size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="dt-body">
|
||||||
|
<div className="dt-section">
|
||||||
|
<h4>Input schema</h4>
|
||||||
|
{selected.inputs.length === 0 ? (
|
||||||
|
<div style={{fontSize: 12, color: 'var(--text-mute)', fontStyle: 'italic'}}>No inputs — this is a trigger.</div>
|
||||||
|
) : selected.inputs.map(f => (
|
||||||
|
<div key={f.k} className="schema-field">
|
||||||
|
<span className="k">{f.k}</span>
|
||||||
|
<span className="t">{f.t}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="dt-section">
|
||||||
|
<h4>Output schema</h4>
|
||||||
|
{selected.outputs.length === 0 ? (
|
||||||
|
<div style={{fontSize: 12, color: 'var(--text-mute)', fontStyle: 'italic'}}>No outputs — terminal node.</div>
|
||||||
|
) : selected.outputs.map(f => (
|
||||||
|
<div key={f.k} className="schema-field">
|
||||||
|
<span className="k">{f.k}</span>
|
||||||
|
<span className="t">{f.t}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selected.id === 'summarize' && (
|
||||||
|
<div className="dt-section">
|
||||||
|
<h4>Configuration</h4>
|
||||||
|
<div className="triplet">
|
||||||
|
<div className="trow">
|
||||||
|
<div className="tkey">model</div>
|
||||||
|
<input className="tval" value={triplet.model} onChange={e => setTriplet({...triplet, model: e.target.value})} />
|
||||||
|
</div>
|
||||||
|
<div className="trow">
|
||||||
|
<div className="tkey">temp</div>
|
||||||
|
<input className="tval" value={triplet.temperature} onChange={e => setTriplet({...triplet, temperature: e.target.value})} />
|
||||||
|
</div>
|
||||||
|
<div className="trow">
|
||||||
|
<div className="tkey">prompt</div>
|
||||||
|
<input className="tval" value={triplet.prompt} onChange={e => setTriplet({...triplet, prompt: e.target.value})} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="dt-section">
|
||||||
|
<h4>Last run</h4>
|
||||||
|
<div style={{display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8, fontSize: 12}}>
|
||||||
|
<div style={{background: 'rgba(255,255,255,0.02)', border: '1px solid var(--line)', borderRadius: 7, padding: '8px 10px'}}>
|
||||||
|
<div style={{color: 'var(--text-mute)', fontSize: 10.5, textTransform: 'uppercase', letterSpacing: '0.06em'}}>Duration</div>
|
||||||
|
<div style={{fontFamily: 'JetBrains Mono, monospace', marginTop: 3}}>2.4s</div>
|
||||||
|
</div>
|
||||||
|
<div style={{background: 'rgba(255,255,255,0.02)', border: '1px solid var(--line)', borderRadius: 7, padding: '8px 10px'}}>
|
||||||
|
<div style={{color: 'var(--text-mute)', fontSize: 10.5, textTransform: 'uppercase', letterSpacing: '0.06em'}}>Status</div>
|
||||||
|
<div style={{marginTop: 2}}><span className="pill active"><span className="pdot" />success</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button className="btn btn-primary" style={{width: '100%', marginTop: 4}}>
|
||||||
|
<Icon name="spark" size={13} /> Edit this node in Claude
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Minimap */}
|
||||||
|
<div className="wf-minimap">
|
||||||
|
<div className="mini-label">Overview</div>
|
||||||
|
{nodes.map(n => {
|
||||||
|
const sz = sizes[n.id] || {w: 180, h: 60};
|
||||||
|
return (
|
||||||
|
<div key={n.id} className="mini-box" style={{
|
||||||
|
left: 8 + (n.x / 1400) * 164,
|
||||||
|
top: 18 + (n.y / 500) * 80,
|
||||||
|
width: Math.max(6, (sz.w / 1400) * 164),
|
||||||
|
height: Math.max(4, (sz.h / 500) * 80),
|
||||||
|
opacity: selectedId === n.id ? 1 : 0.5,
|
||||||
|
background: selectedId === n.id ? 'var(--primary)' : 'var(--primary-soft)',
|
||||||
|
}} />
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Zoom controls */}
|
||||||
|
<div className="wf-controls">
|
||||||
|
<button onClick={() => setZoom(Math.max(40, zoom - 10))}><Icon name="zoom_out" size={13} /></button>
|
||||||
|
<div className="zoom-val">{zoom}%</div>
|
||||||
|
<button onClick={() => setZoom(Math.min(200, zoom + 10))}><Icon name="zoom_in" size={13} /></button>
|
||||||
|
<button><Icon name="maximize" size={13} /></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.WorkflowViewer = WorkflowViewer;
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import SiteNav from '../components/SiteNav';
|
||||||
|
|
||||||
const API_BASE = process.env.NEXT_PUBLIC_API_BASE ?? 'https://cypher.arcrun.dev';
|
const API_BASE = process.env.NEXT_PUBLIC_API_BASE ?? 'https://cypher.arcrun.dev';
|
||||||
|
|
||||||
@@ -25,7 +26,7 @@ export default function ApiDocsPage() {
|
|||||||
const SwaggerUIBundle = (window as unknown as { SwaggerUIBundle: (opts: unknown) => void }).SwaggerUIBundle;
|
const SwaggerUIBundle = (window as unknown as { SwaggerUIBundle: (opts: unknown) => void }).SwaggerUIBundle;
|
||||||
if (!SwaggerUIBundle || !containerRef.current) return;
|
if (!SwaggerUIBundle || !containerRef.current) return;
|
||||||
SwaggerUIBundle({
|
SwaggerUIBundle({
|
||||||
url: `${API_BASE}/docs`,
|
url: `${API_BASE}/openapi.json`,
|
||||||
dom_id: '#swagger-ui',
|
dom_id: '#swagger-ui',
|
||||||
presets: [(window as unknown as { SwaggerUIBundle: { presets: { apis: unknown } } }).SwaggerUIBundle.presets.apis],
|
presets: [(window as unknown as { SwaggerUIBundle: { presets: { apis: unknown } } }).SwaggerUIBundle.presets.apis],
|
||||||
layout: 'BaseLayout',
|
layout: 'BaseLayout',
|
||||||
@@ -51,20 +52,7 @@ export default function ApiDocsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#0a0a0a] text-[#ededed]">
|
<div className="min-h-screen bg-[#0a0a0a] text-[#ededed]">
|
||||||
{/* Nav */}
|
<SiteNav currentPath="/api-docs" />
|
||||||
<nav className="flex items-center justify-between px-6 py-4 border-b border-[#1a1a1a]">
|
|
||||||
<Link href="/" className="text-white font-bold text-lg tracking-tight hover:opacity-80 transition-opacity">
|
|
||||||
arcrun
|
|
||||||
</Link>
|
|
||||||
<div className="flex items-center gap-4 text-sm">
|
|
||||||
<Link href="/integrations" className="text-[#666] hover:text-white transition-colors">Integrations</Link>
|
|
||||||
<Link href="/dashboard" className="text-[#666] hover:text-white transition-colors">Dashboard</Link>
|
|
||||||
<Link href="/login"
|
|
||||||
className="bg-indigo-600 hover:bg-indigo-500 text-white px-4 py-1.5 rounded-md text-sm font-medium transition-colors">
|
|
||||||
Get API Key
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="max-w-5xl mx-auto px-6 py-8">
|
<div className="max-w-5xl mx-auto px-6 py-8">
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
// 九宮格 App Launcher(受 Google Apps menu 啟發)
|
||||||
|
// 規範:matrix/identity/.agents/specs/identity/design.md §2.5
|
||||||
|
// 非白名單 user 看到 mira 等受限 app 顯示為灰色 + tooltip「即將開放」
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { MATRIX_APPS, isAppAccessible, type AppEntry } from './apps';
|
||||||
|
|
||||||
|
export default function AppLauncher({ userEmail }: { userEmail: string | null }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const onClickOutside = (e: MouseEvent) => {
|
||||||
|
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', onClickOutside);
|
||||||
|
return () => document.removeEventListener('mousedown', onClickOutside);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative" ref={ref}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen(o => !o)}
|
||||||
|
aria-label="切換應用"
|
||||||
|
className="flex items-center justify-center w-9 h-9 rounded-md text-[#888] hover:text-white hover:bg-[#1a1a1a] transition-colors"
|
||||||
|
>
|
||||||
|
{/* 九宮格 icon */}
|
||||||
|
<svg width="18" height="18" viewBox="0 0 18 18" fill="currentColor">
|
||||||
|
<circle cx="3" cy="3" r="1.5" /><circle cx="9" cy="3" r="1.5" /><circle cx="15" cy="3" r="1.5" />
|
||||||
|
<circle cx="3" cy="9" r="1.5" /><circle cx="9" cy="9" r="1.5" /><circle cx="15" cy="9" r="1.5" />
|
||||||
|
<circle cx="3" cy="15" r="1.5" /><circle cx="9" cy="15" r="1.5" /><circle cx="15" cy="15" r="1.5" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div
|
||||||
|
className="absolute right-0 mt-2 w-72 bg-[#0f0f0f] border border-[#222] rounded-lg shadow-xl p-2 z-50"
|
||||||
|
role="menu"
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-3 gap-1">
|
||||||
|
{MATRIX_APPS.map(app => (
|
||||||
|
<AppTile key={app.id} app={app} userEmail={userEmail} onClose={() => setOpen(false)} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AppTile({
|
||||||
|
app,
|
||||||
|
userEmail,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
app: AppEntry;
|
||||||
|
userEmail: string | null;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const accessible = isAppAccessible(app, userEmail);
|
||||||
|
const tooltip = !accessible ? (app.locked_tooltip ?? '即將開放') : (app.description ?? '');
|
||||||
|
|
||||||
|
if (!accessible) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
title={tooltip}
|
||||||
|
className="flex flex-col items-center justify-center gap-1 p-3 rounded-md cursor-not-allowed opacity-40"
|
||||||
|
>
|
||||||
|
<span className="text-2xl grayscale">{app.icon ?? '📦'}</span>
|
||||||
|
<span className="text-xs text-[#666] text-center">{app.name}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={app.url}
|
||||||
|
onClick={onClose}
|
||||||
|
title={tooltip}
|
||||||
|
className="flex flex-col items-center justify-center gap-1 p-3 rounded-md hover:bg-[#1a1a1a] transition-colors text-[#ccc] hover:text-white"
|
||||||
|
>
|
||||||
|
<span className="text-2xl">{app.icon ?? '📦'}</span>
|
||||||
|
<span className="text-xs text-center">{app.name}</span>
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import AppLauncher from './AppLauncher';
|
||||||
|
|
||||||
|
const API_BASE = process.env.NEXT_PUBLIC_API_BASE ?? 'https://cypher.arcrun.dev';
|
||||||
|
|
||||||
|
type NavUser = {
|
||||||
|
display_name: string;
|
||||||
|
email: string;
|
||||||
|
avatar_url?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function SiteNav({ currentPath }: { currentPath?: string }) {
|
||||||
|
const [user, setUser] = useState<NavUser | null | undefined>(undefined);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch(`${API_BASE}/me`, { credentials: 'include' })
|
||||||
|
.then(r => r.ok ? r.json() as Promise<NavUser> : null)
|
||||||
|
.then(u => setUser(u))
|
||||||
|
.catch(() => setUser(null));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const logout = async () => {
|
||||||
|
await fetch(`${API_BASE}/auth/logout`, { method: 'POST', credentials: 'include' });
|
||||||
|
window.location.href = '/';
|
||||||
|
};
|
||||||
|
|
||||||
|
const linkCls = (path: string) =>
|
||||||
|
`transition-colors text-sm ${currentPath === path ? 'text-white' : 'text-[#666] hover:text-white'}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className="flex items-center justify-between px-6 py-4 border-b border-[#1a1a1a]">
|
||||||
|
<Link href="/" className="text-white font-bold text-lg tracking-tight hover:opacity-80 transition-opacity">
|
||||||
|
arcrun
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4 text-sm">
|
||||||
|
<Link href="/integrations" className={linkCls('/integrations')}>Integrations</Link>
|
||||||
|
<Link href="/api-docs" className={linkCls('/api-docs')}>API</Link>
|
||||||
|
<a
|
||||||
|
href="https://github.com/richblack/arcrun"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-[#666] hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
GitHub
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{user === undefined ? (
|
||||||
|
// Loading — placeholder to prevent layout shift
|
||||||
|
<div className="w-20 h-7" />
|
||||||
|
) : user ? (
|
||||||
|
<>
|
||||||
|
<AppLauncher userEmail={user.email} />
|
||||||
|
<Link href="/dashboard" className="flex items-center gap-2 hover:opacity-80 transition-opacity">
|
||||||
|
{user.avatar_url && (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img src={user.avatar_url} alt="" width={26} height={26} className="rounded-full" />
|
||||||
|
)}
|
||||||
|
<span className="text-[#aaa]">{user.display_name}</span>
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
onClick={logout}
|
||||||
|
className="text-[#555] hover:text-[#888] transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
登出
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Link
|
||||||
|
href="/login"
|
||||||
|
className="bg-indigo-600 hover:bg-indigo-500 text-white px-4 py-1.5 rounded-md font-medium transition-colors"
|
||||||
|
>
|
||||||
|
Get API Key
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
// Matrix App Launcher 九宮格清單
|
||||||
|
// 來源:matrix/identity/.agents/specs/identity/apps.json(v0 過渡複製,未來 v1 抽進 @matrix/identity-ui)
|
||||||
|
// 規範:matrix/identity/.agents/specs/identity/design.md §2.5
|
||||||
|
|
||||||
|
export type AppEntry = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
icon?: string;
|
||||||
|
description?: string;
|
||||||
|
access?: 'public' | 'allowlist';
|
||||||
|
allowlist_emails?: string[];
|
||||||
|
locked_tooltip?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MATRIX_APPS: AppEntry[] = [
|
||||||
|
{
|
||||||
|
id: 'arcrun',
|
||||||
|
name: 'Arcrun',
|
||||||
|
url: 'https://arcrun.dev',
|
||||||
|
icon: '🔄',
|
||||||
|
description: '工作流引擎與零件平台',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'dashboard',
|
||||||
|
name: 'Dashboard',
|
||||||
|
url: 'https://arcrun.dev/dashboard',
|
||||||
|
icon: '🔑',
|
||||||
|
description: 'API Key 管理',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'integrations',
|
||||||
|
name: 'Integrations',
|
||||||
|
url: 'https://arcrun.dev/integrations',
|
||||||
|
icon: '🧩',
|
||||||
|
description: '服務目錄',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mira',
|
||||||
|
name: 'Mira',
|
||||||
|
url: 'https://arcrun.dev/mira',
|
||||||
|
icon: '🌊',
|
||||||
|
description: '個人化 KM 河道',
|
||||||
|
access: 'allowlist',
|
||||||
|
allowlist_emails: ['leo21c@gmail.com'],
|
||||||
|
locked_tooltip: '即將開放',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function isAppAccessible(app: AppEntry, userEmail: string | null): boolean {
|
||||||
|
if (app.access !== 'allowlist') return true;
|
||||||
|
if (!userEmail) return false;
|
||||||
|
return (app.allowlist_emails ?? []).includes(userEmail);
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useState, useCallback } from 'react';
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import SiteNav from '../components/SiteNav';
|
||||||
|
|
||||||
const API_BASE = process.env.NEXT_PUBLIC_API_BASE ?? 'https://cypher.arcrun.dev';
|
const API_BASE = process.env.NEXT_PUBLIC_API_BASE ?? 'https://cypher.arcrun.dev';
|
||||||
|
|
||||||
@@ -89,11 +90,6 @@ export default function DashboardPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const logout = async () => {
|
|
||||||
await fetch(`${API_BASE}/auth/logout`, { method: 'POST', credentials: 'include' });
|
|
||||||
window.location.href = '/';
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center">
|
<div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center">
|
||||||
@@ -115,22 +111,7 @@ export default function DashboardPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#0a0a0a] text-[#ededed]">
|
<div className="min-h-screen bg-[#0a0a0a] text-[#ededed]">
|
||||||
{/* Nav */}
|
<SiteNav currentPath="/dashboard" />
|
||||||
<nav className="flex items-center justify-between px-6 py-4 border-b border-[#1a1a1a]">
|
|
||||||
<Link href="/" className="text-white font-bold text-lg tracking-tight hover:opacity-80 transition-opacity">
|
|
||||||
arcrun
|
|
||||||
</Link>
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
{user.avatar_url && (
|
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
|
||||||
<img src={user.avatar_url} alt="" width={28} height={28} className="rounded-full" />
|
|
||||||
)}
|
|
||||||
<span className="text-[#666] text-sm">{user.email}</span>
|
|
||||||
<button onClick={logout} className="text-[#555] hover:text-[#888] text-sm transition-colors cursor-pointer">
|
|
||||||
登出
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<main className="max-w-2xl mx-auto px-6 py-12">
|
<main className="max-w-2xl mx-auto px-6 py-12">
|
||||||
<h1 className="text-2xl font-bold text-white mb-1">歡迎,{user.display_name}</h1>
|
<h1 className="text-2xl font-bold text-white mb-1">歡迎,{user.display_name}</h1>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
export const runtime = 'edge';
|
export const runtime = 'edge';
|
||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import SiteNav from '../components/SiteNav';
|
||||||
|
|
||||||
type Recipe = {
|
type Recipe = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -64,19 +65,7 @@ async function IntegrationsContent({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#0a0a0a] text-[#ededed]">
|
<div className="min-h-screen bg-[#0a0a0a] text-[#ededed]">
|
||||||
{/* Nav */}
|
<SiteNav currentPath="/integrations" />
|
||||||
<nav className="flex items-center justify-between px-6 py-4 border-b border-[#1a1a1a]">
|
|
||||||
<Link href="/" className="text-white font-bold text-lg tracking-tight hover:opacity-80 transition-opacity">
|
|
||||||
arcrun
|
|
||||||
</Link>
|
|
||||||
<div className="flex items-center gap-4 text-sm">
|
|
||||||
<Link href="/api-docs" className="text-[#666] hover:text-white transition-colors">API</Link>
|
|
||||||
<Link href="/login"
|
|
||||||
className="bg-indigo-600 hover:bg-indigo-500 text-white px-4 py-1.5 rounded-md text-sm font-medium transition-colors">
|
|
||||||
Get API Key
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div className="max-w-5xl mx-auto px-6 py-12">
|
<div className="max-w-5xl mx-auto px-6 py-12">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
// Mira 共用 Markdown 渲染器(河道 + Wiki 共用)
|
||||||
|
// SDD: polaris/mira/.agents/specs/mira-app/design.md §3.5.7
|
||||||
|
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import ReactMarkdown from 'react-markdown';
|
||||||
|
import remarkGfm from 'remark-gfm';
|
||||||
|
|
||||||
|
export function MarkdownView({ text }: { text: string }) {
|
||||||
|
const cleaned = useMemo(() => stripLogseqMeta(text), [text]);
|
||||||
|
return (
|
||||||
|
<div className="mira-md">
|
||||||
|
<ReactMarkdown
|
||||||
|
remarkPlugins={[remarkGfm]}
|
||||||
|
components={{
|
||||||
|
a: ({ href, children, ...rest }) => (
|
||||||
|
<a
|
||||||
|
href={href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="wiki-link"
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
),
|
||||||
|
// 圖片不直接 inline 顯示(避免大圖打亂 feed),改成連結
|
||||||
|
img: ({ src, alt }) => {
|
||||||
|
const href = typeof src === 'string' ? src : '';
|
||||||
|
return href ? (
|
||||||
|
<a
|
||||||
|
href={href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="wiki-link"
|
||||||
|
style={{ fontStyle: 'italic' }}
|
||||||
|
>
|
||||||
|
🖼 {alt || 'image'}
|
||||||
|
</a>
|
||||||
|
) : null;
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{cleaned}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strip Logseq 專屬語法
|
||||||
|
// - 屬性行:`xxx:: yyy`、`collapsed:: true`、`id:: ...`、`logseq.order-list-type:: ...`
|
||||||
|
// - block ref:`((uuid))` 暫時保留為純文字
|
||||||
|
export function stripLogseqMeta(text: string): string {
|
||||||
|
return text
|
||||||
|
.split('\n')
|
||||||
|
.filter((line) => {
|
||||||
|
const trimmed = line.trimStart();
|
||||||
|
if (/^[a-zA-Z][a-zA-Z0-9_.-]*::\s/.test(trimmed)) return false;
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.join('\n');
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,87 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
// Mira 子應用 layout
|
||||||
|
// SDD: polaris/mira/.agents/specs/mira-app/design.md §5.5
|
||||||
|
// 規範:白名單 user 進得去;非白名單 user 看到「即將開放」頁
|
||||||
|
// middleware 已做未登入跳 /login?redirect=/mira 檢查(不在這裡重做)
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import SiteNav from '../components/SiteNav';
|
||||||
|
import { MATRIX_APPS } from '../components/apps';
|
||||||
|
|
||||||
|
const API_BASE = process.env.NEXT_PUBLIC_API_BASE ?? 'https://cypher.arcrun.dev';
|
||||||
|
|
||||||
|
type Me = { email: string; display_name: string; api_key: string };
|
||||||
|
|
||||||
|
const MIRA = MATRIX_APPS.find(a => a.id === 'mira');
|
||||||
|
const ALLOWED = new Set(MIRA?.allowlist_emails ?? []);
|
||||||
|
|
||||||
|
export default function MiraLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
const [me, setMe] = useState<Me | null | undefined>(undefined);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch(`${API_BASE}/me`, { credentials: 'include' })
|
||||||
|
.then(r => r.ok ? r.json() as Promise<Me> : null)
|
||||||
|
.then(u => setMe(u))
|
||||||
|
.catch(() => setMe(null));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (me === undefined) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SiteNav currentPath="/mira" />
|
||||||
|
<div className="flex-1 flex items-center justify-center text-[#666]">載入中…</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (me === null) {
|
||||||
|
// 理論上 middleware 已擋住,但保險
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.location.href = '/login?redirect=/mira';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ALLOWED.has(me.email)) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SiteNav currentPath="/mira" />
|
||||||
|
<BetaBlocked email={me.email} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SiteNav currentPath="/mira" />
|
||||||
|
{children}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BetaBlocked({ email }: { email: string }) {
|
||||||
|
return (
|
||||||
|
<main className="flex-1 flex items-center justify-center px-6">
|
||||||
|
<div className="max-w-md text-center space-y-4">
|
||||||
|
<div className="text-6xl mb-2">🌊</div>
|
||||||
|
<h1 className="text-3xl font-bold text-white">Mira 仍封測中</h1>
|
||||||
|
<p className="text-[#888] leading-relaxed">
|
||||||
|
Mira 是 arcrun 的個人化 KM 河道,目前僅開放給少數測試用戶。
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-[#555]">
|
||||||
|
你登入的帳號是 <span className="font-mono text-[#888]">{email}</span>,
|
||||||
|
不在白名單內。準備好對外開放時會公告。
|
||||||
|
</p>
|
||||||
|
<div className="pt-4">
|
||||||
|
<a
|
||||||
|
href="/dashboard"
|
||||||
|
className="inline-block bg-indigo-600 hover:bg-indigo-500 text-white px-5 py-2 rounded-md text-sm font-medium transition-colors"
|
||||||
|
>
|
||||||
|
回 Dashboard
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,757 @@
|
|||||||
|
/* Mira — 社群貼文式 KM 河道
|
||||||
|
* 視覺參考:Facebook / X / LinkedIn
|
||||||
|
* Scope 在 .mira-app 容器內,避免汙染 landing 的 Tailwind 樣式
|
||||||
|
*/
|
||||||
|
|
||||||
|
.mira-app {
|
||||||
|
/* Surfaces */
|
||||||
|
--mira-bg-0: #0e0e0d;
|
||||||
|
--mira-bg-1: #1a1a18;
|
||||||
|
--mira-bg-2: #252320;
|
||||||
|
--mira-bg-3: #302d28;
|
||||||
|
|
||||||
|
--mira-line: #2e2c27;
|
||||||
|
--mira-line-soft: #1f1d1a;
|
||||||
|
|
||||||
|
/* Text — 高對比 */
|
||||||
|
--mira-text-1: #f0eee8;
|
||||||
|
--mira-text-2: #c9c4b8;
|
||||||
|
--mira-text-3: #9c968a;
|
||||||
|
--mira-text-4: #6c685e;
|
||||||
|
|
||||||
|
--mira-accent: oklch(0.78 0.13 75);
|
||||||
|
--mira-accent-soft: oklch(0.78 0.13 75 / 0.16);
|
||||||
|
--mira-accent-line: oklch(0.78 0.13 75 / 0.4);
|
||||||
|
|
||||||
|
--mira-conflict: oklch(0.72 0.14 30);
|
||||||
|
--mira-conflict-soft: oklch(0.72 0.14 30 / 0.13);
|
||||||
|
|
||||||
|
--mira-src-logseq: oklch(0.75 0.08 250);
|
||||||
|
--mira-src-mobile: oklch(0.78 0.07 160);
|
||||||
|
--mira-src-tg: oklch(0.78 0.08 220);
|
||||||
|
--mira-src-rss: oklch(0.78 0.08 30);
|
||||||
|
--mira-src-ai: oklch(0.82 0.10 95);
|
||||||
|
|
||||||
|
--mira-radius-sm: 6px;
|
||||||
|
--mira-radius: 10px;
|
||||||
|
--mira-radius-lg: 14px;
|
||||||
|
|
||||||
|
--mira-font-zh: "Noto Sans TC", "PingFang TC", "Microsoft JhengHei", system-ui, sans-serif;
|
||||||
|
--mira-font-en: "Inter Tight", "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||||
|
--mira-font-mono: "JetBrains Mono", "SF Mono", Menlo, monospace;
|
||||||
|
|
||||||
|
background: var(--mira-bg-0);
|
||||||
|
color: var(--mira-text-1);
|
||||||
|
font-family: var(--mira-font-zh);
|
||||||
|
/* 不啟用 palt:保留中文標點全寬,避免標點看起來只有半寬 */
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
line-height: 1.65;
|
||||||
|
min-height: calc(100vh - 73px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mira-app .mira-num,
|
||||||
|
.mira-app .mira-en {
|
||||||
|
font-family: var(--mira-font-en);
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mira-app .mira-content {
|
||||||
|
max-width: 920px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 24px 16px 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.mira-app .mira-content {
|
||||||
|
padding: 12px 0 60px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── card 卡片 ── */
|
||||||
|
.mira-app .mira-card {
|
||||||
|
background: var(--mira-bg-1);
|
||||||
|
border: 1px solid var(--mira-line);
|
||||||
|
border-radius: var(--mira-radius);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
/* 不要 overflow: hidden — 會 clip ⋮ menu dropdown */
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.mira-app .mira-card {
|
||||||
|
border-radius: 0;
|
||||||
|
border-left: none;
|
||||||
|
border-right: none;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── composer 寫貼文 ── */
|
||||||
|
.mira-app .mira-composer-card {
|
||||||
|
padding: 14px 16px;
|
||||||
|
}
|
||||||
|
.mira-app .mira-composer-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
.mira-app .mira-composer-textarea {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
resize: none;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.65;
|
||||||
|
color: var(--mira-text-1);
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
.mira-app .mira-composer-textarea::placeholder {
|
||||||
|
color: var(--mira-text-3);
|
||||||
|
}
|
||||||
|
.mira-app .mira-composer-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding-top: 10px;
|
||||||
|
margin-top: 8px;
|
||||||
|
border-top: 1px solid var(--mira-line-soft);
|
||||||
|
margin-left: 52px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── avatar ── */
|
||||||
|
.mira-app .mira-avatar {
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: var(--mira-bg-2);
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
.mira-app .mira-avatar-fallback {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
background: linear-gradient(135deg, var(--mira-bg-2), var(--mira-bg-3));
|
||||||
|
color: var(--mira-text-1);
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: var(--mira-font-en);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── post 貼文 ── */
|
||||||
|
.mira-app .mira-post {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.mira-app .mira-post-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 14px 16px 10px;
|
||||||
|
}
|
||||||
|
.mira-app .mira-post-author {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.mira-app .mira-post-name {
|
||||||
|
font-size: 14.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--mira-text-1);
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
.mira-app .mira-post-time {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--mira-text-3);
|
||||||
|
font-family: var(--mira-font-en);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── post body 內文 ── */
|
||||||
|
.mira-app .mira-post-body {
|
||||||
|
padding: 0 16px 12px;
|
||||||
|
}
|
||||||
|
.mira-app .mira-post-content {
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.7;
|
||||||
|
color: var(--mira-text-1);
|
||||||
|
word-break: break-word;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
.mira-app .mira-post-content-full {
|
||||||
|
/* 展開模式:顯示每個 sub-block 為獨立行,可編輯 */
|
||||||
|
font-size: 14.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* react-markdown render 樣式 */
|
||||||
|
.mira-app .mira-md > *:first-child { margin-top: 0; }
|
||||||
|
.mira-app .mira-md > *:last-child { margin-bottom: 0; }
|
||||||
|
|
||||||
|
.mira-app .mira-md p {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mira-app .mira-md h1,
|
||||||
|
.mira-app .mira-md h2,
|
||||||
|
.mira-app .mira-md h3,
|
||||||
|
.mira-app .mira-md h4,
|
||||||
|
.mira-app .mira-md h5,
|
||||||
|
.mira-app .mira-md h6 {
|
||||||
|
margin: 14px 0 6px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--mira-text-1);
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
.mira-app .mira-md h1 { font-size: 1.5em; }
|
||||||
|
.mira-app .mira-md h2 { font-size: 1.3em; }
|
||||||
|
.mira-app .mira-md h3 { font-size: 1.15em; font-weight: 600; }
|
||||||
|
.mira-app .mira-md h4,
|
||||||
|
.mira-app .mira-md h5,
|
||||||
|
.mira-app .mira-md h6 { font-size: 1em; font-weight: 600; }
|
||||||
|
|
||||||
|
.mira-app .mira-md ul,
|
||||||
|
.mira-app .mira-md ol {
|
||||||
|
margin: 4px 0 8px;
|
||||||
|
padding-left: 22px;
|
||||||
|
}
|
||||||
|
.mira-app .mira-md ul {
|
||||||
|
list-style: disc;
|
||||||
|
}
|
||||||
|
.mira-app .mira-md ul ul {
|
||||||
|
list-style: circle;
|
||||||
|
}
|
||||||
|
.mira-app .mira-md ul ul ul {
|
||||||
|
list-style: square;
|
||||||
|
}
|
||||||
|
.mira-app .mira-md ol {
|
||||||
|
list-style: decimal;
|
||||||
|
}
|
||||||
|
.mira-app .mira-md ul ul,
|
||||||
|
.mira-app .mira-md ul ol,
|
||||||
|
.mira-app .mira-md ol ul,
|
||||||
|
.mira-app .mira-md ol ol {
|
||||||
|
margin: 4px 0;
|
||||||
|
}
|
||||||
|
.mira-app .mira-md li {
|
||||||
|
margin: 2px 0;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
/* GFM task list 的 li 不顯示 bullet(checkbox 取代)*/
|
||||||
|
.mira-app .mira-md li:has(> input[type="checkbox"]) {
|
||||||
|
list-style: none;
|
||||||
|
margin-left: -22px;
|
||||||
|
}
|
||||||
|
.mira-app .mira-md li > p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mira-app .mira-md blockquote {
|
||||||
|
margin: 8px 0;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-left: 3px solid var(--mira-line);
|
||||||
|
color: var(--mira-text-2);
|
||||||
|
background: var(--mira-bg-2);
|
||||||
|
border-radius: 0 4px 4px 0;
|
||||||
|
}
|
||||||
|
.mira-app .mira-md blockquote p:last-child { margin-bottom: 0; }
|
||||||
|
|
||||||
|
.mira-app .mira-md hr {
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid var(--mira-line);
|
||||||
|
margin: 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mira-app .mira-md code {
|
||||||
|
font-family: var(--mira-font-mono);
|
||||||
|
font-size: 0.9em;
|
||||||
|
background: var(--mira-bg-2);
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid var(--mira-line-soft);
|
||||||
|
}
|
||||||
|
.mira-app .mira-md pre {
|
||||||
|
margin: 8px 0;
|
||||||
|
padding: 12px;
|
||||||
|
background: var(--mira-bg-2);
|
||||||
|
border: 1px solid var(--mira-line-soft);
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
.mira-app .mira-md pre code {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 0.85em;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mira-app .mira-md table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 8px 0;
|
||||||
|
width: 100%;
|
||||||
|
font-size: 0.95em;
|
||||||
|
}
|
||||||
|
.mira-app .mira-md th,
|
||||||
|
.mira-app .mira-md td {
|
||||||
|
border: 1px solid var(--mira-line);
|
||||||
|
padding: 6px 10px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.mira-app .mira-md th {
|
||||||
|
background: var(--mira-bg-2);
|
||||||
|
color: var(--mira-text-1);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.mira-app .mira-md tr:nth-child(even) {
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mira-app .mira-md strong { color: var(--mira-text-1); font-weight: 700; }
|
||||||
|
.mira-app .mira-md em { font-style: italic; }
|
||||||
|
.mira-app .mira-md del { color: var(--mira-text-3); }
|
||||||
|
|
||||||
|
/* GFM task list */
|
||||||
|
.mira-app .mira-md input[type="checkbox"] {
|
||||||
|
margin-right: 6px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── show more ── */
|
||||||
|
.mira-app .mira-show-more {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--mira-accent);
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px 0;
|
||||||
|
margin-top: 4px;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.mira-app .mira-show-more:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── footer 互動列 ── */
|
||||||
|
.mira-app .mira-post-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
border-top: 1px solid var(--mira-line-soft);
|
||||||
|
padding: 4px 8px;
|
||||||
|
margin: 0 8px;
|
||||||
|
}
|
||||||
|
.mira-app .mira-action-btn {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--mira-text-2);
|
||||||
|
font-size: 13.5px;
|
||||||
|
font-family: inherit;
|
||||||
|
padding: 8px 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 6px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
transition: background 0.12s, color 0.12s;
|
||||||
|
}
|
||||||
|
.mira-app .mira-action-btn:hover,
|
||||||
|
.mira-app .mira-action-btn:active {
|
||||||
|
background: var(--mira-bg-2);
|
||||||
|
color: var(--mira-text-1);
|
||||||
|
}
|
||||||
|
.mira-app .mira-action-count {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--mira-text-3);
|
||||||
|
font-family: var(--mira-font-en);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── replies 對整篇的留言 ── */
|
||||||
|
.mira-app .mira-post-replies {
|
||||||
|
background: var(--mira-bg-0);
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-top: 1px solid var(--mira-line-soft);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.mira-app .mira-show-replies {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--mira-text-2);
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 6px 4px;
|
||||||
|
text-align: left;
|
||||||
|
font-family: inherit;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.mira-app .mira-show-replies:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.mira-app .mira-reply-composer {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
border-top: 1px solid var(--mira-line-soft);
|
||||||
|
background: var(--mira-bg-0);
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── reply line ── */
|
||||||
|
.mira-app .mira-reply-line {
|
||||||
|
margin-bottom: 6px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
background: var(--mira-bg-1);
|
||||||
|
border-radius: 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.mira-app .mira-reply-line.is-ai {
|
||||||
|
background: var(--mira-accent-soft);
|
||||||
|
}
|
||||||
|
/* reply line 內的 ⋮ 永遠顯示(取代 hover only)*/
|
||||||
|
.mira-app .mira-reply-line .mira-menu-wrap {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.mira-app .mira-reply-icon {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--mira-text-2);
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
.mira-app .mira-reply-line.is-ai .mira-reply-icon {
|
||||||
|
color: var(--mira-accent);
|
||||||
|
}
|
||||||
|
.mira-app .mira-reply-content {
|
||||||
|
font-size: 13.5px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--mira-text-1);
|
||||||
|
word-break: break-word;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
.mira-app .mira-reply-nested {
|
||||||
|
margin-left: 20px;
|
||||||
|
margin-top: 4px;
|
||||||
|
padding-left: 8px;
|
||||||
|
border-left: 2px solid var(--mira-line-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── source tag ── */
|
||||||
|
.mira-app .src-tag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 1px 7px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-family: var(--mira-font-en);
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
background: var(--mira-bg-2);
|
||||||
|
color: var(--mira-text-2);
|
||||||
|
border: 1px solid var(--mira-line);
|
||||||
|
}
|
||||||
|
.mira-app .src-dot {
|
||||||
|
width: 5px;
|
||||||
|
height: 5px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--mira-text-3);
|
||||||
|
}
|
||||||
|
.mira-app .src-tag.logseq .src-dot { background: var(--mira-src-logseq); }
|
||||||
|
.mira-app .src-tag.mobile .src-dot { background: var(--mira-src-mobile); }
|
||||||
|
.mira-app .src-tag.tg .src-dot { background: var(--mira-src-tg); }
|
||||||
|
.mira-app .src-tag.rss .src-dot { background: var(--mira-src-rss); }
|
||||||
|
.mira-app .src-tag.ai .src-dot { background: var(--mira-src-ai); }
|
||||||
|
|
||||||
|
/* ── wiki link ── */
|
||||||
|
.mira-app .wiki-link {
|
||||||
|
color: var(--mira-accent);
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
border-bottom: 1px dashed var(--mira-accent-line);
|
||||||
|
padding-bottom: 1px;
|
||||||
|
}
|
||||||
|
.mira-app .wiki-link:hover {
|
||||||
|
background: var(--mira-accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── empty state ── */
|
||||||
|
.mira-app .empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 80px 20px;
|
||||||
|
color: var(--mira-text-2);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── icon button ── */
|
||||||
|
.mira-app .mira-icon-btn {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--mira-text-2);
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 0;
|
||||||
|
transition: background 0.12s, color 0.12s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.mira-app .mira-icon-btn:hover,
|
||||||
|
.mira-app .mira-icon-btn:active {
|
||||||
|
background: var(--mira-bg-2);
|
||||||
|
color: var(--mira-text-1);
|
||||||
|
}
|
||||||
|
.mira-app .mira-icon-btn-vertical {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── ⋮ menu (dropdown) ── */
|
||||||
|
.mira-app .mira-menu-wrap {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.mira-app .mira-menu {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: calc(100% + 4px);
|
||||||
|
min-width: 160px;
|
||||||
|
background: var(--mira-bg-1);
|
||||||
|
border: 1px solid var(--mira-line);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
|
||||||
|
padding: 4px;
|
||||||
|
z-index: 10;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1px;
|
||||||
|
}
|
||||||
|
.mira-app .mira-menu-item {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
text-align: left;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: 5px;
|
||||||
|
color: var(--mira-text-1);
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
transition: background 0.12s;
|
||||||
|
}
|
||||||
|
.mira-app .mira-menu-item:hover,
|
||||||
|
.mira-app .mira-menu-item:active {
|
||||||
|
background: var(--mira-bg-2);
|
||||||
|
}
|
||||||
|
.mira-app .mira-menu-item.danger {
|
||||||
|
color: var(--mira-conflict);
|
||||||
|
}
|
||||||
|
.mira-app .mira-menu-item.danger:hover {
|
||||||
|
background: var(--mira-conflict-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── block-line(FullContent 模式)── */
|
||||||
|
.mira-app .mira-block-line {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.mira-app .mira-block-line .mira-menu-wrap {
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.12s;
|
||||||
|
}
|
||||||
|
.mira-app .mira-block-line:hover .mira-menu-wrap {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
@media (hover: none) {
|
||||||
|
/* 觸控裝置一律顯示 ⋮ */
|
||||||
|
.mira-app .mira-block-line .mira-menu-wrap {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.mira-app .mira-menu-wrap:has(.mira-menu) { opacity: 1 !important; }
|
||||||
|
|
||||||
|
/* ── edit textarea ── */
|
||||||
|
.mira-app .mira-edit-textarea {
|
||||||
|
width: 100%;
|
||||||
|
background: var(--mira-bg-0);
|
||||||
|
border: 1px solid var(--mira-accent-line);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.7;
|
||||||
|
color: var(--mira-text-1);
|
||||||
|
resize: vertical;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.mira-app .mira-edit-textarea:focus {
|
||||||
|
border-color: var(--mira-accent);
|
||||||
|
}
|
||||||
|
.mira-app .mira-edit-textarea::placeholder {
|
||||||
|
color: var(--mira-text-3);
|
||||||
|
}
|
||||||
|
.mira-app .mira-edit-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── markdown toolbar ── */
|
||||||
|
.mira-app .mira-md-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
background: var(--mira-bg-2);
|
||||||
|
border: 1px solid var(--mira-line);
|
||||||
|
border-bottom: none;
|
||||||
|
border-top-left-radius: 8px;
|
||||||
|
border-top-right-radius: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
/* compact 模式:給 PostComposer 的 actions 列 inline 用 */
|
||||||
|
.mira-app .mira-md-toolbar.compact {
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
.mira-app .mira-md-btn {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 5px;
|
||||||
|
color: var(--mira-text-2);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: inherit;
|
||||||
|
transition: background 0.12s, color 0.12s;
|
||||||
|
padding: 0;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.mira-app .mira-md-btn:hover,
|
||||||
|
.mira-app .mira-md-btn:active {
|
||||||
|
background: var(--mira-bg-3);
|
||||||
|
color: var(--mira-text-1);
|
||||||
|
}
|
||||||
|
.mira-app .mira-md-btn em {
|
||||||
|
font-style: italic;
|
||||||
|
font-family: serif;
|
||||||
|
}
|
||||||
|
.mira-app .mira-md-hint {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--mira-text-3);
|
||||||
|
font-family: var(--mira-font-en);
|
||||||
|
}
|
||||||
|
/* toolbar 之後的 textarea 接續成一塊 */
|
||||||
|
.mira-app .mira-md-toolbar + .mira-edit-textarea {
|
||||||
|
border-top-left-radius: 0;
|
||||||
|
border-top-right-radius: 0;
|
||||||
|
border-top: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── kbd 提示 ── */
|
||||||
|
.mira-app .mira-kbd {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--mira-text-3);
|
||||||
|
font-family: var(--mira-font-en);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── messages ── */
|
||||||
|
.mira-app .mira-msg-error {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--mira-conflict);
|
||||||
|
}
|
||||||
|
.mira-app .mira-error {
|
||||||
|
padding: 16px;
|
||||||
|
color: var(--mira-conflict);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── buttons ── */
|
||||||
|
.mira-app .mira-btn-primary {
|
||||||
|
background: var(--mira-accent);
|
||||||
|
border: none;
|
||||||
|
color: var(--mira-bg-0);
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 8px 18px;
|
||||||
|
border-radius: 999px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: inherit;
|
||||||
|
transition: opacity 0.12s, background 0.12s;
|
||||||
|
}
|
||||||
|
.mira-app .mira-btn-primary:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
.mira-app .mira-btn-primary:disabled,
|
||||||
|
.mira-app .mira-btn-primary.disabled {
|
||||||
|
background: var(--mira-bg-3);
|
||||||
|
color: var(--mira-text-3);
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mira-app .mira-btn-ghost {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--mira-line);
|
||||||
|
color: var(--mira-text-2);
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 6px 14px;
|
||||||
|
border-radius: 999px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.mira-app .mira-btn-ghost:hover {
|
||||||
|
background: var(--mira-bg-2);
|
||||||
|
color: var(--mira-text-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Mira 思考中(AI reply pending)── */
|
||||||
|
.mira-app .mira-thinking {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
.mira-app .mira-thinking-dots::after {
|
||||||
|
content: '⋯';
|
||||||
|
display: inline-block;
|
||||||
|
animation: mira-think-dots 1.4s infinite;
|
||||||
|
}
|
||||||
|
@keyframes mira-think-dots {
|
||||||
|
0%, 100% { opacity: 0.3; }
|
||||||
|
50% { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── skeleton loader ── */
|
||||||
|
.mira-app .mira-skel {
|
||||||
|
height: 60px;
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
var(--mira-bg-1) 0%,
|
||||||
|
var(--mira-bg-2) 50%,
|
||||||
|
var(--mira-bg-1) 100%
|
||||||
|
);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: mira-skel-shimmer 1.4s ease-in-out infinite;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
@keyframes mira-skel-shimmer {
|
||||||
|
0% { background-position: 200% 0; }
|
||||||
|
100% { background-position: -200% 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── rel time ── */
|
||||||
|
.mira-app .mira-rel-time {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
// Mira 首頁(Profile / KM Hub)
|
||||||
|
// SDD: polaris/mira/.agents/specs/mira-app/design.md §5.2
|
||||||
|
// 這頁是 leo 的 KM 首頁,列出所有同層子頁的入口
|
||||||
|
// 河道 / Wiki 是同層子頁,不是父子關係
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import './mira.css';
|
||||||
|
|
||||||
|
type Entry = {
|
||||||
|
href: string;
|
||||||
|
emoji: string;
|
||||||
|
title: string;
|
||||||
|
desc: string;
|
||||||
|
status?: 'live' | 'wip' | 'planned';
|
||||||
|
};
|
||||||
|
|
||||||
|
const ENTRIES: Entry[] = [
|
||||||
|
{
|
||||||
|
href: '/mira/feed',
|
||||||
|
emoji: '🌊',
|
||||||
|
title: '河道',
|
||||||
|
desc: '時間軸式貼文流,所有 source 進來的內容都在這',
|
||||||
|
status: 'live',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/mira/wiki',
|
||||||
|
emoji: '📚',
|
||||||
|
title: 'Wiki',
|
||||||
|
desc: 'leo 的個人觀點累積(Karpathy LLM Wiki 風格)',
|
||||||
|
status: 'live',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/mira/plan',
|
||||||
|
emoji: '📋',
|
||||||
|
title: '計劃',
|
||||||
|
desc: 'Task 列表 + 狀態切換 + workflow 觸發',
|
||||||
|
status: 'planned',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/mira/dissent',
|
||||||
|
emoji: '⚔️',
|
||||||
|
title: '異見牆',
|
||||||
|
desc: 'Triplet 列表(含 ai-idea / 矛盾標記)',
|
||||||
|
status: 'planned',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function MiraHubPage() {
|
||||||
|
return (
|
||||||
|
<main className="mira-app">
|
||||||
|
<div className="mira-content">
|
||||||
|
<header style={{ padding: '32px 0 24px' }}>
|
||||||
|
<h1 style={{ fontSize: 32, fontWeight: 700, color: '#fff', margin: 0 }}>
|
||||||
|
🦔 Mira
|
||||||
|
</h1>
|
||||||
|
<p style={{ color: '#888', fontSize: 15, marginTop: 6 }}>
|
||||||
|
leo 的個人 KM 系統
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div style={{ display: 'grid', gap: 12, paddingBottom: 40 }}>
|
||||||
|
{ENTRIES.map((e) => (
|
||||||
|
<EntryCard key={e.href} entry={e} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EntryCard({ entry }: { entry: Entry }) {
|
||||||
|
const isClickable = entry.status === 'live';
|
||||||
|
const card = (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '18px 20px',
|
||||||
|
background: '#1a1a1a',
|
||||||
|
border: '1px solid #2a2a2a',
|
||||||
|
borderRadius: 8,
|
||||||
|
opacity: isClickable ? 1 : 0.5,
|
||||||
|
cursor: isClickable ? 'pointer' : 'not-allowed',
|
||||||
|
transition: 'border-color 0.15s',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (isClickable) e.currentTarget.style.borderColor = '#444';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
if (isClickable) e.currentTarget.style.borderColor = '#2a2a2a';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 6 }}>
|
||||||
|
<span style={{ fontSize: 24 }}>{entry.emoji}</span>
|
||||||
|
<span style={{ fontSize: 18, fontWeight: 600, color: '#fff' }}>
|
||||||
|
{entry.title}
|
||||||
|
</span>
|
||||||
|
{entry.status === 'planned' && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
padding: '2px 8px',
|
||||||
|
background: '#2a2a3a',
|
||||||
|
color: '#aab',
|
||||||
|
borderRadius: 3,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
未實作
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ color: '#888', fontSize: 14, marginLeft: 36 }}>{entry.desc}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
return isClickable ? (
|
||||||
|
<Link href={entry.href} style={{ textDecoration: 'none' }}>
|
||||||
|
{card}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
card
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user