@noob-stupid/dsh-plugin-console 0.5.50 → 0.5.54
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/client.js +87 -6
- package/lib/server/domain/bundle-veto.js +195 -0
- package/lib/server/domain/compat.js +16 -3
- package/lib/server/domain/link-inventory.js +142 -0
- package/lib/server/domain/peer-veto.js +14 -0
- package/lib/server/domain/update-candidates.js +27 -1
- package/lib/server/infra/exec.js +22 -23
- package/lib/server/infra/pnpm-release-gate.js +10 -8
- package/lib/server/routes/compat.js +8 -0
- package/lib/server/routes/framework.js +2 -2
- package/lib/server/routes/index.js +3 -1
- package/lib/server/routes/state.js +56 -3
- package/package.json +2 -3
- package/scripts/apply-framework-patch.cjs +134 -0
- package/scripts/apply-npm-names.cjs +36 -0
- package/scripts/build-index.cjs +190 -0
- package/scripts/diag-blocks.mjs +60 -0
- package/scripts/fw-watchdog-task.ps1 +112 -0
- package/scripts/fw-watchdog.cmd +30 -0
- package/scripts/fw-watchdog.mjs +246 -0
package/lib/server/infra/exec.js
CHANGED
|
@@ -445,27 +445,23 @@ function execFileWithKillTree(bin, argv, opts = {}, deps = {}) {
|
|
|
445
445
|
* 仍然照需求注入这三个 env(CI 与非交互确实有用、registry 镜像照旧、fetch_* 是 npm 系标准写法,
|
|
446
446
|
* 对 npm/后续 pnpm 版本仍可能生效),但**不把健壮性只押在 env 上**。
|
|
447
447
|
*
|
|
448
|
-
* ★ 2026-10-14
|
|
449
|
-
*
|
|
450
|
-
*
|
|
451
|
-
*
|
|
452
|
-
*
|
|
453
|
-
*
|
|
454
|
-
*
|
|
455
|
-
*
|
|
456
|
-
*
|
|
457
|
-
*
|
|
458
|
-
*
|
|
459
|
-
*
|
|
460
|
-
*
|
|
461
|
-
*
|
|
462
|
-
*
|
|
463
|
-
*
|
|
464
|
-
*
|
|
465
|
-
* `if (process.stdin.isTTY && !process.env.CI)` ⇒ `stdin.resume()` + `once(stdin,'data')`)
|
|
466
|
-
* —— 它的判据恰恰是 `process.env.CI`,也就是 0.5.48 说"不是 pnpm 读的那一位"的那个变量。
|
|
467
|
-
* 所以"无 TTY 挂住"的真凶是 corepack(且需 isTTY 为真才挂),不是 pnpm 的供应链闸。
|
|
468
|
-
* 本段只更正文档,**未改动任何行为代码**(三个 env 与 argv 的注入原样保留)。 */
|
|
448
|
+
* ★ 2026-10-14(回校 0.5.48 此处的判断,**行为未动、只更正说明**。完整取证与五组实验见
|
|
449
|
+
* CHANGELOG「v0.5.51」那节;这里只留结论,因为本文件贴着架构守卫的 600 行硬顶):
|
|
450
|
+
* ① `npm_config_ci=true` 对 pnpm **自己的**配置项是**空操作**(只认 `pnpm_config_*` 前缀,
|
|
451
|
+
* 含 argv 的 `--config.*`)⇒ 这一行是安慰剂,别再当成非交互的保证。
|
|
452
|
+
* ② `CI=true` **确实**点亮 ci 位(pnpm 配置默认值 `ci: isCI`,`isCI` 判 `env.CI`)⇒
|
|
453
|
+
* `pnpm install` 面对过时 lock **会**被 frozen 拒绝(加 `--no-frozen-lockfile` 即恢复)。
|
|
454
|
+
* 这一位 **v0.5.30 就已注入**,不是 0.5.48 新增。
|
|
455
|
+
* ③ 「pnpm 弹提问 ⇒ 读 stdin 到 EOF ⇒ 永久挂住」**不成立**:`canPrompt` 需要
|
|
456
|
+
* `process.stdin.isTTY`,管道下恒为假 ⇒ pnpm 从不 prompt;五种 ci 配置实测**全部快速失败**。
|
|
457
|
+
* ④ 那"挂到 90s / 180s"**是我们自己的超时常量**(`runPnpmAdd` timeout=90000、
|
|
458
|
+
* `installTargetDependencies` timeoutMs=180000),与当时观察逐一吻合 ⇒ 是"超过我们的
|
|
459
|
+
* 超时被杀",不是"等输入"。**那次为什么慢至今没有证据**(最可能是网络)。
|
|
460
|
+
* ⑤ corepack 的下载提问也**默认是关的**(入口 shim `COREPACK_ENABLE_DOWNLOAD_PROMPT ??= '0'`),
|
|
461
|
+
* 只有显式设成 1 才会问 ⇒ 也不是默认真凶。
|
|
462
|
+
* ⇒ 0.5.48 那条「永久挂住」的**根因至今未查明**,能确定的只有"不是 pnpm 的交互提问"。
|
|
463
|
+
* `--config.ci=true` 仍保留独立价值:钉死 ci 位 + 让 install 在 lock 过时走 frozen 拒绝
|
|
464
|
+
* 而非静默改写;`pnpm add` 不受 frozen 影响(实测带/不带此旗结局相同)。 */
|
|
469
465
|
function pnpmEnvOverrides(registry) {
|
|
470
466
|
const overrides = {
|
|
471
467
|
npm_config_fetch_timeout: '60000',
|
|
@@ -511,8 +507,11 @@ function pnpmFetchArgs(timeoutMs = 60000, retries = 1) {
|
|
|
511
507
|
* ★ 2026-10-14(实测更正 0.5.48 的理由陈述,**行为未动**):0.5.48 写"命中供应链闸 ⇒ 弹提问 ⇒
|
|
512
508
|
* 管道永不关闭 ⇒ 永久挂住",这条因果链实测**不成立**:pnpm 的
|
|
513
509
|
* `canPrompt = !inCi && Boolean(process.stdin.isTTY)`,管道下 isTTY 恒假 ⇒ pnpm 从不 prompt,
|
|
514
|
-
* 五种 ci
|
|
515
|
-
*
|
|
510
|
+
* 五种 ci 配置实测全部快速失败(无一挂住)。
|
|
511
|
+
* ⚠️ 本节**不指定真凶**:那"挂到 90s/180s"是我们**自己的超时常量**(`runPnpmAdd` timeout=90000、
|
|
512
|
+
* `installTargetDependencies` timeoutMs=180000),与当时观察逐一吻合 ⇒ 是"超过我们的超时被杀";
|
|
513
|
+
* 而 corepack 的下载提问**默认是关的**(入口 shim `COREPACK_ENABLE_DOWNLOAD_PROMPT ??= '0'`)。
|
|
514
|
+
* **根因至今未查明**(最可能是网络)—— 别再把 corepack(或 pnpm)当成已证实的结论。
|
|
516
515
|
* 所以这一位的真实价值是:**把 ci 位钉死**(与 `CI=true` 同向、但不受 env 被覆盖影响),
|
|
517
516
|
* 并让 install 类命令在 lock 过时走 frozen 拒绝而非静默改写。`pnpm add` 不受 frozen 影响
|
|
518
517
|
* (实测:带/不带此旗,add 都照常装上并更新 lock)⇒ 不会把安装变成拒绝。
|
|
@@ -9,20 +9,22 @@
|
|
|
9
9
|
// 桌面壳/服务进程给的是**永不关闭的 stdin 管道** ⇒ 不抛错、不超时,**永久挂住**
|
|
10
10
|
// (实测两次分别挂到 90s / 180s 被杀)。
|
|
11
11
|
//
|
|
12
|
-
// ★ 2026-10-14
|
|
13
|
-
// 上述因果链里"pnpm 弹提问 ⇒ 读 stdin 到 EOF ⇒
|
|
12
|
+
// ★ 2026-10-14(回校上面这段"现场描述",**本模块行为未动**):
|
|
13
|
+
// 上述因果链里"pnpm 弹提问 ⇒ 读 stdin 到 EOF ⇒ 永久挂住"这一段**实测不成立**:
|
|
14
14
|
// · pnpm 的 canPrompt = `!inCi && Boolean(process.stdin.isTTY)`(L194277-278);
|
|
15
15
|
// 管道下 isTTY 恒假 ⇒ pnpm **从不**走 prompt 分支,直接 `throw failOnImmature`。
|
|
16
16
|
// · 实测(pnpm 11.7.0,minimumReleaseAge 设成天文数字 + 管道 stdin):无 ci 位 / CI=true /
|
|
17
17
|
// npm_config_ci=true / --config.ci=true / 两者齐上 —— **五种配置全部快速失败,无一挂住**。
|
|
18
|
-
// ·
|
|
19
|
-
// `
|
|
20
|
-
//
|
|
18
|
+
// · 那"挂到 90s / 180s"其实是**我们自己的超时常量**:`runPnpmAdd` 默认 timeout=90000、
|
|
19
|
+
// `link-self-sufficiency#installTargetDependencies` 默认 timeoutMs=180000 —— 与当时观察
|
|
20
|
+
// 逐一吻合 ⇒ 当时是"某次操作超过我们设的超时被我们杀掉",**不是**"等输入等到天荒地老"。
|
|
21
|
+
// 那次为什么慢**至今没有证据**(最可能是网络);corepack 的下载提问也**默认是关的**
|
|
22
|
+
// (入口 shim 第一行 `COREPACK_ENABLE_DOWNLOAD_PROMPT ??= '0'`)。
|
|
21
23
|
// ⇒ 本模块的定位不变(**识别 + 翻译成可操作错误**,绝不绕过闸门),仍然有价值;
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
+
// 只是「为什么会挂住」**至今未查明**,不要再照 0.5.48 那段因果链去找。
|
|
25
|
+
// 另两处一并更正(详见 infra/exec.js#pnpmEnvOverrides 上方的完整说明):
|
|
24
26
|
// · `npm_config_ci=true` 对 pnpm 自己的配置项是**空操作**(只认 `pnpm_config_*`);
|
|
25
|
-
// · `CI=true` **确实**点亮 ci 位(pnpm 配置默认值 `ci: isCI`,L145865
|
|
27
|
+
// · `CI=true` **确实**点亮 ci 位(pnpm 配置默认值 `ci: isCI`,L145865),且 v0.5.30 就有。
|
|
26
28
|
//
|
|
27
29
|
// 闸门实现(本机 pnpm 11.7.0 `dist/pnpm.mjs`,只读反编译取证):
|
|
28
30
|
// L194277 const inCi = opts3.ci ?? isCI
|
|
@@ -262,6 +262,14 @@ async function routeCompatStatusGet(req, res, rc) {
|
|
|
262
262
|
frameworkVersion: current.frameworkVersion,
|
|
263
263
|
fingerprint: { current, previous, changed, reasons, baselineAt },
|
|
264
264
|
rollback: readRollbackRecord(),
|
|
265
|
+
// ★ 0.5.53 加法:外壳托管时升级/回滚通道会被服务端拒绝(409),面板据此把回滚点标成"不可用"
|
|
266
|
+
// 并说明原因,而不是显示"可用"却点了必然失败。判据与 upgrade/rollback 路由共用 detectHostShape。
|
|
267
|
+
host: (() => {
|
|
268
|
+
try {
|
|
269
|
+
const shape = detectHostShape()
|
|
270
|
+
return { hosted: shape.hosted === true, kind: shape.kind, notice: shape.hosted === true ? shape.notice : null }
|
|
271
|
+
} catch { return { hosted: false, kind: 'standalone', notice: null } }
|
|
272
|
+
})(),
|
|
265
273
|
// D1 加法字段:自动预检的状态/结论(面板只放一句短句,长文进详情)
|
|
266
274
|
autoPreflight: { ...autoPreflight, scheduled: autoRun !== null, scheduledAt: autoRun === null ? null : Date.now(), timeoutMs: autoRun?.timeoutMs ?? preflightTimeoutMs() },
|
|
267
275
|
// D5 加法字段:自动禁用的开关状态 + **本次一次性提示**(客户端走既有瞬时 note 通道;
|
|
@@ -16,7 +16,7 @@ import { fetchJsonUrl } from '../infra/http.js'
|
|
|
16
16
|
import { sendError, sendJson } from '../infra/httpd.js'
|
|
17
17
|
import { dshHome, entryPkgMeta, findPatchPath, packageNameOf, pluginRoot, profileDirOf, resolvePackageJson } from '../infra/paths.js'
|
|
18
18
|
import { isFrameworkVersionNewer, semverRangeMatchLoose, frameworkUpgradeCandidates } from '../infra/semver.js'
|
|
19
|
-
import { dropStaleUpdateCandidates } from '../domain/update-candidates.js'
|
|
19
|
+
import { dropStaleUpdateCandidates, pickUpdateTarget } from '../domain/update-candidates.js'
|
|
20
20
|
import { fwCheckCache, setFwCheckCache } from '../state.js'
|
|
21
21
|
|
|
22
22
|
/**
|
|
@@ -337,7 +337,7 @@ async function routeCheckUpdate(req, res, rc) {
|
|
|
337
337
|
} catch {}
|
|
338
338
|
// 兜底(0.5.37):低于已装版本的候选不是"新版"(真机:镜像 latest=0.1.1-rc.1 < 已装 0.2.0-rc.2)
|
|
339
339
|
;({ latest, next, beta } = dropStaleUpdateCandidates({ latest, next, beta }, packageName, ctx.baseUrl ?? 'file:///', profileDirOf(ctx)))
|
|
340
|
-
sendJson(res, 200, { ok: true, packageName, latest, next, beta, depsOutdated, error, source, migrate })
|
|
340
|
+
sendJson(res, 200, { ok: true, packageName, latest, next, beta, target: pickUpdateTarget({ latest, next, beta }), depsOutdated, error, source, migrate })
|
|
341
341
|
return
|
|
342
342
|
}
|
|
343
343
|
|
|
@@ -30,12 +30,14 @@ import { routeEnrich, routeMarketIndex, routeRepo, routeSearch, routeSubpackages
|
|
|
30
30
|
import { routeAdaptUnlock, routeAdaptUnlockAll, routeCleanResiduals, routeSelfUpdate, routeToggle, routeUninstall } from './plugins.js'
|
|
31
31
|
import { routeSkillRemove, routeSkillToggle, routeSkillsInstalledGet } from './skills.js'
|
|
32
32
|
import { routeGiteeOauthCallbackGet, routeGiteeOauthUrlGet, routeRegistryScan, routeSources, routeSourcesGet } from './sources.js'
|
|
33
|
-
import { routeDetails, routeStateGet } from './state.js'
|
|
33
|
+
import { routeDetails, routeLinksGet, routeStateGet } from './state.js'
|
|
34
34
|
|
|
35
35
|
const ROUTE_PREFIX = '/plugin-console'
|
|
36
36
|
|
|
37
37
|
const ROUTES_EARLY = [
|
|
38
38
|
{ methods: ['GET'], path: `${ROUTE_PREFIX}/state`, handler: routeStateGet },
|
|
39
|
+
// link 开发式安装清单(0.5.54 加法):纯只读,供面板渲染"已挂载 / 自足性 / 垫片 / 持久化"
|
|
40
|
+
{ methods: ['GET'], path: `${ROUTE_PREFIX}/links`, handler: routeLinksGet },
|
|
39
41
|
{ methods: ['GET'], path: `${ROUTE_PREFIX}/sources`, handler: routeSourcesGet },
|
|
40
42
|
{ methods: ['GET'], path: `${ROUTE_PREFIX}/gitee-oauth-url`, handler: routeGiteeOauthUrlGet },
|
|
41
43
|
{ methods: ['GET'], path: `${ROUTE_PREFIX}/gitee-oauth-callback`, handler: routeGiteeOauthCallbackGet },
|
|
@@ -5,9 +5,12 @@ import { readFileSync, existsSync } from 'node:fs'
|
|
|
5
5
|
import { dirname, join } from 'node:path'
|
|
6
6
|
import { detectAdoptablePending, detectCompat, gatingSummary, readCompatGate, readCompatPendingWithVerdicts } from '../domain/compat.js'
|
|
7
7
|
import { compUiUrl, findComponents } from '../domain/components.js'
|
|
8
|
-
import { currentFrameworkVersion, detectFrameworkUpgrade } from '../domain/framework.js'
|
|
8
|
+
import { currentFrameworkVersion, detectFrameworkUpgrade, detectHostShape } from '../domain/framework.js'
|
|
9
9
|
import { peerVetoFor } from '../domain/peer-veto.js'
|
|
10
10
|
import { readExtraBundleRows } from '../domain/install-job.js'
|
|
11
|
+
import { bundleVetoList } from '../domain/bundle-veto.js'
|
|
12
|
+
import { buildLinkInventory } from '../domain/link-inventory.js'
|
|
13
|
+
import { readBundlesSync } from '../domain/persist.js'
|
|
11
14
|
import { failedJobEvidence } from '../domain/install-verify.js'
|
|
12
15
|
import { installJobView, readGithubAuth } from '../domain/install.js'
|
|
13
16
|
import { readPluginDetails } from '../domain/market.js'
|
|
@@ -128,7 +131,33 @@ async function routeStateGet(req, res, rc) {
|
|
|
128
131
|
const selfPkg = JSON.parse(readFileSync(join(pluginRoot(), 'package.json'), 'utf8'))
|
|
129
132
|
selfVersion = typeof selfPkg.version === 'string' ? selfPkg.version : null
|
|
130
133
|
} catch {}
|
|
131
|
-
|
|
134
|
+
// ★ 0.5.53 加法(真机 2026-10-15):**被框架整层跳过的 bundle**。
|
|
135
|
+
// 症状:插件装了、在 dsh.profile.bundles 里,但面板上连"未加载"都不显示(用户原话:
|
|
136
|
+
// "为啥有的插件我本身是有的,但是没显示呢")。原因是框架在**装载 bundle 时**就因 peer 不满足
|
|
137
|
+
// throw,整个 bundle 层进 skippedBundles,它要插的行根本不存在 —— 而面板 enumerate 的是 loader 行。
|
|
138
|
+
// 这里复算同一判据(纯只读),把它们如实列出来;绝不改动任何东西。
|
|
139
|
+
let skippedBundles = { items: [], reliable: true, note: null }
|
|
140
|
+
try {
|
|
141
|
+
skippedBundles = bundleVetoList({
|
|
142
|
+
bundleNames: readBundlesSync(profileDir),
|
|
143
|
+
profileDir,
|
|
144
|
+
baseUrl: ctx.baseUrl ?? 'file:///',
|
|
145
|
+
frameworkVersion: vetoFrameworkVersion,
|
|
146
|
+
})
|
|
147
|
+
} catch {}
|
|
148
|
+
// ★ 0.5.53 加法(真机 2026-10-15):把「本实例是否由桌面端外壳托管」告诉面板。
|
|
149
|
+
// 为什么必须下发:一键升级 / 一键回滚这两条通道在**外壳托管时会被服务端拒绝**
|
|
150
|
+
// (409 + 「本实例由桌面端外壳托管,请在桌面端内重启/更新」)—— 那是一份历史事故的修补:
|
|
151
|
+
// 回滚脚本曾按端口 Stop-Process 杀掉 Electron 承载的 host,重新拉起时又找不到 bin.js,
|
|
152
|
+
// 于是外壳记了一次 crash。拒绝本身是对的,但面板**过去照旧显示一个可点的「回滚到上一版」**,
|
|
153
|
+
// 点下去必然 409 —— 典型的"按钮存在但永远失败"。这里把同一份事实(detectHostShape)
|
|
154
|
+
// 下发给面板,让按钮按事实禁用并说明原因,而不是让人撞墙。
|
|
155
|
+
let host = { hosted: false, kind: 'standalone', notice: null }
|
|
156
|
+
try {
|
|
157
|
+
const shape = detectHostShape()
|
|
158
|
+
host = { hosted: shape.hosted === true, kind: shape.kind, notice: shape.hosted === true ? shape.notice : null }
|
|
159
|
+
} catch {}
|
|
160
|
+
sendJson(res, 200, { ok: true, entries, skippedBundles, host, patchPath, compat, installJobs: jobs, recentFailures, pendingRestart, github: { loggedIn: auth.loggedIn, login: auth.login }, patch: { disables: patch.disables, forced: patch.forced, inserts: patch.inserts }, framework, patchHeal: patchHeal === null ? null : { healed: patchHeal.healed ?? [], autoDisabled: patchHeal.autoDisabled ?? [], healedAt: patchHeal.healedAt ?? 0, written: patchHeal.written === true, writeError: patchHeal.writeError ?? null, skippedFrameworkOwned: (patchHeal.skipped ?? []).map((s) => s.id ?? null), uncertain: (patchHeal.uncertain ?? []).map((u) => u.id ?? null) }, gating: gatingSummary(ctx, compatPending, entries, compatVerdicts), compatPending: compatPending === null ? null : { frameworkVersion: compatPending.frameworkVersion ?? null, upgradeFrom: compatPending.upgradeFrom ?? null, pending: (compatPending.pending ?? []).filter((p) => (p.status ?? 'pending') === 'pending').map((p) => ({ rowId: p.rowId, moduleName: p.moduleName, version: p.version ?? null, checkNote: p.checkNote ?? null, check: p.check ?? null, riskyApprovedAt: p.riskyApprovedAt ?? null, adoptable: adoptable.get(p.rowId) ?? null, verdict: compatVerdicts.get(p.rowId) ?? null })), verdictRefresh: pendingState.refresh === null ? null : { version: pendingState.refresh.version ?? null, updated: pendingState.refresh.updated ?? [], unchanged: pendingState.refresh.unchanged ?? [], unresolved: pendingState.refresh.unresolved ?? [], wrote: pendingState.refresh.wrote === true, writeError: pendingState.refresh.writeError ?? null } }, compatGate, rollback: rollbackRec, selfVersion, components: findComponents().map((c) => ({ id: c.id, name: c.name, kind: c.kind ?? 'server', pid: c.pid ?? null, port: c.port ?? null, healthUrl: c.healthUrl ?? null, uiUrl: compUiUrl(c), autoStart: c.autoStart === true })) })
|
|
132
161
|
return
|
|
133
162
|
}
|
|
134
163
|
|
|
@@ -159,4 +188,28 @@ async function routeDetails(req, res, rc) {
|
|
|
159
188
|
return
|
|
160
189
|
}
|
|
161
190
|
|
|
162
|
-
|
|
191
|
+
/**
|
|
192
|
+
* GET /links —— **link 开发式安装清单**(0.5.54 加法,纯只读)。
|
|
193
|
+
*
|
|
194
|
+
* 用户 2026-10-15 的原话:「这主要是对标他们本地开发插件 link 挂载钉住」。
|
|
195
|
+
* 过去面板上 link 钉住的插件与普通插件长得一模一样:看不出指向哪、目标还在不在、
|
|
196
|
+
* 依赖/peer 齐不齐、改完代码加载的是哪一份。本路由把它们聚合成一份清单,让"看见"先成立。
|
|
197
|
+
*
|
|
198
|
+
* 判据全部复用既有唯一真源(domain/link-inventory.js → planLinkSelfSufficiency / persistReport /
|
|
199
|
+
* listSelfSufficiencyRecords),**不新增任何写盘、不 spawn、不联网**。
|
|
200
|
+
*/
|
|
201
|
+
async function routeLinksGet(req, res, rc) {
|
|
202
|
+
const ctx = rc.ctx
|
|
203
|
+
let payload = { ok: true, items: [], count: 0, readyCount: 0, error: null }
|
|
204
|
+
try {
|
|
205
|
+
const patchPath = findPatchPath(ctx)
|
|
206
|
+
const profileDir = dirname(patchPath)
|
|
207
|
+
payload = { ok: true, ...buildLinkInventory({ profileDir, patchPath, entries: listEntries(ctx) }), error: null }
|
|
208
|
+
} catch (error) {
|
|
209
|
+
// 体检失败要如实说,不能假装"没有 link 依赖"
|
|
210
|
+
payload = { ok: false, items: [], count: 0, readyCount: 0, error: error instanceof Error ? error.message : String(error) }
|
|
211
|
+
}
|
|
212
|
+
sendJson(res, 200, payload)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export { routeStateGet, routeDetails, routeLinksGet }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@noob-stupid/dsh-plugin-console",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.54",
|
|
4
4
|
"description": "DSH 框架升级安全与插件升级门控:一键升级、失败自动回滚、升级后回滚上版、旧插件不适配自动禁用;内置多源插件市场为发现层,插件源全部可自定义,可指向公司内网私有源 / 私有索引 / 本地 Git 仓库,纯内网离线可用 | Framework upgrade safety & plugin version gating for DSH, with a customizable multi-source plugin market: point every source at internal mirrors or a local file:// repo for intranet-only, offline installs.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -51,8 +51,7 @@
|
|
|
51
51
|
"cordis.patch.yml",
|
|
52
52
|
"SECURITY.md",
|
|
53
53
|
"icon.svg",
|
|
54
|
-
"scripts
|
|
55
|
-
"scripts/fw-preflight.mjs"
|
|
54
|
+
"scripts"
|
|
56
55
|
],
|
|
57
56
|
"license": "MIT",
|
|
58
57
|
"scripts": {
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 框架层补丁:dsh-app-boot 的 cordis.patch.yml 解析容错(议题 #5)。
|
|
3
|
+
*
|
|
4
|
+
* 问题:cordis.patch.yml 若含顶格 `[]` 占位符 + 后续条目(两个 YAML 根节点),
|
|
5
|
+
* DSH 启动时 parsePatchList 解析崩溃("end of the stream or a document separator is expected")。
|
|
6
|
+
*
|
|
7
|
+
* 修复:parsePatchList 解析失败时,自动移除顶格空数组占位行(视为 no-op)后重试。
|
|
8
|
+
*
|
|
9
|
+
* 用法:node scripts/apply-framework-patch.cjs
|
|
10
|
+
* 说明:DSH 升级(npx 重新拉取)后框架文件会被覆盖,重新运行本脚本即可。
|
|
11
|
+
*/
|
|
12
|
+
const fs = require('node:fs')
|
|
13
|
+
const path = require('node:path')
|
|
14
|
+
const os = require('node:os')
|
|
15
|
+
|
|
16
|
+
/** npm 缓存下的 npx 缓存目录候选(**不写死盘符/用户名**)。
|
|
17
|
+
* npm 的 cache 位置是用户配置(把 cache 指到别的盘很常见)→ 按 npm 自己的来源读:
|
|
18
|
+
* 环境变量 npm_config_cache / NPM_CONFIG_CACHE + 用户级 ~/.npmrc 的 `cache=`。
|
|
19
|
+
* (原实现第一条写死开发机上的缓存绝对路径:某个盘符下的 node_cache\_npx。) */
|
|
20
|
+
function npmNpxCacheRoots() {
|
|
21
|
+
const roots = []
|
|
22
|
+
const push = (value) => {
|
|
23
|
+
if (typeof value !== 'string') return
|
|
24
|
+
const dir = value.trim().replace(/^["']|["']$/g, '').replace(/^~(?=[\\/]|$)/, os.homedir())
|
|
25
|
+
if (dir !== '') roots.push(path.join(dir, '_npx'))
|
|
26
|
+
}
|
|
27
|
+
for (const key of ['npm_config_cache', 'NPM_CONFIG_CACHE']) push(process.env[key])
|
|
28
|
+
try {
|
|
29
|
+
for (const line of fs.readFileSync(path.join(os.homedir(), '.npmrc'), 'utf8').split(/\r?\n/)) {
|
|
30
|
+
const hit = /^\s*cache\s*=\s*(.*?)\s*$/i.exec(line)
|
|
31
|
+
if (hit) push(hit[1])
|
|
32
|
+
}
|
|
33
|
+
} catch {}
|
|
34
|
+
return roots
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// 定位 @deepseek-ai/dsh-app-boot(与 @deepseek-ai/dsh 同级)
|
|
38
|
+
function locateAppBoot() {
|
|
39
|
+
try {
|
|
40
|
+
const dshPkg = require.resolve('@deepseek-ai/dsh/package.json')
|
|
41
|
+
const aiDir = path.dirname(dshPkg) // .../node_modules/@deepseek-ai
|
|
42
|
+
const candidate = path.join(aiDir, 'dsh-app-boot', 'lib', 'index.js')
|
|
43
|
+
if (fs.existsSync(candidate)) return candidate
|
|
44
|
+
} catch {}
|
|
45
|
+
const cacheRoots = [
|
|
46
|
+
process.env.NODE_CACHE || '',
|
|
47
|
+
...npmNpxCacheRoots(),
|
|
48
|
+
path.join(os.homedir(), '.npm', '_npx'),
|
|
49
|
+
process.env.LOCALAPPDATA ? path.join(process.env.LOCALAPPDATA, 'node_cache', '_npx') : '',
|
|
50
|
+
].filter(Boolean)
|
|
51
|
+
for (const root of cacheRoots) {
|
|
52
|
+
if (!fs.existsSync(root)) continue
|
|
53
|
+
let entries = []
|
|
54
|
+
try { entries = fs.readdirSync(root, { withFileTypes: true }) } catch { continue }
|
|
55
|
+
for (const entry of entries) {
|
|
56
|
+
if (!entry.isDirectory()) continue
|
|
57
|
+
const candidate = path.join(root, entry.name, 'node_modules', '@deepseek-ai', 'dsh-app-boot', 'lib', 'index.js')
|
|
58
|
+
if (fs.existsSync(candidate)) return candidate
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return null
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const MARKER = 'tryDropEmptyArrayPlaceholder'
|
|
65
|
+
const OLD = `function parsePatchList(binName, file, content, label) {
|
|
66
|
+
let parsed;
|
|
67
|
+
try {
|
|
68
|
+
parsed = yaml.load(content, { schema: userPatchesSchema });
|
|
69
|
+
} catch (error) {
|
|
70
|
+
throw new Error(\`${'${binName}'}: failed to parse ${'${label}'} ${'${file}'}: ${'${String(error)}'}\`);
|
|
71
|
+
}`
|
|
72
|
+
const NEW = `function parsePatchList(binName, file, content, label) {
|
|
73
|
+
let parsed;
|
|
74
|
+
try {
|
|
75
|
+
parsed = yaml.load(content, { schema: userPatchesSchema });
|
|
76
|
+
} catch (error) {
|
|
77
|
+
// 容错(issue #5):文件可能是「[] 空数组占位符 + 后续条目」两个根节点,
|
|
78
|
+
// YAML 解析必然失败。移除顶层空数组占位行(视为 no-op)后重试。
|
|
79
|
+
const retried = tryDropEmptyArrayPlaceholder(content);
|
|
80
|
+
if (retried !== null) {
|
|
81
|
+
try {
|
|
82
|
+
parsed = yaml.load(retried, { schema: userPatchesSchema });
|
|
83
|
+
} catch {
|
|
84
|
+
throw new Error(\`${'${binName}'}: failed to parse ${'${label}'} ${'${file}'}: ${'${String(error)}'}\`);
|
|
85
|
+
}
|
|
86
|
+
} else {
|
|
87
|
+
throw new Error(\`${'${binName}'}: failed to parse ${'${label}'} ${'${file}'}: ${'${String(error)}'}\`);
|
|
88
|
+
}
|
|
89
|
+
}`
|
|
90
|
+
|
|
91
|
+
const HELPER = `
|
|
92
|
+
/**
|
|
93
|
+
* 容错辅助(issue #5):若文件含顶格空数组占位行(\`[]\` / \`[ ]\`,可带行尾注释),
|
|
94
|
+
* 视为 no-op 全部移除;无此模式返回 null。
|
|
95
|
+
*/
|
|
96
|
+
function tryDropEmptyArrayPlaceholder(content) {
|
|
97
|
+
const lines = String(content).split("\\n");
|
|
98
|
+
const kept = [];
|
|
99
|
+
let dropped = false;
|
|
100
|
+
for (const line of lines) {
|
|
101
|
+
if (/^\\[\\s*\\]\\s*(?:#.*)?$/u.test(line)) {
|
|
102
|
+
dropped = true;
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
kept.push(line);
|
|
106
|
+
}
|
|
107
|
+
if (!dropped) return null;
|
|
108
|
+
return kept.join("\\n");
|
|
109
|
+
}
|
|
110
|
+
`
|
|
111
|
+
|
|
112
|
+
function main() {
|
|
113
|
+
const target = locateAppBoot()
|
|
114
|
+
if (!target) {
|
|
115
|
+
console.error('未找到 @deepseek-ai/dsh-app-boot/lib/index.js,请确认 DSH 已安装(或手动指定路径)')
|
|
116
|
+
process.exit(1)
|
|
117
|
+
}
|
|
118
|
+
const source = fs.readFileSync(target, 'utf8')
|
|
119
|
+
if (source.includes(MARKER)) {
|
|
120
|
+
console.log('已打过补丁,跳过:' + target)
|
|
121
|
+
return
|
|
122
|
+
}
|
|
123
|
+
if (!source.includes('function parsePatchList')) {
|
|
124
|
+
console.error('未找到 parsePatchList(框架版本可能已变更),跳过。' + target)
|
|
125
|
+
process.exit(1)
|
|
126
|
+
}
|
|
127
|
+
fs.copyFileSync(target, target + '.bak-issue5')
|
|
128
|
+
const next = source.replace(OLD, NEW) + HELPER
|
|
129
|
+
fs.writeFileSync(target, next, 'utf8')
|
|
130
|
+
console.log('补丁已应用:' + target)
|
|
131
|
+
console.log('备份:' + target + '.bak-issue5')
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
main()
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 给已提交的 marketplace/index.json 就地补 npmName(批次 C-⑪)。
|
|
3
|
+
*
|
|
4
|
+
* 为什么单独有这个脚本:build-index.cjs 需要 gh CLI + 网络(本机 github 不可达时跑不了),
|
|
5
|
+
* 而"补 npmName"这件事**完全离线** —— 映射来自 marketplace/npm-name-hints.json(人工核对)。
|
|
6
|
+
* 用法:
|
|
7
|
+
* node scripts/apply-npm-names.cjs # 就地更新 marketplace/index.json
|
|
8
|
+
* node scripts/apply-npm-names.cjs --dry-run # 只报告会改哪几条
|
|
9
|
+
* node scripts/apply-npm-names.cjs --file=<index.json> # 指定索引文件
|
|
10
|
+
* 幂等:已经带 npmName 的条目内容不变;映射里没有的仓库保持原样(老索引兼容)。
|
|
11
|
+
*/
|
|
12
|
+
const fs = require('node:fs')
|
|
13
|
+
const path = require('node:path')
|
|
14
|
+
const { readNpmNameHints, withNpmNames } = require('./build-index.cjs')
|
|
15
|
+
|
|
16
|
+
const args = process.argv.slice(2)
|
|
17
|
+
const dryRun = args.includes('--dry-run')
|
|
18
|
+
const fileArg = args.find((a) => a.startsWith('--file='))
|
|
19
|
+
const hintsArg = args.find((a) => a.startsWith('--hints='))
|
|
20
|
+
const indexFile = fileArg ? fileArg.split('=')[1] : path.join(__dirname, '..', 'marketplace', 'index.json')
|
|
21
|
+
const hints = readNpmNameHints(hintsArg ? hintsArg.split('=')[1] : undefined)
|
|
22
|
+
|
|
23
|
+
const raw = JSON.parse(fs.readFileSync(indexFile, 'utf8'))
|
|
24
|
+
const before = Array.isArray(raw.items) ? raw.items : []
|
|
25
|
+
const after = withNpmNames(before, hints)
|
|
26
|
+
const changed = after.filter((it, i) => typeof it.npmName === 'string' && before[i]?.npmName !== it.npmName)
|
|
27
|
+
|
|
28
|
+
console.log(`映射条目 ${Object.keys(hints).length} 条;索引 ${before.length} 条命中 ${changed.length} 条`)
|
|
29
|
+
for (const it of changed) console.log(` ${it.fullName} → ${it.npmName}`)
|
|
30
|
+
|
|
31
|
+
if (dryRun) {
|
|
32
|
+
console.log('(dry-run:未写入)')
|
|
33
|
+
process.exit(0)
|
|
34
|
+
}
|
|
35
|
+
fs.writeFileSync(indexFile, JSON.stringify({ ...raw, items: after }, null, 2) + '\n', 'utf8')
|
|
36
|
+
console.log(`已写入 ${indexFile}`)
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 构建静态插件/技能索引:嗅探 GitHub topic 仓库 → marketplace/index.json。
|
|
3
|
+
*
|
|
4
|
+
* 插件模式(默认):topic:dsh-plugin,按 star 排序,限制 --limit(默认 500)
|
|
5
|
+
* 技能模式:--skills,合并 topic:agent-skills + topic:claude-skills + topic:dsh-skill,
|
|
6
|
+
* 写入 index.json 的 skills 段(保留已有 items 段),限制 --limit(默认 300)
|
|
7
|
+
*
|
|
8
|
+
* 数据拉取(本机沙箱禁止 node spawn gh 时的推荐方式):
|
|
9
|
+
* gh api --paginate "search/repositories?q=topic:dsh-plugin&sort=stars&order=desc&per_page=100" > raw.json
|
|
10
|
+
* node scripts/build-index.cjs --input raw.json [--limit 500]
|
|
11
|
+
* 无 --input 时尝试直接用 gh CLI 拉取(GitHub Actions 中预装 gh 且已认证)。
|
|
12
|
+
*
|
|
13
|
+
* 产物:marketplace/index.json(提交到仓库,经 jsDelivr CDN 分发,终端零 GitHub API 调用)
|
|
14
|
+
*/
|
|
15
|
+
const { execFile } = require('node:child_process')
|
|
16
|
+
const { promisify } = require('node:util')
|
|
17
|
+
const fs = require('node:fs')
|
|
18
|
+
const path = require('node:path')
|
|
19
|
+
|
|
20
|
+
const execFileAsync = promisify(execFile)
|
|
21
|
+
|
|
22
|
+
const SKILL_TOPICS = ['agent-skills', 'claude-skills', 'dsh-skill']
|
|
23
|
+
|
|
24
|
+
// gh api search 单对象响应,--paginate 拼接后不是合法 JSON,改手动分页循环
|
|
25
|
+
async function ghSearchRepos(query, limit) {
|
|
26
|
+
const all = []
|
|
27
|
+
let page = 1
|
|
28
|
+
const perPage = Math.min(100, limit)
|
|
29
|
+
while (all.length < limit) {
|
|
30
|
+
const { stdout } = await execFileAsync('gh', [
|
|
31
|
+
'api',
|
|
32
|
+
'search/repositories',
|
|
33
|
+
'--method', 'GET',
|
|
34
|
+
'-f', 'q=' + query,
|
|
35
|
+
'-f', 'sort=stars',
|
|
36
|
+
'-f', 'order=desc',
|
|
37
|
+
'-f', `per_page=${perPage}`,
|
|
38
|
+
'-f', `page=${page}`,
|
|
39
|
+
'--jq', '.items',
|
|
40
|
+
], {
|
|
41
|
+
timeout: 120000,
|
|
42
|
+
windowsHide: true,
|
|
43
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
44
|
+
})
|
|
45
|
+
const batch = JSON.parse(stdout)
|
|
46
|
+
if (!Array.isArray(batch) || batch.length === 0) break
|
|
47
|
+
all.push(...batch)
|
|
48
|
+
page += 1
|
|
49
|
+
}
|
|
50
|
+
return all
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function normalizeRepo(item) {
|
|
54
|
+
const fullName = String(item.full_name ?? '').trim()
|
|
55
|
+
if (!fullName) return null
|
|
56
|
+
return {
|
|
57
|
+
fullName,
|
|
58
|
+
description: item.description ?? '',
|
|
59
|
+
htmlUrl: item.html_url ?? `https://github.com/${fullName}`,
|
|
60
|
+
stars: item.stargazers_count ?? 0,
|
|
61
|
+
updatedAt: item.updated_at ?? '',
|
|
62
|
+
defaultBranch: item.default_branch ?? 'main',
|
|
63
|
+
topics: Array.isArray(item.topics) ? item.topics : [],
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function readExisting(file) {
|
|
68
|
+
try {
|
|
69
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'))
|
|
70
|
+
} catch {
|
|
71
|
+
return {}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* 仓库 → npm 包名(批次 C-⑪):市场索引的 npmName 字段。
|
|
77
|
+
* 为什么需要:有些仓库的根包没有发布到 npm(private),真正该装的产物是某个子包/聚合包
|
|
78
|
+
* (真机:zhu1090093659/dsh-web → @linxin666/dsh-web-all,5.97 MB,而仓库本体 429 MB、
|
|
79
|
+
* git 协议 0 B/s)。把这个映射写进索引后,hub 侧可以**直接按包名走 registry 安装**,
|
|
80
|
+
* 省掉"先探测根 package.json、再展开子包"的一整轮(本机 github 不可达时那条路根本走不通)。
|
|
81
|
+
* 只收录**能确定**的(人工核对过的映射文件);不能确定就省略 —— 老索引没有这个字段,
|
|
82
|
+
* hub 侧行为与改动前完全一致。
|
|
83
|
+
*/
|
|
84
|
+
function readNpmNameHints(explicitFile) {
|
|
85
|
+
const file = explicitFile ?? path.join(__dirname, '..', 'marketplace', 'npm-name-hints.json')
|
|
86
|
+
try {
|
|
87
|
+
const raw = JSON.parse(fs.readFileSync(file, 'utf8'))
|
|
88
|
+
const hints = raw && typeof raw.hints === 'object' && raw.hints !== null ? raw.hints : {}
|
|
89
|
+
const out = {}
|
|
90
|
+
for (const [repo, npmName] of Object.entries(hints)) {
|
|
91
|
+
if (typeof repo !== 'string' || repo.trim() === '') continue
|
|
92
|
+
if (typeof npmName !== 'string' || !/^(?:@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/u.test(npmName.trim())) continue
|
|
93
|
+
out[repo.trim().toLowerCase()] = npmName.trim()
|
|
94
|
+
}
|
|
95
|
+
return out
|
|
96
|
+
} catch {
|
|
97
|
+
return {}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** 给归一化后的条目补 npmName(能确定时)。纯函数,便于单测。 */
|
|
102
|
+
function withNpmNames(items, hints) {
|
|
103
|
+
return items.map((item) => {
|
|
104
|
+
const hit = hints[String(item.fullName).toLowerCase()]
|
|
105
|
+
return hit === undefined ? item : { ...item, npmName: hit }
|
|
106
|
+
})
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function main() {
|
|
110
|
+
const skillsMode = process.argv.includes('--skills')
|
|
111
|
+
const limitArg = process.argv.find((a) => a.startsWith('--limit='))
|
|
112
|
+
const limit = limitArg ? Number(limitArg.split('=')[1]) || (skillsMode ? 300 : 500) : (skillsMode ? 300 : 500)
|
|
113
|
+
const inputArg = process.argv.find((a) => a.startsWith('--input='))
|
|
114
|
+
const inputFile = inputArg ? inputArg.split('=')[1] : null
|
|
115
|
+
|
|
116
|
+
const dir = path.join(__dirname, '..', 'marketplace')
|
|
117
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
118
|
+
const file = path.join(dir, 'index.json')
|
|
119
|
+
|
|
120
|
+
let items = []
|
|
121
|
+
if (skillsMode) {
|
|
122
|
+
console.log(`拉取技能 topic 仓库(${SKILL_TOPICS.join(' + ')},gh CLI,自动分页)…`)
|
|
123
|
+
try {
|
|
124
|
+
const raw = inputFile ? JSON.parse(fs.readFileSync(inputFile, 'utf8')) : null
|
|
125
|
+
if (raw) {
|
|
126
|
+
items = Array.isArray(raw) ? raw : (raw.items ?? [])
|
|
127
|
+
} else {
|
|
128
|
+
for (const topic of SKILL_TOPICS) {
|
|
129
|
+
const batch = await ghSearchRepos(`topic:${topic} in:name,description,topics`, limit)
|
|
130
|
+
items.push(...batch)
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
} catch (error) {
|
|
134
|
+
console.error('技能搜索失败:' + error.message)
|
|
135
|
+
process.exit(1)
|
|
136
|
+
}
|
|
137
|
+
} else if (inputFile) {
|
|
138
|
+
// 外部已拉取的数据文件(gh api --paginate 输出:JSON 数组)
|
|
139
|
+
console.log(`读取数据文件 ${inputFile}…`)
|
|
140
|
+
const raw = JSON.parse(fs.readFileSync(inputFile, 'utf8'))
|
|
141
|
+
items = Array.isArray(raw) ? raw : (raw.items ?? [])
|
|
142
|
+
} else {
|
|
143
|
+
console.log('拉取 dsh-plugin topic 仓库(gh CLI,自动分页)…')
|
|
144
|
+
try {
|
|
145
|
+
items = await ghSearchRepos('topic:dsh-plugin in:name,description,topics', limit)
|
|
146
|
+
} catch (error) {
|
|
147
|
+
console.error('搜索失败:' + error.message)
|
|
148
|
+
process.exit(1)
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const seen = new Set()
|
|
153
|
+
const normalized = []
|
|
154
|
+
for (const item of items) {
|
|
155
|
+
const norm = normalizeRepo(item)
|
|
156
|
+
if (!norm || seen.has(norm.fullName)) continue
|
|
157
|
+
seen.add(norm.fullName)
|
|
158
|
+
if (skillsMode) {
|
|
159
|
+
norm.skillTopics = SKILL_TOPICS.filter((t) => norm.topics.includes(t))
|
|
160
|
+
}
|
|
161
|
+
normalized.push(norm)
|
|
162
|
+
if (normalized.length >= limit) break
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const existing = readExisting(file)
|
|
166
|
+
// 批次 C-⑪:给能确定的条目补 npmName(--hints=<file> 可覆盖映射文件)
|
|
167
|
+
const hintsArg = process.argv.find((a) => a.startsWith('--hints='))
|
|
168
|
+
const hints = readNpmNameHints(hintsArg ? hintsArg.split('=')[1] : undefined)
|
|
169
|
+
const named = withNpmNames(normalized, hints)
|
|
170
|
+
const out = {
|
|
171
|
+
generatedAt: new Date().toISOString(),
|
|
172
|
+
count: existing.count ?? 0,
|
|
173
|
+
items: existing.items ?? [],
|
|
174
|
+
skills: existing.skills ?? [],
|
|
175
|
+
}
|
|
176
|
+
if (skillsMode) {
|
|
177
|
+
out.skills = named
|
|
178
|
+
out.skillCount = named.length
|
|
179
|
+
} else {
|
|
180
|
+
out.items = named
|
|
181
|
+
out.count = named.length
|
|
182
|
+
}
|
|
183
|
+
fs.writeFileSync(file, JSON.stringify(out, null, 2) + '\n', 'utf8')
|
|
184
|
+
const hinted = named.filter((it) => typeof it.npmName === 'string').length
|
|
185
|
+
console.log(`已更新 ${file}:${skillsMode ? '技能 ' + named.length : '插件 ' + named.length}(限制 ${limit}${hinted > 0 ? `;其中 ${hinted} 条带 npmName` : ''})`)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (require.main === module) main().catch((error) => { console.error(error); process.exit(1) })
|
|
189
|
+
|
|
190
|
+
module.exports = { normalizeRepo, readNpmNameHints, withNpmNames }
|