Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 98b3221eec | |||
| 43c4b825e8 | |||
| 7ebc1c9b36 | |||
| 9855358304 | |||
| 89eef567e1 | |||
| 6c820772a3 |
+54
-1
@@ -11,8 +11,9 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { spawnSync } from 'node:child_process'
|
import { spawnSync } from 'node:child_process'
|
||||||
import { existsSync } from 'node:fs'
|
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
||||||
import { join, resolve } from 'node:path'
|
import { join, resolve } from 'node:path'
|
||||||
|
import { dump, load } from 'js-yaml'
|
||||||
import {
|
import {
|
||||||
DEFAULT_PROFILE_BUNDLES,
|
DEFAULT_PROFILE_BUNDLES,
|
||||||
initProfile,
|
initProfile,
|
||||||
@@ -26,6 +27,52 @@ import { INSTALL_ANCHOR } from './profile-boot.ts'
|
|||||||
|
|
||||||
const NAME = 'dsh'
|
const NAME = 'dsh'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The bare package name when `spec` is a registry specifier, else undefined —
|
||||||
|
* the same shape the in-app install uses to decide registry participation.
|
||||||
|
* Everything pnpm treats as a non-registry source (paths, `file:`/`link:`/
|
||||||
|
* `github:`/`git+`, tarballs, http(s) repo URLs, `.git` markers) is not a
|
||||||
|
* registry spec.
|
||||||
|
* @param spec - the pnpm specifier verbatim.
|
||||||
|
*/
|
||||||
|
export function registryNameOf(spec: string): string | undefined {
|
||||||
|
const trimmed = spec.trim()
|
||||||
|
if (trimmed.length === 0) return undefined
|
||||||
|
if (/^(?:file:|link:|github:|gitlab:|bitbucket:|git\+|git@)/.test(trimmed)) return undefined
|
||||||
|
if (/^(?:\.{1,2}|~|[/\\])/.test(trimmed) || /^[a-zA-Z]:[\\/]/.test(trimmed)) return undefined
|
||||||
|
if (/\.(?:tgz|tar\.gz)(?:[?#]|$)/.test(trimmed)) return undefined
|
||||||
|
if (/^https?:\/\//.test(trimmed) && trimmed.replace(/^https?:\/\//, '').split('/').length > 1) return undefined
|
||||||
|
if (/\.git(?:[#@]|$)/.test(trimmed)) return undefined
|
||||||
|
return trimmed.replace(/@[^/@]+$/, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exempt registry package names from pnpm's `minimumReleaseAge` check by writing
|
||||||
|
* the `minimumReleaseAgeExclude` setting into the profile's `pnpm-workspace.yaml`
|
||||||
|
* (honored by pnpm ≥10.16; an older pnpm ignores the key rather than aborting,
|
||||||
|
* unlike the `--minimum-release-age-exclude` CLI flag). This mirrors the in-app
|
||||||
|
* install so an external `dsh plugin add <name>` gets the same latest-version
|
||||||
|
* behavior as the desktop's install, not a stale age-blocked fallback.
|
||||||
|
* @param profileDir - the profile directory.
|
||||||
|
* @param names - the registry package names to exempt.
|
||||||
|
*/
|
||||||
|
export function writeReleaseAgeExclude(profileDir: string, names: readonly string[]): void {
|
||||||
|
const workspacePath = join(profileDir, 'pnpm-workspace.yaml')
|
||||||
|
let doc: Record<string, unknown>
|
||||||
|
try {
|
||||||
|
const parsed = load(readFileSync(workspacePath, 'utf8'))
|
||||||
|
doc = parsed !== null && typeof parsed === 'object'
|
||||||
|
? parsed as Record<string, unknown>
|
||||||
|
: {}
|
||||||
|
} catch {
|
||||||
|
doc = {}
|
||||||
|
}
|
||||||
|
const excluded = new Set<string>((doc.minimumReleaseAgeExclude ?? []) as string[])
|
||||||
|
for (const name of names) excluded.add(name)
|
||||||
|
if (excluded.size > 0) doc.minimumReleaseAgeExclude = [...excluded]
|
||||||
|
writeFileSync(workspacePath, dump(doc))
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Rewrite relative filesystem specs against the user's invoking directory.
|
* Rewrite relative filesystem specs against the user's invoking directory.
|
||||||
* pnpm runs with cwd = the profile directory, so a bare `.` or `../plugin`
|
* pnpm runs with cwd = the profile directory, so a bare `.` or `../plugin`
|
||||||
@@ -66,6 +113,12 @@ export function runPlugin(profile: string, args: readonly string[]): number {
|
|||||||
// its .cmd shim, which spawn() refuses without a shell since the
|
// its .cmd shim, which spawn() refuses without a shell since the
|
||||||
// CVE-2024-27980 hardening; the vendored path runs node against pnpm.cjs.
|
// CVE-2024-27980 hardening; the vendored path runs node against pnpm.cjs.
|
||||||
const anchored = args.map(argument => anchorPathSpec(argument, process.cwd()))
|
const anchored = args.map(argument => anchorPathSpec(argument, process.cwd()))
|
||||||
|
// An external `add <registry-name>` gets the same minimum-release-age exemption
|
||||||
|
// the in-app install applies, so a just-published plugin installs at latest.
|
||||||
|
if (args[0] === 'add') {
|
||||||
|
const registryNames = args.slice(1).map(registryNameOf).filter((name): name is string => name !== undefined)
|
||||||
|
if (registryNames.length > 0) writeReleaseAgeExclude(dir, registryNames)
|
||||||
|
}
|
||||||
const vendored = resolvePnpm(process.execPath)
|
const vendored = resolvePnpm(process.execPath)
|
||||||
const result = vendored === undefined
|
const result = vendored === undefined
|
||||||
? spawnSync('pnpm', anchored, { cwd: dir, stdio: 'inherit', shell: process.platform === 'win32' })
|
? spawnSync('pnpm', anchored, { cwd: dir, stdio: 'inherit', shell: process.platform === 'win32' })
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import { afterEach, describe, expect, it } from 'vitest'
|
||||||
|
import { load } from 'js-yaml'
|
||||||
|
import { registryNameOf, writeReleaseAgeExclude } from '../src/plugin.ts'
|
||||||
|
|
||||||
|
const dirs: string[] = []
|
||||||
|
function tempDir(): string {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), 'dsh-cli-plugin-'))
|
||||||
|
dirs.push(dir)
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
afterEach(() => { for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }) })
|
||||||
|
|
||||||
|
describe('registryNameOf', () => {
|
||||||
|
it('returns the bare name for a registry specifier', () => {
|
||||||
|
expect(registryNameOf('dsh-theme-plugin')).toBe('dsh-theme-plugin')
|
||||||
|
expect(registryNameOf('dsh-theme-plugin@latest')).toBe('dsh-theme-plugin')
|
||||||
|
expect(registryNameOf('@scope/pkg@1.2.0')).toBe('@scope/pkg')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns undefined for non-registry sources', () => {
|
||||||
|
expect(registryNameOf('github:user/repo')).toBeUndefined()
|
||||||
|
expect(registryNameOf('git+https://github.com/user/repo.git')).toBeUndefined()
|
||||||
|
expect(registryNameOf('file:../plugin')).toBeUndefined()
|
||||||
|
expect(registryNameOf('./plugin')).toBeUndefined()
|
||||||
|
expect(registryNameOf('https://example.com/p.tgz')).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('writeReleaseAgeExclude', () => {
|
||||||
|
it('writes the exclusion into pnpm-workspace.yaml, preserving existing settings', () => {
|
||||||
|
const dir = tempDir()
|
||||||
|
writeFileSync(join(dir, 'pnpm-workspace.yaml'), 'packages:\n - .\nallowBuilds:\n node-pty: true\n')
|
||||||
|
writeReleaseAgeExclude(dir, ['dsh-theme-plugin'])
|
||||||
|
writeReleaseAgeExclude(dir, ['@scope/pkg'])
|
||||||
|
const doc = load(readFileSync(join(dir, 'pnpm-workspace.yaml'), 'utf8')) as Record<string, unknown>
|
||||||
|
expect(doc.minimumReleaseAgeExclude).toEqual(['dsh-theme-plugin', '@scope/pkg'])
|
||||||
|
expect((doc.allowBuilds as Record<string, unknown>)['node-pty']).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('creates the file when absent', () => {
|
||||||
|
const dir = tempDir()
|
||||||
|
writeReleaseAgeExclude(dir, ['x'])
|
||||||
|
const doc = load(readFileSync(join(dir, 'pnpm-workspace.yaml'), 'utf8')) as Record<string, unknown>
|
||||||
|
expect(doc.minimumReleaseAgeExclude).toEqual(['x'])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
本项目(DeepSeek Harness Desktop,即 deepseek-harness-desktop)是基于开源项目
|
||||||
|
deepseek-harness(https://github.com/deepseek-ai/deepseek-harness)的衍生作品,
|
||||||
|
同样以 MIT 协议开源,并保留原项目版权声明。
|
||||||
|
|
||||||
|
Copyright (c) 2026 PineSound
|
||||||
|
Original project deepseek-harness Copyright (c) 2026 DeepSeek
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -1,5 +1,35 @@
|
|||||||
# @deepseek-ai/dsh-desktop
|
# @deepseek-ai/dsh-desktop
|
||||||
|
|
||||||
|
> **DeepSeek Harness Desktop(deepseek-harness-desktop)** — 由 PineSound 基于开源项目
|
||||||
|
> [deepseek-harness](https://github.com/deepseek-ai/deepseek-harness) 构建的**社区版**桌面应用。
|
||||||
|
> 为官方 CLI/Web 形态的 harness 提供原生桌面外壳:双击即用、零外部依赖,发布 macOS 与 Windows 安装包。
|
||||||
|
|
||||||
|
[](LICENSE)
|
||||||
|
|
||||||
|
- **基于**: [deepseek-ai/deepseek-harness](https://github.com/deepseek-ai/deepseek-harness)(MIT)
|
||||||
|
- **源码**: https://github.com/PineKings/deepseek-harness-desktop
|
||||||
|
- **发布站**: https://deepseek.pinesound.cn/
|
||||||
|
|
||||||
|
## 开源协议(License)
|
||||||
|
|
||||||
|
本项目基于 MIT 授权的 deepseek-harness 构建,同样以 **MIT 协议**开源(见 [LICENSE](./LICENSE)),并保留原项目版权声明。
|
||||||
|
|
||||||
|
## 修改说明(Modification Note)
|
||||||
|
|
||||||
|
本项目是 [deepseek-harness](https://github.com/deepseek-ai/deepseek-harness) 的**衍生作品**,在**完全保留原项目后端与核心 harness 行为**的基础上,面向最终用户增加了以下能力:
|
||||||
|
|
||||||
|
- **桌面外壳**:基于 Electron 的原生桌面窗口,双击即用、无需浏览器标签页;安装包内置完整运行时与 Node,目标机器零外部依赖;发布 macOS(`.dmg`)与 Windows(`.nsis`)安装包。
|
||||||
|
- **插件系统**:图形化的插件安装 / 开关、黑白名单守卫、内置 pnpm 与多镜像回退、免重启实时生效。
|
||||||
|
- **图像识别**:通过 OpenAI 兼容接口(DashScope)补齐视觉能力,密钥 / 地址 / 模型与对话主模型完全独立。
|
||||||
|
- **更新机制**:读取线上 `releases.json` 自动检测更新,桌面与发布站同渠道。
|
||||||
|
- **发布站**:配套的**非官方社区发布站**(deepseek.pinesound.cn),复刻并重构官方页面为中文单语言纯静态站点,**非 DeepSeek 官方站点**,与 DeepSeek 无隶属或赞助关系,商标归原权利方所有。
|
||||||
|
|
||||||
|
> 说明:本项目的后端与核心 harness 行为**完全保留原项目**;桌面外壳只是薄包装,原生 ABI 兼容,无需为 Electron 重编译。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 技术说明(Technical Reference)
|
||||||
|
|
||||||
Electron desktop shell for DeepSeek Harness. The Electron main process is a thin
|
Electron desktop shell for DeepSeek Harness. The Electron main process is a thin
|
||||||
wrapper: it spawns the real `dsh` CLI running the `web` profile on loopback (an
|
wrapper: it spawns the real `dsh` CLI running the `web` profile on loopback (an
|
||||||
OS-assigned port), parses the readiness URL line the profile prints
|
OS-assigned port), parses the readiness URL line the profile prints
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
"version": "0.1.0-rc.5",
|
"version": "0.1.0-rc.5",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"license": "MIT",
|
||||||
"author": "PineSound",
|
"author": "PineSound",
|
||||||
"main": "lib/types/main.js",
|
"main": "lib/types/main.js",
|
||||||
"files": [
|
"files": [
|
||||||
|
|||||||
@@ -10,15 +10,22 @@
|
|||||||
* native, apps/cli, apps/web — plus the platform Node binary, into
|
* native, apps/cli, apps/web — plus the platform Node binary, into
|
||||||
* `build/harness`, which electron-builder ships as `extraResources`.
|
* `build/harness`, which electron-builder ships as `extraResources`.
|
||||||
*
|
*
|
||||||
* Run from the repository root (`pnpm --filter @deepseek-ai/dsh-desktop run
|
* The packaged harness must stay fully self-contained: the app is not a mere
|
||||||
* build:harness`; the root `desktop:pack` script runs it automatically). The
|
* launcher, it must let users in the most barren environments use dsh entirely
|
||||||
* copy is followed by a relink pass (harness-relink.mjs) that rewrites every
|
* through the app. We therefore copy the whole workspace node_modules and then
|
||||||
|
* prune a curated, VERIFIED-safe exclusion list (see pruneNodeModules below and
|
||||||
|
* `scripts/harness-excludes.json`). Nothing is excluded unless it is provably
|
||||||
|
* outside the dsh runtime dependency closure.
|
||||||
|
*
|
||||||
|
* The copy is followed by a relink pass (harness-relink.mjs) that rewrites every
|
||||||
* Windows junction / absolute link into a RELATIVE in-tree symlink, so the
|
* Windows junction / absolute link into a RELATIVE in-tree symlink, so the
|
||||||
* packaged harness no longer references the build machine's dev tree, and by a
|
* packaged harness no longer references the build machine's dev tree, then by a
|
||||||
* self-containment check plus a `dsh --version` smoke test.
|
* self-containment check and a `dsh --version` smoke test.
|
||||||
|
*
|
||||||
|
* Run from the repository root before `desktop:pack`.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { cpSync, chmodSync, existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
|
import { cpSync, chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, rmSync, writeFileSync } from 'node:fs'
|
||||||
import { dirname, join, resolve } from 'node:path'
|
import { dirname, join, resolve } from 'node:path'
|
||||||
import { fileURLToPath } from 'node:url'
|
import { fileURLToPath } from 'node:url'
|
||||||
import { execFileSync } from 'node:child_process'
|
import { execFileSync } from 'node:child_process'
|
||||||
@@ -60,7 +67,11 @@ for (const [label, path] of [
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
rmSync(out, { recursive: true, force: true })
|
// Recursively removing the previous (multi-GB, symlink-heavy) harness can hit
|
||||||
|
// ENOTEMPTY/EBUSY transiently — on Windows especially, and on macOS under load.
|
||||||
|
// Retry so the rebuild is robust; maxRetries/retryDelay are Node's built-in
|
||||||
|
// answer to exactly these codes.
|
||||||
|
rmSync(out, { recursive: true, force: true, maxRetries: 10, retryDelay: 250 })
|
||||||
mkdirSync(join(out, 'apps'), { recursive: true })
|
mkdirSync(join(out, 'apps'), { recursive: true })
|
||||||
mkdirSync(join(out, 'bin'), { recursive: true })
|
mkdirSync(join(out, 'bin'), { recursive: true })
|
||||||
|
|
||||||
@@ -71,6 +82,12 @@ for (const [src, dst] of extraDirs) {
|
|||||||
cpSync(join(root, src), join(out, dst), { recursive: true })
|
cpSync(join(root, src), join(out, dst), { recursive: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Prune verified-safe build/packaging tooling out of the copied node_modules.
|
||||||
|
// This is the ONLY sanctioned way to shrink the self-contained runtime: every
|
||||||
|
// exclusion is validated against the dependency closure of the kept packages
|
||||||
|
// and refused if any kept package depends on it.
|
||||||
|
pruneNodeModules(join(out, 'node_modules'))
|
||||||
|
|
||||||
// Bundle the platform Node binary for the harness child. Windows needs a
|
// Bundle the platform Node binary for the harness child. Windows needs a
|
||||||
// `.exe` extension so both the child spawn and the `dsh.cmd` shim can execute
|
// `.exe` extension so both the child spawn and the `dsh.cmd` shim can execute
|
||||||
// it; POSIX uses a bare `node`.
|
// it; POSIX uses a bare `node`.
|
||||||
@@ -97,6 +114,13 @@ try {
|
|||||||
// Node/pnpm install is needed on the target. POSIX ships an executable `dsh`
|
// Node/pnpm install is needed on the target. POSIX ships an executable `dsh`
|
||||||
// shell script; Windows ships a `dsh.cmd` shim (the desktop registers the
|
// shell script; Windows ships a `dsh.cmd` shim (the desktop registers the
|
||||||
// harness directory on the user PATH on first launch).
|
// harness directory on the user PATH on first launch).
|
||||||
|
//
|
||||||
|
// The POSIX launcher resolves its own real path through symlinks before looking
|
||||||
|
// up `bin/node` and `apps/cli/lib/bin.js`: the desktop registers `dsh` on PATH
|
||||||
|
// as a symlink into a bin dir, so a bare `dirname "$0"` would resolve to the
|
||||||
|
// link's directory (no siblings there). `readlink -f` is unavailable on macOS,
|
||||||
|
// so resolve portably with the readlink loop below. `dsh.cmd` needs no such
|
||||||
|
// handling because `%~dp0` already expands to the real script location.
|
||||||
if (process.platform === 'win32') {
|
if (process.platform === 'win32') {
|
||||||
writeFileSync(
|
writeFileSync(
|
||||||
join(out, 'dsh.cmd'),
|
join(out, 'dsh.cmd'),
|
||||||
@@ -106,11 +130,180 @@ if (process.platform === 'win32') {
|
|||||||
const dshLauncher = join(out, 'dsh')
|
const dshLauncher = join(out, 'dsh')
|
||||||
writeFileSync(
|
writeFileSync(
|
||||||
dshLauncher,
|
dshLauncher,
|
||||||
'#!/bin/sh\nexec "$(dirname "$0")/bin/node" "$(dirname "$0")/apps/cli/lib/bin.js" "$@"\n',
|
'#!/bin/sh\nSELF="$0"\nwhile [ -h "$SELF" ]; do\n DIR=$(cd "$(dirname "$SELF")" && pwd)\n LINK=$(readlink "$SELF")\n case "$LINK" in /*) SELF="$LINK";; *) SELF="$DIR/$LINK";; esac\ndone\nDIR=$(cd "$(dirname "$SELF")" && pwd)\nexec "$DIR/bin/node" "$DIR/apps/cli/lib/bin.js" "$@"\n',
|
||||||
)
|
)
|
||||||
chmodSync(dshLauncher, 0o755)
|
chmodSync(dshLauncher, 0o755)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the curated build/packaging-time packages from a copied pnpm
|
||||||
|
* `node_modules`. Safe by construction:
|
||||||
|
*
|
||||||
|
* 1. Reads `scripts/harness-excludes.json` — a visible, configurable list of
|
||||||
|
* package names + reasons (users add/remove entries without touching the
|
||||||
|
* copy logic).
|
||||||
|
* 2. For each exclusion, walks every KEPT `.pnpm/<pkg>@<ver>` entry and checks
|
||||||
|
* whether any kept package symlinks this name as a dependency. If so, the
|
||||||
|
* exclusion is REFUSED (throws) — the runtime must stay whole.
|
||||||
|
* 3. If safe, deletes the `.pnpm/<name>@*` entries, the top-level
|
||||||
|
* `node_modules/<name>` link, and any `.bin` shims that pointed into them,
|
||||||
|
* reporting bytes freed per entry and a total.
|
||||||
|
*/
|
||||||
|
function pruneNodeModules(nmDir) {
|
||||||
|
const cfgPath = join(root, 'apps/desktop/scripts/harness-excludes.json')
|
||||||
|
if (!existsSync(cfgPath)) return
|
||||||
|
const { excludes } = JSON.parse(readFileSync(cfgPath, 'utf8'))
|
||||||
|
if (!Array.isArray(excludes) || excludes.length === 0) return
|
||||||
|
|
||||||
|
// De-dupe by name so a duplicate config entry can't double-report or double-count.
|
||||||
|
const unique = new Map()
|
||||||
|
for (const e of excludes) if (e && e.name && !unique.has(e.name)) unique.set(e.name, e)
|
||||||
|
const list = [...unique.values()]
|
||||||
|
|
||||||
|
const pnpmDir = join(nmDir, '.pnpm')
|
||||||
|
if (!existsSync(pnpmDir)) return
|
||||||
|
const entries = readdirSync(pnpmDir)
|
||||||
|
|
||||||
|
// All packages that will be removed — used to ignore cross-excluded refs
|
||||||
|
// during the safety check (a dependency among two removed packages is fine).
|
||||||
|
const excludedSet = new Set(list.map((e) => e.name))
|
||||||
|
|
||||||
|
// Package name of a .pnpm entry. Entries look like "name@1.0.0" for a bare
|
||||||
|
// package or "@scope+name@1.0.0" for a scoped one, but peer-dependency
|
||||||
|
// suffixes add further "@"s (e.g. "dmg-builder@25.1.8_peer@25.1.8"). Parse
|
||||||
|
// from the FIRST "@" — the base name never contains one after the version
|
||||||
|
// separator — so peer suffixes can't leak into the parsed name.
|
||||||
|
const entryPkg = (entry) => {
|
||||||
|
const at = entry.indexOf('@')
|
||||||
|
if (at === -1) return entry
|
||||||
|
if (at === 0) {
|
||||||
|
const rest = entry.slice(1) // "@scope+name@ver..." -> "scope+name@ver..."
|
||||||
|
const sep = rest.indexOf('@')
|
||||||
|
const name = sep === -1 ? rest : rest.slice(0, sep)
|
||||||
|
return '@' + name.replace('+', '/')
|
||||||
|
}
|
||||||
|
return entry.slice(0, at)
|
||||||
|
}
|
||||||
|
|
||||||
|
let totalFreed = 0
|
||||||
|
|
||||||
|
for (const ex of list) {
|
||||||
|
const name = ex.name
|
||||||
|
if (excludedSet.size === 0) continue
|
||||||
|
|
||||||
|
// --- 2. Safety: does any KEPT package depend on this package? ---
|
||||||
|
const dependents = []
|
||||||
|
for (const entry of entries) {
|
||||||
|
const ep = entryPkg(entry)
|
||||||
|
if (ep === name || excludedSet.has(ep)) continue // itself or also excluded
|
||||||
|
const depDir = join(pnpmDir, entry, 'node_modules')
|
||||||
|
if (!existsSync(depDir)) continue
|
||||||
|
if (readdirSync(depDir).includes(name)) dependents.push(ep)
|
||||||
|
}
|
||||||
|
if (dependents.length > 0) {
|
||||||
|
throw new Error(
|
||||||
|
`[harness-excludes] REFUSING to exclude "${name}": kept packages depend on it ` +
|
||||||
|
`(${dependents.join(', ')}). The runtime must stay whole — remove it from ` +
|
||||||
|
`${cfgPath} or it is not actually safe to exclude.`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 3. Delete the .pnpm store entries for this package ---
|
||||||
|
const matching = entries.filter((e) => entryPkg(e) === name)
|
||||||
|
for (const m of matching) {
|
||||||
|
const p = join(pnpmDir, m)
|
||||||
|
const sz = dirSize(p)
|
||||||
|
rmSync(p, { recursive: true, force: true })
|
||||||
|
totalFreed += sz
|
||||||
|
console.log(` [exclude] ${name} — removed .pnpm/${m} (${fmtSize(sz)})`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 4. Delete the top-level link/dir (node_modules/@scope/name too) ---
|
||||||
|
const top = join(nmDir, ...name.split('/'))
|
||||||
|
if (existsSync(top)) {
|
||||||
|
const sz = dirSize(top)
|
||||||
|
rmSync(top, { recursive: true, force: true })
|
||||||
|
totalFreed += sz
|
||||||
|
console.log(` [exclude] ${name} — removed top-level link (${fmtSize(sz)})`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 5. Delete the flat .pnpm/node_modules/<name> copy. cpSync dereferences
|
||||||
|
// links while copying, so an excluded package lands here as a REAL directory
|
||||||
|
// (e.g. electron → 837 MB) that steps 2–4 never touch; it was shipping anyway.
|
||||||
|
// This shares the same safety check in step 2 — no kept package depends on it.
|
||||||
|
// Probe with lstatSync (not existsSync): it inspects the link itself rather
|
||||||
|
// than following the target, so a dangling symlink/junction (its .pnpm store
|
||||||
|
// entry already removed in step 3) is still found and removed on Windows. ---
|
||||||
|
const flat = join(pnpmDir, 'node_modules', ...name.split('/'))
|
||||||
|
let flatStat
|
||||||
|
try {
|
||||||
|
flatStat = lstatSync(flat)
|
||||||
|
} catch {
|
||||||
|
flatStat = null
|
||||||
|
}
|
||||||
|
if (flatStat) {
|
||||||
|
const sz = flatStat.isSymbolicLink() || flatStat.isFile() ? flatStat.size : dirSize(flat)
|
||||||
|
rmSync(flat, { recursive: true, force: true })
|
||||||
|
totalFreed += sz
|
||||||
|
console.log(` [exclude] ${name} — removed flat .pnpm/node_modules copy (${fmtSize(sz)})`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 6. Drop .bin shims that pointed into the removed store entries ---
|
||||||
|
removeBinShims(join(nmDir, '.bin'), matching, pnpmDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totalFreed > 0) {
|
||||||
|
console.log(
|
||||||
|
`\n[harness-excludes] freed ${fmtSize(totalFreed)} by excluding ` +
|
||||||
|
`${list.map((e) => e.name).join(', ')} (config: ${cfgPath})`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Recursive size of a dir or link target. Symlinks are counted as 0 bytes and
|
||||||
|
* never followed (a pnpm store is full of links; following would loop). */
|
||||||
|
function dirSize(p) {
|
||||||
|
let st
|
||||||
|
try {
|
||||||
|
st = lstatSync(p)
|
||||||
|
} catch {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if (st.isSymbolicLink() || st.isFile()) return st.size
|
||||||
|
if (!st.isDirectory()) return 0
|
||||||
|
let total = 0
|
||||||
|
for (const child of readdirSync(p)) total += dirSize(join(p, child))
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove `.bin` shims whose resolved target lives inside any removed store
|
||||||
|
* entry, so no dangling executables remain. */
|
||||||
|
function removeBinShims(binDir, removedEntries, pnpmDir) {
|
||||||
|
if (!existsSync(binDir)) return
|
||||||
|
const removedStoreDirs = removedEntries.map((e) => join(pnpmDir, e))
|
||||||
|
for (const shim of readdirSync(binDir)) {
|
||||||
|
const p = join(binDir, shim)
|
||||||
|
let target
|
||||||
|
try {
|
||||||
|
target = readlinkSync(p)
|
||||||
|
} catch {
|
||||||
|
continue // not a symlink (e.g. a real .cmd file) — leave it
|
||||||
|
}
|
||||||
|
const resolved = resolve(binDir, target)
|
||||||
|
if (removedStoreDirs.some((d) => resolved.startsWith(d))) {
|
||||||
|
rmSync(p, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Human-readable byte size, e.g. "553 MiB". */
|
||||||
|
function fmtSize(bytes) {
|
||||||
|
if (bytes >= 1024 ** 3) return `${(bytes / 1024 ** 3).toFixed(2)} GiB`
|
||||||
|
if (bytes >= 1024 ** 2) return `${(bytes / 1024 ** 2).toFixed(1)} MiB`
|
||||||
|
if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KiB`
|
||||||
|
return `${bytes} B`
|
||||||
|
}
|
||||||
|
|
||||||
console.log(`assembled self-contained harness at ${out}`)
|
console.log(`assembled self-contained harness at ${out}`)
|
||||||
|
|
||||||
// Normalize every link to a relative in-tree target, then prove the tree no
|
// Normalize every link to a relative in-tree target, then prove the tree no
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
{
|
||||||
|
"$schema": "harness-excludes.schema.json",
|
||||||
|
"version": 1,
|
||||||
|
"description": "Packages excluded from the packaged harness node_modules (.pnpm store). The app must stay fully self-contained — it is not a launcher, it lets users in the most barren environments use dsh entirely through the app. So an entry here is a HARD guarantee that the package is NOT part of the dsh runtime dependency closure: the build refuses to exclude anything a kept workspace package actually depends on (see build-harness.mjs pruneNodeModules). Add entries only after verifying the package is build-time / packaging-time tooling that the harness never imports at runtime.",
|
||||||
|
"excludes": [
|
||||||
|
{
|
||||||
|
"name": "electron",
|
||||||
|
"reason": "Electron shell runtime. electron-builder ships it separately into resources/app; the dsh harness runs under the bundled system-Node child and never imports Electron. Verified: only apps/desktop/package.json depends on it, only apps/desktop/lib/types/{main,preload}.js import it (those are the shell, already packaged)."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "electron-builder",
|
||||||
|
"reason": "Packaging toolchain (group). The harness is pre-built and distributed, so nothing packages installers at runtime. The electron-builder toolchain is mutually interdependent, so it is excluded as a group; build-harness.mjs refuses any exclusion a kept package still depends on, which is how this group stays coherent."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "app-builder-lib",
|
||||||
|
"reason": "electron-builder toolchain (group): app-builder-lib is packaging logic only, never imported by the dsh runtime."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "builder-util",
|
||||||
|
"reason": "electron-builder toolchain (group): build utilities (spawns app-builder-bin, 7zip-bin). Build-time only."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "builder-util-runtime",
|
||||||
|
"reason": "electron-builder toolchain (group): runtime helpers used only by the builder at package time, not by dsh."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "dmg-builder",
|
||||||
|
"reason": "electron-builder toolchain (group): mac .dmg packaging; not present on this Windows build and never used at runtime."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "electron-builder-squirrel-windows",
|
||||||
|
"reason": "electron-builder toolchain (group): Squirrel.Windows packaging; build-time only."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "app-builder-bin",
|
||||||
|
"reason": "electron-builder's build binary (7za/signtool/app-builder). Build-time only, never imported by the dsh runtime. Depended on by builder-util, which is itself excluded in this group."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "electron-publish",
|
||||||
|
"reason": "electron-builder toolchain (group): publishing (upload to GitHub etc.); never used by the dsh runtime."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "7zip-bin",
|
||||||
|
"reason": "electron-builder toolchain (group): 7-zip binary used to compress installers at package time; not a dsh runtime dependency."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "@electron/asar",
|
||||||
|
"reason": "electron-builder toolchain (group): asar archive packing; build-time only (the desktop app ships asar:false anyway)."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "@electron/notarize",
|
||||||
|
"reason": "electron-builder toolchain (group): macOS notarization; build-time only, and irrelevant on this Windows build."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "@electron/osx-sign",
|
||||||
|
"reason": "electron-builder toolchain (group): macOS code signing; build-time only."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "@electron/universal",
|
||||||
|
"reason": "electron-builder toolchain (group): macOS universal binaries; build-time only."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "@electron/get",
|
||||||
|
"reason": "Orphan of the excluded electron toolchain: @electron/rebuild's download helper. No kept package imports it at runtime."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "@electron/rebuild",
|
||||||
|
"reason": "Orphan of the excluded electron toolchain: rebuilds native addons for the Electron ABI, which this app never needs (harness runs under system-Node)."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "@electron-internal/extract-zip",
|
||||||
|
"reason": "Orphan of the excluded electron-builder toolchain: internal zip extraction used only during packaging. Build-time only."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||||
|
"$id": "https://deepseek-harness.dev/schemas/harness-excludes.schema.json",
|
||||||
|
"title": "Harness dependency exclusion list",
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["version", "description", "excludes"],
|
||||||
|
"properties": {
|
||||||
|
"version": { "type": "integer", "const": 1 },
|
||||||
|
"description": { "type": "string" },
|
||||||
|
"excludes": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["name", "reason"],
|
||||||
|
"properties": {
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Package name to exclude, e.g. \"electron\" or \"@scope/name\". Matches pnpm store entries by unscoped/scoped base name.",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"reason": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Why this package is safely outside the dsh runtime dependency closure. Required so the exclusion stays auditable."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
runPnpmRemove,
|
runPnpmRemove,
|
||||||
uninstallBundle,
|
uninstallBundle,
|
||||||
writeAllowBuilds,
|
writeAllowBuilds,
|
||||||
|
writeReleaseAgeExclude,
|
||||||
} from './install.ts'
|
} from './install.ts'
|
||||||
import {
|
import {
|
||||||
fetchMarketplaceCatalog,
|
fetchMarketplaceCatalog,
|
||||||
@@ -253,8 +254,12 @@ export class PluginInventoryGateway extends TypertRemoteService {
|
|||||||
writeAllowBuilds(profileDir, spec.consentBuilds)
|
writeAllowBuilds(profileDir, spec.consentBuilds)
|
||||||
}
|
}
|
||||||
// A registry-name spec participates in the registry fallback loop and the
|
// A registry-name spec participates in the registry fallback loop and the
|
||||||
// minimum-release-age exemption; a git, tarball, or path spec runs once.
|
// minimum-release-age exemption; a git, tarball, or path spec runs once. The
|
||||||
|
// release-age exemption is written into pnpm-workspace.yaml (not passed as a
|
||||||
|
// CLI flag) so an older pnpm ignores the setting instead of aborting on an
|
||||||
|
// unknown option.
|
||||||
const registryName = registryPackageName(spec.spec)
|
const registryName = registryPackageName(spec.spec)
|
||||||
|
if (registryName !== undefined) writeReleaseAgeExclude(profileDir, [registryName])
|
||||||
const result = registryName === undefined
|
const result = registryName === undefined
|
||||||
? runPnpmInstall({
|
? runPnpmInstall({
|
||||||
binName: 'dsh',
|
binName: 'dsh',
|
||||||
@@ -273,7 +278,6 @@ export class PluginInventoryGateway extends TypertRemoteService {
|
|||||||
pnpmCjs: pnpm.pnpmCjs,
|
pnpmCjs: pnpm.pnpmCjs,
|
||||||
spec: spec.spec,
|
spec: spec.spec,
|
||||||
before,
|
before,
|
||||||
minimumReleaseAgeExclude: registryName,
|
|
||||||
})
|
})
|
||||||
if (result.pendingBuilds !== undefined) {
|
if (result.pendingBuilds !== undefined) {
|
||||||
return { ok: true, restartRequired: false, pendingBuilds: result.pendingBuilds }
|
return { ok: true, restartRequired: false, pendingBuilds: result.pendingBuilds }
|
||||||
|
|||||||
@@ -150,8 +150,6 @@ export interface PnpmInstallOptions {
|
|||||||
readonly before: ProfileManifest
|
readonly before: ProfileManifest
|
||||||
/** An npm registry to install from (`pnpm add --registry`); defaults to pnpm's configured one. */
|
/** An npm registry to install from (`pnpm add --registry`); defaults to pnpm's configured one. */
|
||||||
readonly registry?: string
|
readonly registry?: string
|
||||||
/** A bare registry package name to exempt from pnpm's minimum-release-age check. */
|
|
||||||
readonly minimumReleaseAgeExclude?: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -164,15 +162,9 @@ export interface PnpmInstallOptions {
|
|||||||
* @returns `pendingBuilds` when pnpm blocked build scripts, else an empty result.
|
* @returns `pendingBuilds` when pnpm blocked build scripts, else an empty result.
|
||||||
*/
|
*/
|
||||||
export function runPnpmInstall(options: PnpmInstallOptions): PnpmAddResult {
|
export function runPnpmInstall(options: PnpmInstallOptions): PnpmAddResult {
|
||||||
const { binName, profileDir, installAnchor, nodeBin, pnpmCjs, spec, before, registry, minimumReleaseAgeExclude } = options
|
const { binName, profileDir, installAnchor, nodeBin, pnpmCjs, spec, before, registry } = options
|
||||||
const args = ['add', spec]
|
const args = ['add', spec]
|
||||||
if (registry !== undefined) args.push('--registry', registry)
|
if (registry !== undefined) args.push('--registry', registry)
|
||||||
// `--minimum-release-age-exclude` is understood only by pnpm ≥10.7. It is safe
|
|
||||||
// with the vendored pnpm (pinned 11.7); a PATH-pnpm fallback in a development
|
|
||||||
// checkout may be older and reject the option, so skip it there.
|
|
||||||
if (minimumReleaseAgeExclude !== undefined && nodeBin !== undefined) {
|
|
||||||
args.push(`--minimum-release-age-exclude=${minimumReleaseAgeExclude}`)
|
|
||||||
}
|
|
||||||
const result = nodeBin === undefined ? spawn(pnpmCjs, args, profileDir) : spawn(nodeBin, [pnpmCjs, ...args], profileDir)
|
const result = nodeBin === undefined ? spawn(pnpmCjs, args, profileDir) : spawn(nodeBin, [pnpmCjs, ...args], profileDir)
|
||||||
if (result.exitCode !== 0) {
|
if (result.exitCode !== 0) {
|
||||||
const pendingBuilds = parseBlockedBuilds(result.output)
|
const pendingBuilds = parseBlockedBuilds(result.output)
|
||||||
@@ -264,6 +256,37 @@ export function writeAllowBuilds(profileDir: string, names: readonly string[]):
|
|||||||
writeFileSync(workspacePath, dump(doc))
|
writeFileSync(workspacePath, dump(doc))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exempt the given registry package names from pnpm's `minimumReleaseAge` check
|
||||||
|
* by writing the `minimumReleaseAgeExclude` setting into the profile's
|
||||||
|
* `pnpm-workspace.yaml`. Writing the config directly is the robust way to beat
|
||||||
|
* the release-age check: the setting is honored by pnpm ≥10.16 (the documented
|
||||||
|
* mechanism, which pnpm reads only from `pnpm-workspace.yaml`, not `.npmrc`),
|
||||||
|
* and — unlike the `--minimum-release-age-exclude` CLI flag — an unknown-key pnpm
|
||||||
|
* ignores it instead of aborting with `Unknown option`. This lets a plugin
|
||||||
|
* published minutes ago install at its latest version on whichever pnpm the
|
||||||
|
* runtime resolves. Existing workspace settings are preserved and the exclusion
|
||||||
|
* list is merged.
|
||||||
|
* @param profileDir - the writable profile directory.
|
||||||
|
* @param names - the registry package names to exempt from the release-age check.
|
||||||
|
*/
|
||||||
|
export function writeReleaseAgeExclude(profileDir: string, names: readonly string[]): void {
|
||||||
|
const workspacePath = join(profileDir, 'pnpm-workspace.yaml')
|
||||||
|
let doc: Record<string, unknown>
|
||||||
|
try {
|
||||||
|
const parsed = load(readFileSync(workspacePath, 'utf8'))
|
||||||
|
doc = parsed !== null && typeof parsed === 'object'
|
||||||
|
? parsed as Record<string, unknown>
|
||||||
|
: { ...PROFILE_WORKSPACE_BASE }
|
||||||
|
} catch {
|
||||||
|
doc = { ...PROFILE_WORKSPACE_BASE }
|
||||||
|
}
|
||||||
|
const excluded = new Set<string>((doc.minimumReleaseAgeExclude ?? []) as string[])
|
||||||
|
for (const name of names) excluded.add(name)
|
||||||
|
if (excluded.size > 0) doc.minimumReleaseAgeExclude = [...excluded]
|
||||||
|
writeFileSync(workspacePath, dump(doc))
|
||||||
|
}
|
||||||
|
|
||||||
/** Options for removing one plugin dependency. */
|
/** Options for removing one plugin dependency. */
|
||||||
export interface PnpmRemoveOptions {
|
export interface PnpmRemoveOptions {
|
||||||
readonly binName: string
|
readonly binName: string
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||||
import { tmpdir } from 'node:os'
|
import { tmpdir } from 'node:os'
|
||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
import { afterEach, describe, expect, it } from 'vitest'
|
import { afterEach, describe, expect, it } from 'vitest'
|
||||||
|
import { load } from 'js-yaml'
|
||||||
import { readProfileManifest } from '@deepseek-ai/dsh-app-boot'
|
import { readProfileManifest } from '@deepseek-ai/dsh-app-boot'
|
||||||
import {
|
import {
|
||||||
composeOfflineBundle, INSTALL_REGISTRIES, parseBlockedBuilds, registryPackageName,
|
composeOfflineBundle, INSTALL_REGISTRIES, parseBlockedBuilds, registryPackageName,
|
||||||
resolvePnpm, resolvePnpmCommand, runPnpmInstall, writeAllowBuilds,
|
resolvePnpm, resolvePnpmCommand, runPnpmInstall, writeAllowBuilds,
|
||||||
|
writeReleaseAgeExclude,
|
||||||
runPnpmInstallWithRegistries, runPnpmRemove, uninstallBundle,
|
runPnpmInstallWithRegistries, runPnpmRemove, uninstallBundle,
|
||||||
} from '../src/install.ts'
|
} from '../src/install.ts'
|
||||||
|
|
||||||
@@ -67,24 +69,6 @@ function makeBlockedBuildPnpm(dir: string): string {
|
|||||||
return file
|
return file
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* A self-executable fake pnpm (shebang + executable bit) that records its argv
|
|
||||||
* and exits 0 — simulates a `pnpm` command invoked directly off PATH, without a
|
|
||||||
* `node` prefix (as the PATH-pnpm fallback does).
|
|
||||||
*/
|
|
||||||
function makeExecutablePnpm(dir: string): string {
|
|
||||||
const file = join(dir, 'pnpm-path')
|
|
||||||
writeFileSync(file, [
|
|
||||||
'#!/usr/bin/env node',
|
|
||||||
"const fs = require('fs')",
|
|
||||||
'const args = process.argv.slice(2)',
|
|
||||||
'fs.writeFileSync(process.env.RECORD, JSON.stringify(args))',
|
|
||||||
'process.exit(0)',
|
|
||||||
].join('\n'))
|
|
||||||
chmodSync(file, 0o755)
|
|
||||||
return file
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A fake pnpm that, for `remove <name>`, drops the named dependency from the
|
* A fake pnpm that, for `remove <name>`, drops the named dependency from the
|
||||||
* profile's package.json (as pnpm does) and exits 0.
|
* profile's package.json (as pnpm does) and exits 0.
|
||||||
@@ -171,7 +155,7 @@ describe('runPnpmInstall', () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
it('passes minimum-release-age-exclude for a registry name', () => {
|
it('never passes a release-age CLI flag (the exemption is a workspace setting, not a flag)', () => {
|
||||||
const dir = makeProfile()
|
const dir = makeProfile()
|
||||||
const record = join(dir, 'record.json')
|
const record = join(dir, 'record.json')
|
||||||
const pnpm = makeRecordingPnpm(dir)
|
const pnpm = makeRecordingPnpm(dir)
|
||||||
@@ -181,32 +165,28 @@ describe('runPnpmInstall', () => {
|
|||||||
runPnpmInstall({
|
runPnpmInstall({
|
||||||
binName: 'dsh', profileDir: dir, installAnchor: join(dir, 'package.json'),
|
binName: 'dsh', profileDir: dir, installAnchor: join(dir, 'package.json'),
|
||||||
nodeBin: process.execPath, pnpmCjs: pnpm, spec: 'x',
|
nodeBin: process.execPath, pnpmCjs: pnpm, spec: 'x',
|
||||||
before: readProfileManifest('dsh', dir), minimumReleaseAgeExclude: 'x',
|
before: readProfileManifest('dsh', dir),
|
||||||
})
|
})
|
||||||
const args = JSON.parse(readFileSync(record, 'utf8')) as string[]
|
const args = JSON.parse(readFileSync(record, 'utf8')) as string[]
|
||||||
expect(args).toContain('--minimum-release-age-exclude=x')
|
expect(args).toEqual(['add', 'x'])
|
||||||
|
expect(args.some(arg => arg.includes('minimum-release-age'))).toBe(false)
|
||||||
} finally {
|
} finally {
|
||||||
delete process.env.RECORD
|
delete process.env.RECORD
|
||||||
delete process.env.EXIT
|
delete process.env.EXIT
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
it('omits minimum-release-age-exclude for a PATH-pnpm fallback (may be an older pnpm)', () => {
|
it('writes the minimum-release-age exemption into pnpm-workspace.yaml, preserving existing settings', () => {
|
||||||
const dir = makeProfile()
|
const dir = makeProfile()
|
||||||
const record = join(dir, 'record.json')
|
const workspacePath = join(dir, 'pnpm-workspace.yaml')
|
||||||
const pnpm = makeExecutablePnpm(dir)
|
writeFileSync(workspacePath, 'autoInstallPeers: false\nallowBuilds:\n node-pty: true\n')
|
||||||
process.env.RECORD = record
|
writeReleaseAgeExclude(dir, ['dsh-theme-plugin'])
|
||||||
try {
|
writeReleaseAgeExclude(dir, ['@scope/pkg'])
|
||||||
runPnpmInstall({
|
const doc = JSON.parse(JSON.stringify(load(readFileSync(workspacePath, 'utf8')))) as Record<string, unknown>
|
||||||
binName: 'dsh', profileDir: dir, installAnchor: join(dir, 'package.json'),
|
expect(doc.minimumReleaseAgeExclude).toEqual(['dsh-theme-plugin', '@scope/pkg'])
|
||||||
nodeBin: undefined, pnpmCjs: pnpm, spec: 'x',
|
// Existing settings survive.
|
||||||
before: readProfileManifest('dsh', dir), minimumReleaseAgeExclude: 'x',
|
expect((doc.allowBuilds as Record<string, unknown>).node_pty ?? (doc.allowBuilds as Record<string, unknown>)['node-pty']).toBe(true)
|
||||||
})
|
expect(doc.autoInstallPeers).toBe(false)
|
||||||
const args = JSON.parse(readFileSync(record, 'utf8')) as string[]
|
|
||||||
expect(args).toEqual(['add', 'x'])
|
|
||||||
} finally {
|
|
||||||
delete process.env.RECORD
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('returns pendingBuilds when pnpm blocks build scripts', () => {
|
it('returns pendingBuilds when pnpm blocks build scripts', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user