Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/workflows/website-pages.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,13 @@ on:
# this event must not be path-filtered.
pull_request:
workflow_dispatch:
# The marketplace repository pings us when its index rebuilds, so the
# generated plugin pages pick up new listings without a website commit;
# the weekly run is the safety net for missed pings (the token is optional).
repository_dispatch:
types: [market-updated]
schedule:
- cron: '41 5 * * 1'

permissions:
contents: read
Expand Down
86 changes: 86 additions & 0 deletions script/tests/market-generator.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
/** Website marketplace generation boundary tests. @module script/tests/market-generator */

import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { test } from 'node:test'
import { detailPage, installCommand, readmeBlock, requiresDedicatedProfile, validateMarketIndex } from '../../website/scripts/generate-market.mjs'

function entry(overrides = {}) {
return {
id: 'loop',
source: 'official',
displayName: 'Loop',
description: 'Recurring prompts.',
descriptionZh: '循环提示。',
author: { name: 'Test' },
category: 'workflow',
status: 'stable',
surfaces: { server: {} },
install: { rows: [{ name: 'dsh-loop', npm: { spec: 'dsh-loop' } }] },
...overrides,
}
}

test('market generator rejects unsafe paths and malformed install rows', () => {
assert.throws(() => validateMarketIndex(null), /not an object/)
assert.throws(() => validateMarketIndex({ schemaVersion: 2, entries: [] }), /schema version 2/)
assert.throws(() => validateMarketIndex({ schemaVersion: 1 }), /no entries array/)
assert.throws(() => validateMarketIndex({ schemaVersion: 1, entries: [null] }), /not an object/)
assert.throws(() => validateMarketIndex({ schemaVersion: 1, entries: [entry({ id: '../../outside' })] }), /unsafe id/)
assert.throws(() => validateMarketIndex({ schemaVersion: 1, entries: [entry(), entry()] }), /repeats id/)
assert.throws(() => validateMarketIndex({ schemaVersion: 1, entries: [entry({ displayName: '' })] }), /displayName/)
assert.throws(() => validateMarketIndex({ schemaVersion: 1, entries: [entry({ source: 'unknown' })] }), /unknown source/)
assert.throws(() => validateMarketIndex({ schemaVersion: 1, entries: [entry({ status: 'unknown' })] }), /unknown status/)
assert.throws(() => validateMarketIndex({ schemaVersion: 1, entries: [entry({ surfaces: null })] }), /surfaces/)
assert.throws(() => validateMarketIndex({ schemaVersion: 1, entries: [entry({ install: { rows: [] } })] }), /install.rows/)
assert.throws(() => validateMarketIndex({ schemaVersion: 1, entries: [entry({ install: { rows: [{}] } })] }), /package name and source/)
assert.throws(() => validateMarketIndex({ schemaVersion: 1, entries: [entry({ install: { rows: [{ name: 'x', npm: {} }] } })] }), /npm sources/)
assert.throws(() => validateMarketIndex({ schemaVersion: 1, entries: [entry({ install: { rows: [{ name: 'x', github: {} }] } })] }), /GitHub sources/)
const valid = { schemaVersion: 1, entries: [entry()] }
assert.equal(validateMarketIndex(valid), valid.entries)
const fallback = JSON.parse(readFileSync(new URL('../../website/scripts/market-fallback.json', import.meta.url), 'utf8'))
assert.equal(validateMarketIndex(fallback).length, 14)
})

