feat(plugin-market-generator): record the source repository and parse options with commander

Copy each plugin's manifest repository as its web page and package
directory, converting shorthands and Git URLs with hosted-git-info. Parse
options and generate help with commander.
This commit is contained in:
turtle1999 committed 2026-10-15 05:37:54 +08:00
1 parent 434dc116f0
commit 14abe9543c
13 files changed
+229 -82

No files matched your search

+2
View File
@@ -92,6 +92,7 @@ External packages installed for runtime use or distributed inside the prebuilt b
| [`fast-xml-parser`](https://github.com/NaturalIntelligence/fast-xml-parser) | MIT |
| [`fflate`](https://github.com/101arrowz/fflate) | MIT |
| [`got`](https://github.com/sindresorhus/got) | MIT |
| [`hosted-git-info`](https://github.com/npm/hosted-git-info) | ISC |
| [`immer`](https://github.com/immerjs/immer) | MIT |
| [`ipaddr.js`](https://github.com/whitequark/ipaddr.js) | MIT |
| [`js-yaml`](https://github.com/nodeca/js-yaml) | MIT |
@@ -204,6 +205,7 @@ External packages **directly declared** for development, tests, types, or toolin
| [`@types/babel__code-frame`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
| [`@types/big.js`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
| [`@types/compression`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
| [`@types/hosted-git-info`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
| [`@types/js-yaml`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
| [`@types/jsdom`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
| [`@types/mdast`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
@@ -6,11 +6,11 @@
en: 531349889ccc3124
zh: 08769a2d9fc586fd
/plugin-market-index-generator/usage:
en: 528513d86b87fe4e
zh: 59e2ecba16dbe336
en: ed2a83d06f6789ef
zh: e5c1e26486dfefb0
/plugin-market-index-generator/what-gets-listed:
en: d12b091789de1751
zh: 8d57fd273c77d804
en: 7457f51ab0e41cbe
zh: 6c690391aaa7cf0a
/plugin-market-index-generator/caching-and-repeated-runs:
en: a62c9bf12310c33c
zh: 60aa01288f46133c
+2 -1
View File
@@ -37,6 +37,7 @@ pnpm --filter @deepseek-ai/dsh-plugin-market-generator start \
| `--inline-icon-bytes <n>` | `16384` | Largest icon embedded as a `data:` URL, at most 32768, the format's inline icon limit. |
| `--concurrency <n>` | `4` | Sources read at the same time. |
| `--timeout-ms <n>` | `60000` | Deadline for one request or Git command. |
| `-h`, `--help` | | Print these options and the exit statuses. |
The exit status is 0 when every source was read, 1 when a source could not be read, and 2 for invalid arguments or an invalid list. A list line that the index format would reject, such as an `http:` URL, a URL with credentials, a query, or a fragment, a URL with fewer than two path segments, or a tarball URL, is an invalid list. A source that cannot be read keeps its last successful result, so a temporary outage does not remove plugins from the index; the failure is still reported.
@@ -54,7 +55,7 @@ Each package becomes one plugin with one or more versions. A version is listed o
`engines.dsh` is the manifest's `engines.dsh`, trimmed. Without one, it is the conjunction of the manifest's `@deepseek-ai/dsh` and `@deepseek-ai/dsh-*` `peerDependencies` ranges; a peer of exactly `workspace:*`, `workspace:^`, or `workspace:~` means the running DSH version and adds nothing, and identical ranges appear once. A `*` range admits every version, so a version whose ranges are all `*` lists no `engines.dsh`. A version is skipped with a warning when installation would refuse it on every DSH version: an empty range, a `workspace:` value in `engines.dsh` or any other `workspace:` peer, an invalid range, or ranges that no version satisfies together.
Display fields come from the highest listed version and follow the rules DSH uses for installed plugins. `./locale/en.json` and `./locale/<language>.json` are resolved through the package `exports` map with the `import`, `node`, and `default` conditions, or literally without one, and every locale file must sit in the English file's directory; their `meta.title` and `meta.description` fall back to the manifest `name` and `description`. The icon is the manifest `icon`, a relative path, or else the exported `./icon`: SVG, PNG, JPEG, or WebP, at most 256 KiB, inside the package. `author` is the name part of the manifest `author` string, without its `<email>` and `(url)`, or the `name` of an author object. `homepage` is the manifest `homepage` as written when it is an absolute http(s) URL; nothing else, such as `repository`, stands in for a missing one. Malformed locale files, invalid locale names, and rejected icons are reported as warnings. Translations without English text or a manifest fallback are dropped because the format requires English. Every entry is checked with [`dsh-plugin-market-index`](../../packages/util/plugin-market-index/README.md) before it is written.
Display fields come from the highest listed version and follow the rules DSH uses for installed plugins. `./locale/en.json` and `./locale/<language>.json` are resolved through the package `exports` map with the `import`, `node`, and `default` conditions, or literally without one, and every locale file must sit in the English file's directory; their `meta.title` and `meta.description` fall back to the manifest `name` and `description`. The icon is the manifest `icon`, a relative path, or else the exported `./icon`: SVG, PNG, JPEG, or WebP, at most 256 KiB, inside the package. `author` is the name part of the manifest `author` string, without its `<email>` and `(url)`, or the `name` of an author object. `homepage` is the manifest `homepage` as written when it is an absolute http(s) URL; nothing else, such as `repository`, stands in for a missing one. `repository` is the web page of the manifest `repository`, a shorthand such as `github:o/r` or a URL, through [hosted-git-info](https://github.com/npm/hosted-git-info) for hosts it knows, without any `#` committish; another http(s) URL loses a `git+` prefix, credentials, query, fragment, and `.git` suffix. Its `directory` is copied when the format accepts it. A repository with no http(s) page, or an unusable `directory`, is reported and omitted. Malformed locale files, invalid locale names, and rejected icons are reported as warnings. Translations without English text or a manifest fallback are dropped because the format requires English. Every entry is checked with [`dsh-plugin-market-index`](../../packages/util/plugin-market-index/README.md) before it is written.
`metadata.keywords` is an optional array of strings for discovery. npm `package.json` `keywords` is the convention; index generators may supplement or adjust it. This reference generator copies the highest listed version's manifest `keywords` unchanged, including empty strings, duplicates, and an empty array. An absent field is omitted; a non-array value or an array containing any non-string produces a warning and omits the whole field without dropping the package.
+2 -1
View File
@@ -37,6 +37,7 @@ pnpm --filter @deepseek-ai/dsh-plugin-market-generator start \
| `--inline-icon-bytes <n>` | `16384` | 以 `data:` URL 内嵌的图标的最大字节数,不超过格式的内联图标上限 32768。 |
| `--concurrency <n>` | `4` | 同时读取的来源数。 |
| `--timeout-ms <n>` | `60000` | 单个请求或 Git 命令的时限。 |
| `-h`、`--help` | | 打印这些选项和退出状态。 |
所有来源都读取成功时退出状态为 0,有来源无法读取时为 1,参数无效或列表无效时为 2。索引格式会拒绝的列表行属于无效列表,例如 `http:` URL、带凭据、查询或片段的 URL、路径段少于两个的 URL,以及 tarball URL。无法读取的来源保留其上一次成功的结果,因此临时故障不会把插件从索引中移除;生成器仍会报告该失败。
@@ -54,7 +55,7 @@ pnpm --filter @deepseek-ai/dsh-plugin-market-generator start \
`engines.dsh` 取 manifest 的 `engines.dsh` 并去掉首尾空白。没有该字段时,它是 manifest 中 `@deepseek-ai/dsh` 和 `@deepseek-ai/dsh-*` 的 `peerDependencies` 范围的合取;恰为 `workspace:*`、`workspace:^` 或 `workspace:~` 的 peer 表示当前运行的 DSH 版本,不增加限制;相同的范围只出现一次。`*` 范围接受所有版本,因此范围全为 `*` 的版本不列出 `engines.dsh`。如果安装时在所有 DSH 版本上都会拒绝某个版本,该版本会被跳过并发出警告:范围为空、`engines.dsh` 中出现 `workspace:` 值或 peer 中出现其他 `workspace:` 范围、范围无效,或者没有任何版本能同时满足这些范围。
展示字段来自最高的列出版本,并遵循 DSH 读取已安装插件时的规则。`./locale/en.json` 和 `./locale/<language>.json` 通过包的 `exports` 映射以 `import`、`node` 和 `default` 条件解析,没有该映射时按字面路径读取,并且每个 locale 文件都必须与英文文件位于同一目录;其 `meta.title` 和 `meta.description` 缺失时回退到 manifest 的 `name` 和 `description`。图标是 manifest 的 `icon`(相对路径),否则是导出的 `./icon`:格式为 SVG、PNG、JPEG 或 WebP,不超过 256 KiB,并位于包内。`author` 是 manifest `author` 字符串中去掉 `<email>` 和 `(url)` 后的名字部分,或 author 对象的 `name`。`homepage` 在 manifest 的 `homepage` 是绝对 http(s) URL 时按原样复制;缺少时不会用 `repository` 等其他字段代替。格式错误的 locale 文件、无效的 locale 名称和被拒绝的图标会作为警告报告。由于该格式要求英文,没有英文文本也没有 manifest 回退值的翻译会被丢弃。每个条目在写出前都经过 [`dsh-plugin-market-index`](../../packages/util/plugin-market-index/README.zh.md) 校验。
展示字段来自最高的列出版本,并遵循 DSH 读取已安装插件时的规则。`./locale/en.json` 和 `./locale/<language>.json` 通过包的 `exports` 映射以 `import`、`node` 和 `default` 条件解析,没有该映射时按字面路径读取,并且每个 locale 文件都必须与英文文件位于同一目录;其 `meta.title` 和 `meta.description` 缺失时回退到 manifest 的 `name` 和 `description`。图标是 manifest 的 `icon`(相对路径),否则是导出的 `./icon`:格式为 SVG、PNG、JPEG 或 WebP,不超过 256 KiB,并位于包内。`author` 是 manifest `author` 字符串中去掉 `<email>` 和 `(url)` 后的名字部分,或 author 对象的 `name`。`homepage` 在 manifest 的 `homepage` 是绝对 http(s) URL 时按原样复制;缺少时不会用 `repository` 等其他字段代替。`repository` 是 manifest `repository`(如 `github:o/r` 这样的简写或 URL)对应的网页:已知主机通过 [hosted-git-info](https://github.com/npm/hosted-git-info) 转换,并去掉 `#` 后的 committish;其他 http(s) URL 去掉 `git+` 前缀、凭据、查询、片段和 `.git` 后缀。格式接受时复制其 `directory`。没有 http(s) 网页的仓库或不可用的 `directory` 会被报告并省略。格式错误的 locale 文件、无效的 locale 名称和被拒绝的图标会作为警告报告。由于该格式要求英文,没有英文文本也没有 manifest 回退值的翻译会被丢弃。每个条目在写出前都经过 [`dsh-plugin-market-index`](../../packages/util/plugin-market-index/README.zh.md) 校验。
`metadata.keywords` 是用于发现插件的可选字符串数组。按约定使用 npm `package.json` 的 `keywords`;索引生成器可以补充或调整它。本参考生成器原样复制最高列出版本 manifest 的 `keywords`,包括空字符串、重复值和空数组。字段缺省时省略;如果值不是数组,或数组包含任何非字符串成员,则发出警告并省略整个字段,但保留该包。
@@ -10,6 +10,8 @@
"license": "MIT",
"dependencies": {
"@deepseek-ai/dsh-plugin-market-index": "workspace:*",
"commander": "^15.0.0",
"hosted-git-info": "^9.0.3",
"resolve.exports": "^2.0.3",
"semver": "^7.8.5",
"tar": "^7.5.22",
@@ -17,6 +19,7 @@
},
"devDependencies": {
"@deepseek-ai/dsh-package-manifest": "workspace:*",
"@types/hosted-git-info": "^3.0.5",
"@types/semver": "^7.8.0",
"tsx": "^4.22.4"
}
+64 -58
View File
@@ -10,7 +10,7 @@
*/
import { existsSync, lstatSync, readdirSync, readFileSync, rmSync } from 'node:fs'
import { dirname, join, resolve } from 'node:path'
import { parseArgs } from 'node:util'
import { Command, CommanderError, InvalidArgumentError, Option } from 'commander'
import { MAX_INLINE_ICON_BYTES } from '@deepseek-ai/dsh-plugin-market-index'
import { writeIfChanged } from './files.ts'
import { generate } from './generate.ts'
@@ -18,78 +18,66 @@ import { GENERATED_ICON } from './metadata.ts'
import { parseSources } from './sources.ts'
const PUBLIC_REGISTRY = 'https://registry.npmjs.org/'
/** Names of icon files the generator writes, which it may also remove. */
const HELP = `Usage: plugin-market-generator --input <list> --output <index.json> --title <text> [options]
/** Parsed command-line options. */
interface Options {
readonly input: string
readonly output: string
readonly title: string
readonly description?: string
readonly cache?: string
readonly registry: string
readonly inlineIconBytes: number
readonly concurrency: number
readonly timeoutMs: number
}
Options:
--input <file> source list: one npm package name or https Git URL per line; lines starting with # are comments
--output <file> index file to write; large icons go to icons/ beside it, and stale generated icons there are removed
--title <text> market title
--description <text> market description
--cache <dir> cache directory (default: .plugin-market-cache beside the input list)
--registry <url> npm registry (default: ${PUBLIC_REGISTRY})
--inline-icon-bytes <n> largest icon embedded as a data URL, at most ${String(MAX_INLINE_ICON_BYTES)} (default: 16384)
--concurrency <n> sources read at once (default: 4)
--timeout-ms <n> deadline for one request or git command (default: 60000)
-h, --help show this help
Exit status: 0 when every source was read, 1 when some source fell back to its cached result or was omitted,
2 for invalid arguments or input.`
function integer(value: string | undefined, fallback: number, name: string, max?: number): number {
if (value === undefined) return fallback
/** A positive integer option value, at most `max` when one is given. */
function integer(max?: number): (value: string) => number {
return (value) => {
const parsed = Number(value)
if (!Number.isInteger(parsed) || parsed < 1 || (max !== undefined && parsed > max)) {
throw new Error(`--${name} must be a positive integer${max === undefined ? '' : ` up to ${String(max)}`}`)
throw new InvalidArgumentError(`must be a positive integer${max === undefined ? '' : ` up to ${String(max)}`}`)
}
return parsed
}
}
/**
* Run the generator.
* @param argv - command-line arguments after the program name.
* @returns the process exit status.
*/
export async function main(argv: readonly string[]): Promise<number> {
let values
try {
({ values } = parseArgs({
args: [...argv],
options: {
input: { type: 'string' }, output: { type: 'string' }, title: { type: 'string' }, description: { type: 'string' },
cache: { type: 'string' }, registry: { type: 'string' }, 'inline-icon-bytes': { type: 'string' },
concurrency: { type: 'string' }, 'timeout-ms': { type: 'string' }, help: { type: 'boolean', short: 'h' },
},
}))
} catch (error) {
console.error(`${(error as Error).message}\n\n${HELP}`)
return 2
function program(): Command {
return new Command('plugin-market-generator')
.description('Write a plugin market index from a list of npm packages and Git repositories.')
.requiredOption('--input <file>', 'source list: one npm package name or https Git URL per line; lines starting with # are comments')
.requiredOption('--output <file>', 'index file to write; large icons go to icons/ beside it, and stale generated icons there are removed')
.requiredOption('--title <text>', 'market title')
.option('--description <text>', 'market description')
.option('--cache <dir>', 'cache directory (default: .plugin-market-cache beside the input list)')
.option('--registry <url>', 'npm registry', PUBLIC_REGISTRY)
.addOption(new Option('--inline-icon-bytes <n>', `largest icon embedded as a data URL, at most ${String(MAX_INLINE_ICON_BYTES)}`)
.argParser(integer(MAX_INLINE_ICON_BYTES)).default(16384))
.addOption(new Option('--concurrency <n>', 'sources read at once').argParser(integer()).default(4))
.addOption(new Option('--timeout-ms <n>', 'deadline for one request or git command').argParser(integer()).default(60000))
.helpOption('-h, --help', 'show this help')
.addHelpText('after', '\nExit status: 0 when every source was read, 1 when some source fell back to its cached result or was omitted,\n2 for invalid arguments or input.')
.showHelpAfterError('(run with --help for usage)')
.exitOverride()
}
if (values.help === true) {
console.log(HELP)
return 0
}
try {
if (values.input === undefined || values.output === undefined || values.title === undefined) throw new Error('--input, --output, and --title are required')
async function run(options: Options): Promise<number> {
// `pnpm --filter … start` runs in the package directory; paths are relative to where the user ran pnpm.
const base = process.env.INIT_CWD ?? process.cwd()
const input = resolve(base, values.input)
const output = resolve(base, values.output)
const registry = new URL(values.registry ?? PUBLIC_REGISTRY).href.replace(/\/?$/u, '/')
const inlineIconBytes = integer(values['inline-icon-bytes'], 16384, 'inline-icon-bytes', MAX_INLINE_ICON_BYTES)
const timeoutMs = integer(values['timeout-ms'], 60000, 'timeout-ms')
const concurrency = integer(values.concurrency, 4, 'concurrency')
const input = resolve(base, options.input)
const output = resolve(base, options.output)
const registry = new URL(options.registry).href.replace(/\/?$/u, '/')
const result = await generate({
sources: parseSources(readFileSync(input, 'utf8')),
cacheDir: values.cache === undefined ? join(dirname(input), '.plugin-market-cache') : resolve(base, values.cache),
cacheDir: options.cache === undefined ? join(dirname(input), '.plugin-market-cache') : resolve(base, options.cache),
registry,
recordRegistry: registry !== PUBLIC_REGISTRY,
title: values.title,
...values.description === undefined ? {} : { description: values.description },
inlineIconBytes,
limits: { timeoutMs, maxTarballBytes: 64 * 1024 * 1024 },
concurrency,
title: options.title,
...options.description === undefined ? {} : { description: options.description },
inlineIconBytes: options.inlineIconBytes,
limits: { timeoutMs: options.timeoutMs, maxTarballBytes: 64 * 1024 * 1024 },
concurrency: options.concurrency,
warn: (message) => { console.error(`warning: ${message}`) },
})
for (const [path, cached] of result.icons) writeIfChanged(join(dirname(output), path), readFileSync(cached))
@@ -102,6 +90,24 @@ export async function main(argv: readonly string[]): Promise<number> {
const plugins = result.index.plugins.length
console.log(`${changed ? 'wrote' : 'unchanged'} ${output}: ${plugins} plugin(s), ${result.failures.length} failed source(s)`)
return result.failures.length === 0 ? 0 : 1
}
/**
* Run the generator.
* @param argv - command-line arguments after the program name.
* @returns the process exit status.
*/
export async function main(argv: readonly string[]): Promise<number> {
let options: Options
try {
options = program().parse([...argv], { from: 'user' }).opts<Options>()
} catch (error) {
// Commander has already printed the help or the parse error.
if (!(error instanceof CommanderError)) throw error
return error.code === 'commander.helpDisplayed' ? 0 : 2
}
try {
return await run(options)
} catch (error) {
console.error((error as Error).message)
return 2
+3 -2
View File
@@ -59,7 +59,7 @@ interface Release {
/** Base URL only used to validate relative icon references. */
const VALIDATION_URL = 'https://index.invalid/index.json'
/** Part of every cache key; change it when the way entries are derived changes, so cached results are rebuilt. */
const CACHE_VERSION = 6
const CACHE_VERSION = 7
/** Why the index format rejects these plugins, or undefined when it accepts them. */
function invalid(plugins: unknown): string | undefined {
@@ -74,11 +74,12 @@ function invalid(plugins: unknown): string | undefined {
/** Combine releases of one package into an entry whose display fields come from the highest version. */
function entryOf(name: string, releases: readonly Release[]): GeneratedEntry {
const sorted = [...releases].sort((left, right) => semver.rcompare(left.version.version, right.version.version))
const { title, description, icon, author, homepage, metadata } = sorted[0]?.display ?? {}
const { title, description, icon, author, homepage, repository, metadata } = sorted[0]?.display ?? {}
return {
name,
...title === undefined ? {} : { title }, ...description === undefined ? {} : { description },
...icon === undefined ? {} : { icon }, ...author === undefined ? {} : { author }, ...homepage === undefined ? {} : { homepage },
...repository === undefined ? {} : { repository },
...metadata === undefined ? {} : { metadata },
versions: sorted.map(release => release.version),
}
+50 -2
View File
@@ -5,6 +5,8 @@
import { createHash } from 'node:crypto'
import { posix } from 'node:path'
import type { LocalizedText } from '@deepseek-ai/dsh-package-manifest'
import hostedGitInfo from 'hosted-git-info'
import type { MarketRepository } from '@deepseek-ai/dsh-plugin-market-index'
import { exports as resolveExports, type Package } from 'resolve.exports'
import { PackageFiles } from './files.ts'
import type { Manifest } from './packages.ts'
@@ -20,6 +22,8 @@ export interface Display {
readonly author?: string
/** The manifest `homepage`, copied as written. */
readonly homepage?: string
/** The web URL of the manifest `repository`, with its `directory`. */
readonly repository?: MarketRepository
/** Optional discovery metadata; keywords are copied from the manifest as written. */
readonly metadata?: { readonly keywords: readonly string[] }
}
@@ -44,6 +48,9 @@ const LANGUAGE_ID = /^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*$/u
const MAX_ICON_BYTES = 256 * 1024
const ICON_TYPES = new Map([['.svg', 'image/svg+xml'], ['.png', 'image/png'], ['.jpg', 'image/jpeg'], ['.jpeg', 'image/jpeg'], ['.webp', 'image/webp']])
/** One segment of a package directory, as the index format accepts it; keep it equal to the parser's rule. */
const PATH_SEGMENT = /^[A-Za-z0-9._@+-]+$/u
function text(value: unknown): string | undefined {
return typeof value === 'string' && value.trim() !== '' ? value : undefined
}
@@ -123,6 +130,43 @@ function localized(field: keyof Fields, locales: Map<string, Fields>, fallback:
return Object.fromEntries([['en', english], ...translated]) as LocalizedText
}
/**
* The web URL of a manifest `repository` string or object `url`: a shorthand or URL of a host npm knows becomes its
* browse URL without a committish, and any other http(s) URL, after a `git+` prefix, loses its credentials, query, fragment, and `.git`
* suffix.
*/
function repositoryUrl(declared: string): string | undefined {
// A committish such as `#semver:^1` names a revision, not the repository page.
const hosted = hostedGitInfo.fromUrl(declared.replace(/#.*$/u, ''))
if (hosted !== undefined) return hosted.browse()
const plain = declared.replace(/^git\+/u, '')
if (!URL.canParse(plain)) return undefined
const url = new URL(plain)
if (url.protocol !== 'https:' && url.protocol !== 'http:') return undefined
url.username = ''
url.password = ''
url.search = ''
url.hash = ''
url.pathname = url.pathname.replace(/\.git$/u, '')
return url.href
}
/** The repository a manifest declares, or why it is omitted; a directory the format cannot hold is dropped with a warning. */
function readRepository(value: unknown, warn: (message: string) => void): MarketRepository | undefined {
if (value === undefined) return undefined
const object = typeof value === 'object' && value !== null ? value as { url?: unknown; directory?: unknown } : undefined
const declared = text(object === undefined ? value : object.url)?.trim()
const url = declared === undefined ? undefined : repositoryUrl(declared)
if (url === undefined) {
warn(`package.json repository ${JSON.stringify(value)} names no http(s) repository page; it is omitted`)
return undefined
}
const directory = text(object?.directory)?.replace(/^\.\/|\/$/gu, '')
const valid = directory !== undefined && directory.split('/').every(segment => segment !== '.' && segment !== '..' && PATH_SEGMENT.test(segment))
if (object?.directory !== undefined && !valid) warn(`package.json repository.directory ${JSON.stringify(object.directory)} is not a relative directory; it is omitted`)
return { url, ...valid ? { directory } : {} }
}
/** The icon a package declares, or why it is not used: the manifest `icon` takes priority over the `./icon` export. */
async function readIcon(
files: PackageFiles, manifest: Manifest,
@@ -147,8 +191,10 @@ async function readIcon(
}
/**
* Read display metadata, author, homepage, and icon of a package. `homepage` is copied from the manifest as written; a
* value that is not an absolute http(s) URL is reported and omitted, and nothing else stands in for it.
* Read display metadata, author, homepage, repository, and icon of a package. `homepage` is copied from the manifest as
* written; a value that is not an absolute http(s) URL is reported and omitted, and nothing else stands in for it.
* `repository` becomes the repository's web URL and its directory; one that names no http(s) page is reported and
* omitted.
* Manifest `keywords` arrays containing only strings are copied unchanged into `metadata.keywords`; any other
* present value is reported and omitted without dropping the package.
* @param files - the package's files.
@@ -168,12 +214,14 @@ export async function readDisplayMetadata(
const homepage = text(manifest.homepage)
const linkable = homepage !== undefined && URL.canParse(homepage) && ['https:', 'http:'].includes(new URL(homepage).protocol)
if (homepage !== undefined && !linkable) options.warn(`package.json homepage ${JSON.stringify(homepage)} is not an http(s) URL; it is omitted`)
const repository = readRepository(manifest.repository, options.warn)
const keywords = Array.isArray(manifest.keywords) && manifest.keywords.every((keyword): keyword is string => typeof keyword === 'string')
? manifest.keywords : undefined
if (manifest.keywords !== undefined && keywords === undefined) options.warn('package.json keywords must be an array of strings; it is omitted')
const display = {
...title === undefined ? {} : { title }, ...description === undefined ? {} : { description }, ...author === undefined ? {} : { author },
...linkable ? { homepage } : {},
...repository === undefined ? {} : { repository },
...keywords === undefined ? {} : { metadata: { keywords } },
}
const icon = await readIcon(files, manifest)
@@ -12,6 +12,7 @@ export interface Manifest {
readonly icon?: unknown
readonly author?: unknown
readonly homepage?: unknown
readonly repository?: unknown
readonly keywords?: unknown
readonly deprecated?: unknown
readonly workspaces?: unknown
+41 -7
View File
@@ -1,15 +1,49 @@
/** Command-line validation of the generator's options. */
/** Command-line parsing and help of the generator. */
import { afterEach, expect, it, vi } from 'vitest'
import { main } from '../src/cli.ts'
afterEach(() => { vi.restoreAllMocks() })
/** Run the CLI and collect what it writes to stdout and stderr. */
async function cli(...argv: string[]) {
const out: string[] = []
const err: string[] = []
vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => { out.push(String(chunk)); return true })
vi.spyOn(process.stderr, 'write').mockImplementation((chunk) => { err.push(String(chunk)); return true })
const status = await main(argv)
return { status, out: out.join(''), err: err.join('') }
}
const REQUIRED = ['--input', '/nonexistent/list.txt', '--output', '/nonexistent/index.json', '--title', 'T']
it('prints help with every option, its default, and the exit statuses', async () => {
const { status, out } = await cli('--help')
expect(status).toBe(0)
for (const text of ['--input <file>', '--inline-icon-bytes <n>', 'at most 32768', '(default: 16384)', 'Exit status: 0 when every source was read']) {
expect(out).toContain(text)
}
})
it.each([
['32769', '--inline-icon-bytes must be a positive integer up to 32768'],
['0', '--inline-icon-bytes must be a positive integer up to 32768'],
])('refuses --inline-icon-bytes %s before reading the source list', async (value, message) => {
[['--inline-icon-bytes', '32769'], "option '--inline-icon-bytes <n>' argument '32769' is invalid. must be a positive integer up to 32768"],
[['--inline-icon-bytes', '0'], 'must be a positive integer up to 32768'],
[['--concurrency', '1.5'], "option '--concurrency <n>' argument '1.5' is invalid. must be a positive integer"],
[['--bogus'], "unknown option '--bogus'"],
])('refuses %j before reading the source list', async (extra, message) => {
const { status, err } = await cli(...REQUIRED, ...extra)
expect(status).toBe(2)
expect(err).toContain(message)
})
it('names a missing required option', async () => {
const { status, err } = await cli('--input', 'list.txt')
expect(status).toBe(2)
expect(err).toContain("required option '--output <file>' not specified")
expect(err).toContain('(run with --help for usage)')
})
it('reports a source list it cannot read', async () => {
const error = vi.spyOn(console, 'error').mockImplementation(() => {})
const status = await main(['--input', '/nonexistent/list.txt', '--output', '/nonexistent/index.json', '--title', 'T', '--inline-icon-bytes', value])
expect(status).toBe(2)
expect(error).toHaveBeenCalledWith(message)
expect(await main(REQUIRED)).toBe(2)
expect(String(error.mock.calls[0]?.[0])).toContain('ENOENT')
})
@@ -171,13 +171,44 @@ describe('readDisplayMetadata', () => {
})
})
it.each([
['github:jane/dsh-a', { url: 'https://github.com/jane/dsh-a' }],
['jane/dsh-a', { url: 'https://github.com/jane/dsh-a' }],
[' github:jane/dsh-a ', { url: 'https://github.com/jane/dsh-a' }],
['github:jane/dsh-a#semver:^1', { url: 'https://github.com/jane/dsh-a' }],
[{ type: 'git', url: 'git+ssh://[email protected]/jane/plugins.git', directory: 'packages/a' }, { url: 'https://github.com/jane/plugins', directory: 'packages/a' }],
[{ url: 'git+https://u:[email protected]/g/sub/r.git?x=1#main', directory: './packages/a/' }, { url: 'https://git.example/g/sub/r', directory: 'packages/a' }],
[{ url: 'http://git.intranet/o/r' }, { url: 'http://git.intranet/o/r' }],
])('converts the manifest repository %j to its web page', async (repository, expected) => {
expect(await readDisplay(tree({}), { name: 'dsh-a', repository })).toEqual({ display: { title: 'dsh-a', repository: expected }, warnings: [] })
})
it.each([
'git://git.example/o/r.git',
{ url: 'ssh://[email protected]/o/r.git' },
{ directory: 'packages/a' },
3,
])('warns and omits a repository %j without an http(s) page', async (repository) => {
expect(await readDisplay(tree({}), { name: 'dsh-a', repository })).toEqual({
display: { title: 'dsh-a' },
warnings: [`package.json repository ${JSON.stringify(repository)} names no http(s) repository page; it is omitted`],
})
})
it.each(['../a', 'a/./b', 'a b', 4])('keeps the repository but omits the directory %j', async (directory) => {
expect(await readDisplay(tree({}), { name: 'dsh-a', repository: { url: 'https://github.com/o/r', directory } })).toEqual({
display: { title: 'dsh-a', repository: { url: 'https://github.com/o/r' } },
warnings: [`package.json repository.directory ${JSON.stringify(directory)} is not a relative directory; it is omitted`],
})
})
it('copies an http(s) homepage as written and reports any other value without a substitute', async () => {
const homepage = 'https://github.com/jane/dsh-a#readme'
expect((await readDisplay(tree({}), { name: 'dsh-a', homepage })).display).toEqual({ title: 'dsh-a', homepage })
// `repository` is present to show that it does not replace a rejected homepage.
// A repository stays its own field and does not replace a rejected homepage.
const manifest = { name: 'dsh-a', homepage: 'javascript:alert(1)', repository: 'https://github.com/jane/dsh-a' }
const rejected = await readDisplay(tree({}), manifest)
expect(rejected.display).toEqual({ title: 'dsh-a' })
expect(rejected.display).toEqual({ title: 'dsh-a', repository: { url: 'https://github.com/jane/dsh-a' } })
expect(rejected.warnings).toEqual(['package.json homepage "javascript:alert(1)" is not an http(s) URL; it is omitted'])
expect((await readDisplay(tree({}), { name: 'dsh-a', homepage: 'docs/readme' })).display).toEqual({ title: 'dsh-a' })
})
@@ -106,7 +106,7 @@ it('lists every non-deprecated bundle version from the registry document and rea
time: '2026-06-01T00:00:00.000Z',
manifest: { ...BUNDLE, author: { name: 'Jane Doe', email: '[email protected]' } },
tarball: await pack({
'package.json': bundleManifest('dsh-a', '2.0.0-rc.1', { author: { name: 'Jane Doe' }, icon: 'icon.png', homepage: 'https://example.com/dsh-a', keywords: ['alpha', '', 'alpha'] }),
'package.json': bundleManifest('dsh-a', '2.0.0-rc.1', { author: { name: 'Jane Doe' }, icon: 'icon.png', homepage: 'https://example.com/dsh-a', repository: { type: 'git', url: 'git+https://github.com/jane/plugins.git', directory: 'packages/a' }, keywords: ['alpha', '', 'alpha'] }),
'locale/en.json': JSON.stringify({ meta: { title: 'Alpha' } }),
'locale/zh.json': JSON.stringify({ meta: { title: '甲' } }),
'locale/x_y.json': '{}',
@@ -136,6 +136,7 @@ it('lists every non-deprecated bundle version from the registry document and rea
expect(first.index.plugins).toEqual([
{
name: 'dsh-a', title: { en: 'Alpha', zh: '甲' }, icon: expect.stringMatching(/^icons\/[0-9a-f]{16}\.png$/u) as string, author: 'Jane Doe', homepage: 'https://example.com/dsh-a',
repository: { url: 'https://github.com/jane/plugins', directory: 'packages/a' },
metadata: { keywords: ['alpha', '', 'alpha'] },
versions: [
{ version: '2.0.0-rc.1', publishTimestamp: 1780272000, source: npm },
@@ -252,10 +253,6 @@ it('writes the index and icons once, keeps the cache beside the input, and remov
const other = join(scratch(), 'index.json')
expect(await main(['--input', list, '--output', other, '--title', 'T', '--description', 'D', '--registry', server.url, '--concurrency', '2', '--cache', join(lists, 'other-cache')])).toBe(1)
expect((JSON.parse(readFileSync(other, 'utf8')) as { description: string }).description).toBe('D')
expect(await main(['--input', list])).toBe(2)
expect(await main(['--unknown'])).toBe(2)
expect(await main(['--help'])).toBe(0)
expect(await main([...args, '--concurrency', '0'])).toBe(2)
writeFileSync(list, 'http://example.com/o/r\n')
expect(await main(['--input', list, '--output', other, '--title', 'T'])).toBe(2)
})
+22
View File
@@ -833,6 +833,12 @@ importers:
'@deepseek-ai/dsh-plugin-market-index':
specifier: workspace:*
version: link:../../packages/util/plugin-market-index
commander:
specifier: ^15.0.0
version: 15.0.0
hosted-git-info:
specifier: ^9.0.3
version: 9.0.3
resolve.exports:
specifier: ^2.0.3
version: 2.0.3
@@ -849,6 +855,9 @@ importers:
'@deepseek-ai/dsh-package-manifest':
specifier: workspace:*
version: link:../../packages/util/package-manifest
'@types/hosted-git-info':
specifier: ^3.0.5
version: 3.0.5
'@types/semver':
specifier: ^7.8.0
version: 7.8.0
@@ -18916,6 +18925,9 @@ packages:
'@types/[email protected]':
resolution: {integrity: sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==}
'@types/[email protected]':
resolution: {integrity: sha512-Dmngh7U003cOHPhKGyA7LWqrnvcTyILNgNPmNCxlx7j8MIi54iBliiT8XqVLIQ3GchoOjVAyBzNJVyuaJjqokg==}
'@types/[email protected]':
resolution: {integrity: sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==}
@@ -20861,6 +20873,10 @@ packages:
resolution: {integrity: sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==}
engines: {node: '>=10'}
[email protected]:
resolution: {integrity: sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg==}
engines: {node: ^20.17.0 || >=22.9.0}
[email protected]:
resolution: {integrity: sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==}
engines: {node: '>=12'}
@@ -26174,6 +26190,8 @@ snapshots:
dependencies:
'@types/unist': 3.0.3
'@types/[email protected]': {}
'@types/[email protected]': {}
'@types/[email protected]': {}
@@ -28491,6 +28509,10 @@ snapshots:
dependencies:
lru-cache: 6.0.0
[email protected]:
dependencies:
lru-cache: 11.5.1
[email protected]:
dependencies:
whatwg-encoding: 2.0.0