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:
1 parent
434dc116f0
commit
14abe9543c
13 files changed
+233
-86
No files matched your search
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
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
|
||||
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)}`}`)
|
||||
}
|
||||
return parsed
|
||||
/** 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
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
/** 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 InvalidArgumentError(`must be a positive integer${max === undefined ? '' : ` up to ${String(max)}`}`)
|
||||
}
|
||||
if (values.help === true) {
|
||||
console.log(HELP)
|
||||
return 0
|
||||
return parsed
|
||||
}
|
||||
try {
|
||||
if (values.input === undefined || values.output === undefined || values.title === undefined) throw new Error('--input, --output, and --title are required')
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
Generated
+22
@@ -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
|
||||
|
||||
Reference in new issue
Block a user