test('third-party README content stays inert in generated Vue Markdown', () => {
const malicious = entry({
displayName: 'Line one\n# injected',
links: { repo: 'javascript:alert(1)', docs: 'https://example.com/docs' },
readme: '<!-- @include: ../../../../.git/config -->\n{{ globalThis.location }}\n<script>alert(1)</script>',
})
const block = readmeBlock(malicious)
assert.match(block, /^<pre v-pre/u)
assert.doesNotMatch(block, /<!--|\{\{|<script/u)
assert.match(block, /&lt;!-- @include/u)
assert.match(block, /&#123;&#123; globalThis\.location/u)
const page = detailPage(malicious, 'en')
assert.match(page, /title: "Line one # injected"/u)
assert.doesNotMatch(page, /javascript:/u)
assert.match(page, /https:\/\/example\.com\/docs/u)
})

test('install commands include every row and quote shell-sensitive GitHub specs', () => {
const github = entry({ install: { rows: [
{ name: 'a', github: { repo: 'owner/repo', ref: 'main', subdir: 'plugins/a' } },
{ name: 'b', github: { repo: 'owner/repo', ref: 'main', subdir: 'plugins/b' } },
] } })
assert.equal(installCommand(github), "dsh plugin --profile <name> add 'github:owner/repo#main&path:plugins/a' 'github:owner/repo#main&path:plugins/b'")
const mixed = entry({ install: { rows: [
{ name: 'a', npm: { spec: 'a' } },
{ name: 'b', github: { repo: 'owner/repo', ref: 'main' } },
] } })
assert.equal(installCommand(mixed), 'dsh plugin --profile <name> add <loop>')
})

test('ACP is documented as a dedicated-profile automation frontend', () => {
const acp = entry({
id: 'acp',
displayName: 'ACP Server',
install: { rows: [{ name: '@deepseek-ai/dsh-acp', npm: { spec: '@deepseek-ai/dsh-acp' } }] },
})
assert.equal(requiresDedicatedProfile(entry()), false)
assert.equal(requiresDedicatedProfile(acp), true)
assert.equal(installCommand(acp), 'dsh plugin --profile <automation-name> add @deepseek-ai/dsh-acp')
assert.match(detailPage(acp, 'en'), /owns stdio.*dedicated profile/su)
assert.match(detailPage(acp, 'zh'), /独占 stdio.*独立 profile/su)
})
3 changes: 3 additions & 0 deletions website/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
market/p/
market/catalog.json
en/market/p/
38 changes: 38 additions & 0 deletions website/.vitepress/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,11 +45,13 @@ const sharedTheme = {
// 使用与定制,开发手册收口 /plugins/ 路径下的插件开发内容。
const navZh = [
{ text: '用户手册', link: '/guide/', activeMatch: '/(guide|dsh|features|reference)' },
{ text: '插件市场', link: '/market/', activeMatch: '^/market' },
{ text: '开发手册', link: '/plugins/', activeMatch: '^/plugins' },
]

const navEn = [
{ text: 'User manual', link: '/en/guide/', activeMatch: '/en/(guide|dsh|features|reference)' },
{ text: 'Marketplace', link: '/en/market/', activeMatch: '/en/market' },
{ text: 'Developer manual', link: '/en/plugins/', activeMatch: '^/en/plugins' },
]

Expand Down Expand Up @@ -99,6 +101,24 @@ const sidebarZh = {
],
},
],
'/market/': [
{
text: '市场',
items: [
{ text: '插件目录', link: '/market/' },
{ text: '安装与更新', link: '/market/installing' },
{ text: '信任与安全', link: '/market/trust' },
],
},
{
text: '收录',
items: [
{ text: '提交你的插件', link: '/market/submit' },
{ text: 'Manifest 规范', link: '/market/manifest' },
{ text: '审查清单', link: '/market/review' },
],
},
],
'/plugins/': [
{
text: '开始',
Expand Down Expand Up @@ -185,6 +205,24 @@ const sidebarEn = {
],
},
],
'/en/market/': [
{
text: 'Market',
items: [
{ text: 'Plugin catalog', link: '/en/market/' },
{ text: 'Installing & updating', link: '/en/market/installing' },
{ text: 'Trust & safety', link: '/en/market/trust' },
],
},
{
text: 'Listings',
items: [
{ text: 'Submit your plugin', link: '/en/market/submit' },
{ text: 'Manifest spec', link: '/en/market/manifest' },
{ text: 'Review checklist', link: '/en/market/review' },
],
},
],
'/en/plugins/': [
{
text: 'Getting started',
Expand Down
98 changes: 98 additions & 0 deletions website/.vitepress/theme/components/MarketCatalog.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
<script setup>
import { computed, ref } from 'vue'
import { useData, withBase } from 'vitepress'
import catalog from '../../../market/catalog.json'

const query = ref('')
const surface = ref('all')
const source = ref('all')

const surfaces = [
{ id: 'all', label: '全部 / all' },
{ id: 'tui', label: 'TUI' },
{ id: 'web', label: 'Web' },
{ id: 'server', label: 'Server' },
{ id: 'automation', label: 'Automation' },
]
const sources = [
{ id: 'all', label: '全部 / all' },
{ id: 'official', label: 'official' },
{ id: 'dsh', label: 'dsh' },
{ id: 'community', label: 'community' },
]

const { lang } = useData()
const english = computed(() => lang.value.toLowerCase().startsWith('en'))

const describe = entry => (!english.value && entry.descriptionZh) || entry.description
const entryLink = entry => withBase(`${english.value ? '/en' : ''}/market/p/${entry.id}/`)

const surfaceBadge = entry =>
['tui', 'web', 'server', 'automation'].filter(key => entry.surfaces[key]).map(key => key === 'automation' ? 'Automation' : key.toUpperCase()).join('+') || '—'

const filtered = computed(() =>
catalog.filter(entry => {
if (surface.value !== 'all' && !entry.surfaces[surface.value]) return false
if (source.value !== 'all' && entry.source !== source.value) return false
const q = query.value.trim().toLowerCase()
if (q === '') return true
const corpus = [
entry.id,
entry.displayName,
entry.description,
entry.descriptionZh,
...(entry.provides.tools ?? []),
...(entry.provides.commands ?? []),
entry.category,
entry.author,
].filter(Boolean).join(' ').toLowerCase()
return q.split(/\s+/).every(token => corpus.includes(token))
}),
)
</script>

<template>
<div class="market-catalog">
<div class="filters">
<input v-model="query" type="search" placeholder="搜索 id / 描述 / 工具 · search" aria-label="search plugins" />
<select v-model="surface" aria-label="filter by surface">
<option v-for="option in surfaces" :key="option.id" :value="option.id">{{ option.label }}</option>
</select>
<select v-model="source" aria-label="filter by source">
<option v-for="option in sources" :key="option.id" :value="option.id">{{ option.label }}</option>
</select>
</div>
<p class="count">{{ filtered.length }} / {{ catalog.length }}</p>
<div class="cards">
<a v-for="entry in filtered" :key="entry.id" :href="entryLink(entry)" class="card">
<div class="head">
<strong>{{ entry.displayName }}</strong>
<span class="badges">
<span class="badge" :data-source="entry.source">{{ entry.source }}</span>
<span class="badge surface">{{ surfaceBadge(entry) }}</span>
<span v-if="entry.status !== 'stable'" class="badge status">{{ entry.status }}</span>
</span>
</div>
<p class="desc">{{ describe(entry) }}</p>
</a>
</div>
<p v-if="filtered.length === 0" class="empty">没有匹配的插件 · no matching plugins</p>
</div>
</template>

<style scoped>
.filters { display: flex; gap: 0.75rem; flex-wrap: wrap; margin: 0.5rem 0 0.25rem; }
.filters input { flex: 1 1 14rem; }
.count { color: var(--vp-c-text-2, #888); font-size: 0.85rem; margin: 0.25rem 0; }
.cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(17rem, 1fr)); gap: 0.75rem; margin-top: 0.5rem; }
.card { display: block; border: 1px solid var(--vp-c-border, #ddd); border-radius: 8px; padding: 0.75rem 0.9rem; text-decoration: none; color: inherit; transition: border-color 0.15s; }
.card:hover { border-color: var(--vp-c-brand, #3e8e7e); }
.head { display: flex; justify-content: space-between; gap: 0.5rem; align-items: baseline; flex-wrap: wrap; }
.badges { display: inline-flex; gap: 0.35rem; }
.badge { font-size: 0.7rem; border-radius: 999px; padding: 0.05rem 0.5rem; border: 1px solid var(--vp-c-border, #ddd); color: var(--vp-c-text-2, #888); }
.badge[data-source='official'] { color: var(--vp-c-brand, #3e8e7e); border-color: var(--vp-c-brand, #3e8e7e); }
.badge.surface { letter-spacing: 0; }
.badge.status { color: var(--vp-c-warning, #b8860b); border-color: var(--vp-c-warning, #b8860b); }
.desc { margin: 0.45rem 0 0; font-size: 0.86rem; color: var(--vp-c-text-2, #666); }
.empty { color: var(--vp-c-text-2, #888); }
</style>
5 changes: 5 additions & 0 deletions website/.vitepress/theme/custom.css
Original file line number Diff line number Diff line change
Expand Up @@ -532,3 +532,8 @@ html:not(.dark) .brand-home .VPNavBarTitle .VPImage.dark,
padding: 72px 20px 100px;
}
}

.vp-doc .market-readme {
white-space: pre-wrap;
overflow-wrap: anywhere;
}
2 changes: 2 additions & 0 deletions website/.vitepress/theme/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import DefaultTheme from 'vitepress/theme'
import type { Theme } from 'vitepress'
import Layout from './Layout.vue'
import MarketCatalog from './components/MarketCatalog.vue'
import MermaidDiagram from './MermaidDiagram.vue'
import './custom.css'

Expand All @@ -9,5 +10,6 @@ export default {
Layout,
enhanceApp({ app }) {
app.component('MermaidDiagram', MermaidDiagram)
app.component('MarketCatalog', MarketCatalog)
},
} satisfies Theme
15 changes: 15 additions & 0 deletions website/en/market/index.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
title: Plugin Marketplace
---

# Plugin Marketplace

Browse vetted Mayfly / dsh plugins: official ones (Ephemeral AI Lab, sources in the [marketplace repository](https://github.com/Ephemeral-AI-Lab/dsh-plugins)), curated dsh optional plugins, and community submissions.

Every entry installs directly through Mayfly's `/plugin` command, or manually with `dsh plugin --profile <name> add <package>` (npm and GitHub sources both work).

- Installing? See [Installing & updating](/en/market/installing).
- Trusting? See [Trust & safety](/en/market/trust).
- Submitting your own? See [Submit your plugin](/en/market/submit).

<MarketCatalog />
40 changes: 40 additions & 0 deletions website/en/market/installing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
---
title: Installing & updating
---

# Installing & updating

## Inside Mayfly (recommended)

```
/plugin # browse, search, open details
/plugin install <id> # e.g. /plugin install loop
/plugin install <id> --source github # install from a GitHub source
/plugin list # installed / updates / removed-from-market
/plugin uninstall <id>
/plugin refresh # force an index refresh
```

In the list, **Enter** opens the detail panel; `i` installs, `u` removes, `r` refreshes; type to filter.

## With the dsh CLI (any profile)

```sh
# npm source
dsh plugin --profile mayfly add @deepseek-ai/dsh-terminal-bash @deepseek-ai/dsh-tool-terminal

# GitHub source (monorepo subdirectories work; pin a commit when possible)
dsh plugin --profile mayfly add 'github:Ephemeral-AI-Lab/dsh-plugins#main&path:plugins/loop'
```

## After installing

**Bundle membership is a startup boundary**: after install or removal, restart Mayfly (or `dsh --profile <name>`) and **start a new session** for the new tools and commands to appear.

- Plugins with native dependencies (node-pty for codex-terminal) write their `allowBuilds` allowance into the profile's `pnpm-workspace.yaml` at install time — the pnpm ≥10 permit for build scripts.
- Entries with `profile-patch` activation (most dsh optional plugins) append assembly rows to the profile's `cordis.patch.yml`; `/plugin` does this automatically, manual installs follow each plugin's README.
- **ACP Server is different**: it is an automation frontend that owns stdin/stdout. Mayfly shows it for discovery and removal but refuses to install it into the current TUI profile; create a dedicated non-Mayfly profile for ACP.

## Updates & offline

`/plugin list` marks entries with newer versions (`↑`). The index caches under `$DSH_HOME/storages/mayfly-plugin-market/` and refreshes hourly by default; on failure the cached catalog still renders. Point the `mayfly.marketIndexUrl` setting at a mirror or a private marketplace if you need to.
Loading
Loading