dsh-runnpu 0.1.0-rc.1
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.
Potentially problematic release.
This version of dsh-runnpu might be problematic. Click here for more details.
- package/LICENSE +21 -0
- package/README.md +89 -0
- package/bin/cli-deps.mjs +384 -0
- package/bin/dsh-runnpu.mjs +255 -0
- package/bin/presets.mjs +167 -0
- package/compute-mode/node/index.mjs +527 -0
- package/compute-mode/node/tools.mjs +1440 -0
- package/compute-mode/preset/agent.cordis.yml +366 -0
- package/compute-mode/preset/preset.yml +3 -0
- package/compute-mode/preset/skills/runnpu/SKILL.md +233 -0
- package/compute-mode/preset/skills/runnpu/VENDOR.txt +17 -0
- package/compute-mode/preset/skills/runnpu/examples/01-dev-environment.sh +26 -0
- package/compute-mode/preset/skills/runnpu/examples/02-training-job.sh +26 -0
- package/compute-mode/preset/skills/runnpu/examples/03-inference-service.sh +24 -0
- package/compute-mode/preset/skills/runnpu/examples/04-fractional-vnpu.sh +14 -0
- package/compute-mode/preset/skills/runnpu/examples/05-troubleshoot.sh +17 -0
- package/compute-mode/preset/skills/runnpu/references/admin.md +60 -0
- package/compute-mode/preset/skills/runnpu/references/data-sources.md +35 -0
- package/compute-mode/preset/skills/runnpu/references/templates-assets.md +61 -0
- package/compute-mode/preset/skills/runnpu/references/training-runs.md +56 -0
- package/compute-mode/preset/skills/runnpu/references/troubleshooting.md +75 -0
- package/compute-mode/preset/skills/runnpu/references/workload-types.md +47 -0
- package/cordis.patch.yml +80 -0
- package/lib/client.js +189 -0
- package/lib/client.js.map +7 -0
- package/package.json +106 -0
- package/shared/config.mjs +496 -0
- package/shared/connectors.mjs +322 -0
- package/shared/http.mjs +179 -0
- package/shared/insights.mjs +250 -0
- package/shared/json.mjs +28 -0
- package/shared/nonce.mjs +109 -0
- package/shared/operations.mjs +427 -0
- package/shared/pins.mjs +570 -0
- package/shared/reports.mjs +187 -0
- package/shared/research.mjs +91 -0
- package/shared/runnpu.mjs +527 -0
- package/shared/series.mjs +208 -0
- package/shared/sse-host.mjs +172 -0
- package/shared/swanlab.mjs +523 -0
- package/shared/ui-host.mjs +21 -0
- package/shared/upload.mjs +928 -0
- package/shared/views.mjs +520 -0
- package/training-mode/node/index.mjs +750 -0
- package/training-mode/node/tools.mjs +1268 -0
- package/training-mode/preset/agent.cordis.yml +341 -0
- package/training-mode/preset/preset.yml +3 -0
- package/training-mode/preset/skills/runnpu/SKILL.md +233 -0
- package/training-mode/preset/skills/runnpu/VENDOR.txt +17 -0
- package/training-mode/preset/skills/runnpu/examples/01-dev-environment.sh +26 -0
- package/training-mode/preset/skills/runnpu/examples/02-training-job.sh +26 -0
- package/training-mode/preset/skills/runnpu/examples/03-inference-service.sh +24 -0
- package/training-mode/preset/skills/runnpu/examples/04-fractional-vnpu.sh +14 -0
- package/training-mode/preset/skills/runnpu/examples/05-troubleshoot.sh +17 -0
- package/training-mode/preset/skills/runnpu/references/admin.md +60 -0
- package/training-mode/preset/skills/runnpu/references/data-sources.md +35 -0
- package/training-mode/preset/skills/runnpu/references/templates-assets.md +61 -0
- package/training-mode/preset/skills/runnpu/references/training-runs.md +56 -0
- package/training-mode/preset/skills/runnpu/references/troubleshooting.md +75 -0
- package/training-mode/preset/skills/runnpu/references/workload-types.md +47 -0
- package/training-mode/preset/skills/swanlab-skill/LICENSE +21 -0
- package/training-mode/preset/skills/swanlab-skill/SKILL.md +163 -0
- package/training-mode/preset/skills/swanlab-skill/VENDOR.txt +9 -0
- package/training-mode/preset/skills/swanlab-skill/references/ANALYSIS_GUIDE.md +83 -0
- package/training-mode/preset/skills/swanlab-skill/references/CLI_REFERENCE.md +510 -0
- package/training-mode/preset/skills/swanlab-skill/references/SDK_QUICKSTART.md +341 -0
- package/training-mode/preset/skills/swanlab-skill/references/SWANLAB_CONCEPTS.md +523 -0
- package/training-mode/preset/skills/swanlab-skill/scripts/plot_metrics.py +353 -0
- package/training-mode/preset/skills/swanlab-skill/scripts/runs_benchmark.py +550 -0
- package/training-mode/preset/skills/training-research/SKILL.md +33 -0
- package/training-mode/preset/skills/training-research/references/pipeline.md +45 -0
- package/training-mode/preset/skills/training-research/references/research.md +76 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 EM-GeekLab
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# dsh-runnpu
|
|
2
|
+
|
|
3
|
+
Two agent modes for DeepSeek Harness over a RunNPU Ascend NPU cluster.
|
|
4
|
+
|
|
5
|
+
- **算力管理模式 (`compute`)** — a cluster console beside the conversation: connection
|
|
6
|
+
status + login form, and a pinned-chart column that keeps each chart's *query* and
|
|
7
|
+
refreshes it, not a screenshot.
|
|
8
|
+
- **训练模式 (`training`)** — the workspace's training project on the cluster with SwanLab
|
|
9
|
+
instrumentation: key-metric strip above the composer, a version card from `.train/`,
|
|
10
|
+
and SwanLab connection settings that follow the run into the container as env vars.
|
|
11
|
+
|
|
12
|
+
Each mode is an agent preset (`*/preset/`) carrying its own skills; the UI and the HTTP
|
|
13
|
+
surface ship as one plugin bundle mounted on the host plane.
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
Published on npm as [`dsh-runnpu`](https://www.npmjs.com/package/dsh-runnpu). In the dsh
|
|
18
|
+
profile you run (usually `~/.dsh/profiles/web`):
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
cd ~/.dsh/profiles/web
|
|
22
|
+
pnpm add dsh-runnpu
|
|
23
|
+
npx dsh-runnpu install all --profile web --with-cli # or: compute | training
|
|
24
|
+
pnpm install
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
`--with-cli` installs the host CLIs the mode drives — `runnpu` (the pinned release of
|
|
28
|
+
[EM-GeekLab/run-npu-skill](https://github.com/EM-GeekLab/run-npu-skill), sha256-checked, or
|
|
29
|
+
`--from <archive>` offline) and, for training, `swanlab` (via `uv tool` / `pipx` / `pip`). `npx dsh-runnpu doctor` checks them any time; the `runnpu`
|
|
30
|
+
build must match the vendored skill (`package.json` → `cliDeps`), because the skill
|
|
31
|
+
documents that binary's parameters.
|
|
32
|
+
|
|
33
|
+
then restart dsh and pick the mode in the New Session picker. `dsh-runnpu install`
|
|
34
|
+
copies the preset(s) into `~/.dsh/.agent-presets/{compute,training}` (they must be
|
|
35
|
+
real directories — npm cannot put them there) and `--profile` adds `"dsh-runnpu"` to
|
|
36
|
+
the profile's `dsh.profile.bundles`. `dsh-runnpu status` / `uninstall` round it out.
|
|
37
|
+
Host prerequisites: the `runnpu` CLI, and `swanlab` ≥ 0.9.0 for training mode.
|
|
38
|
+
Full guide: [`docs/RELEASE-INSTALL.md`](docs/RELEASE-INSTALL.md).
|
|
39
|
+
|
|
40
|
+
## Develop
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
npm install
|
|
44
|
+
npm run build # client/src → lib/client.js
|
|
45
|
+
node scripts/install-presets.mjs # = dsh-runnpu install all, from this checkout
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Development commands — all four run in CI (`.github/workflows/ci.yml`):
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
npm test # node:test over shared/ and bin/ — one case per bug that actually shipped
|
|
52
|
+
npm run lint # oxlint
|
|
53
|
+
npm run typecheck # tsc --checkJs over shared/'s JSDoc (jsconfig.json); no TypeScript in the repo
|
|
54
|
+
npm run build # the bundle, plus the guard that keeps node-only code out of it
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
To run the checkout instead of the npm release, point the profile at it:
|
|
58
|
+
|
|
59
|
+
```jsonc
|
|
60
|
+
"dependencies": { "dsh-runnpu": "file:/path/to/this/repo" },
|
|
61
|
+
"dsh": { "profile": { "bundles": ["@deepseek-ai/dsh-base", "@deepseek-ai/dsh-web-app", "dsh-runnpu"] } }
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
`pnpm install` in the profile, restart dsh. Releasing: `npm version` + `npm publish`
|
|
65
|
+
(`prepublishOnly` runs build / test / lint / typecheck), see the guide above.
|
|
66
|
+
|
|
67
|
+
## Skills
|
|
68
|
+
|
|
69
|
+
`skills/` is the canonical copy of the three skills the modes carry — `runnpu` (vendored
|
|
70
|
+
from run-npu-skill, same release as the pinned CLI), `swanlab-skill` (vendored from
|
|
71
|
+
SwanHubX/SwanLab-Skill) and `training-research` (ours). Each preset holds a byte-identical
|
|
72
|
+
copy that `node scripts/sync-skills.mjs` refreshes and `tests/skills-sync.test.mjs` pins,
|
|
73
|
+
so edit or re-vendor under `skills/` only. The same layout is what `npx skills add` and
|
|
74
|
+
skill hubs discover, for use outside dsh.
|
|
75
|
+
|
|
76
|
+
## Layout
|
|
77
|
+
|
|
78
|
+
| Path | Role |
|
|
79
|
+
|---|---|
|
|
80
|
+
| `shared/` | RunNPU REST client, SwanLab CLI wrapper, config/secret resolution, route helpers |
|
|
81
|
+
| `compute-mode/` | compute preset + node half (`/runnpu-api`) |
|
|
82
|
+
| `training-mode/` | training preset + node half (`/training-api`) |
|
|
83
|
+
| `client/src/` | the single browser bundle; both modes branch on the session's `agentPreset` |
|
|
84
|
+
| `skills/` | canonical skills; presets carry pinned copies (`scripts/sync-skills.mjs`) |
|
|
85
|
+
| `bin/` | the `dsh-runnpu` installer: presets, profile mounting, host CLIs |
|
|
86
|
+
| `cordis.patch.yml` | host-plane rows: connectors, the two node halves, the client roster row |
|
|
87
|
+
|
|
88
|
+
`compute-mode/CLAUDE.md` and `training-mode/CLAUDE.md` are the design contracts — read
|
|
89
|
+
them before changing anything; they record the harness constraints this was built against.
|
package/bin/cli-deps.mjs
ADDED
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two host CLIs the modes drive — `runnpu` (RunNPU control panel) and
|
|
3
|
+
* `swanlab` (SwanLab, Python) — are NOT npm packages, so this module is what
|
|
4
|
+
* `dsh-runnpu doctor` / `dsh-runnpu cli` use to check and install them.
|
|
5
|
+
*
|
|
6
|
+
* Pins live in package.json `cliDeps`:
|
|
7
|
+
* runnpu.version a release tag of EM-GeekLab/run-npu-skill (e.g.
|
|
8
|
+
* `v0.1.0-rc.1`). CLI and skill ship together there, so
|
|
9
|
+
* this is also the version of the vendored `runnpu`
|
|
10
|
+
* skill — see skills/runnpu/VENDOR.txt.
|
|
11
|
+
* runnpu.releaseBase `<repo>/releases/download`; under `<version>/` sit
|
|
12
|
+
* `runnpu_<ver>_<os>_<arch>.tar.gz|zip` (the binary at
|
|
13
|
+
* the archive root) and `checksums.txt` (sha256).
|
|
14
|
+
* swanlab.min the SDK/CLI floor the training mode was verified on.
|
|
15
|
+
*
|
|
16
|
+
* Everything that touches the network or the PATH takes its inputs as
|
|
17
|
+
* parameters (`env`, `fetchImpl`, `from`) so tests can drive it with stub
|
|
18
|
+
* executables and a local archive, never the real machine.
|
|
19
|
+
*
|
|
20
|
+
* @module dsh-runnpu/bin/cli-deps
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { execFile } from 'node:child_process'
|
|
24
|
+
import { createHash } from 'node:crypto'
|
|
25
|
+
import { access, chmod, copyFile, mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'
|
|
26
|
+
import { constants } from 'node:fs'
|
|
27
|
+
import { homedir, tmpdir } from 'node:os'
|
|
28
|
+
import { delimiter, dirname, join, resolve } from 'node:path'
|
|
29
|
+
import { fileURLToPath } from 'node:url'
|
|
30
|
+
import { promisify } from 'node:util'
|
|
31
|
+
|
|
32
|
+
const execFileAsync = promisify(execFile)
|
|
33
|
+
|
|
34
|
+
export const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
|
35
|
+
|
|
36
|
+
/** @typedef {{ version: string, releaseBase: string, tag?: string }} RunnpuPin */
|
|
37
|
+
/** @typedef {{ min: string, package: string }} SwanlabPin */
|
|
38
|
+
/** @typedef {{ runnpu: RunnpuPin, swanlab: SwanlabPin }} CliPins */
|
|
39
|
+
|
|
40
|
+
/** @returns {Promise<CliPins>} */
|
|
41
|
+
export async function readPins(root = packageRoot) {
|
|
42
|
+
const pkg = JSON.parse(await readFile(join(root, 'package.json'), 'utf8'))
|
|
43
|
+
const pins = pkg.cliDeps
|
|
44
|
+
if (!pins?.runnpu?.version || !pins?.swanlab?.min) throw new Error('package.json cliDeps is missing the runnpu / swanlab pins')
|
|
45
|
+
return pins
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ── platform ────────────────────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Map Node's platform/arch to the release's archive naming (`GOOS`/`GOARCH`
|
|
52
|
+
* + archive type, exactly what run-npu-skill's install.sh / install.ps1 use).
|
|
53
|
+
* @param {string} platform process.platform
|
|
54
|
+
* @param {string} arch process.arch
|
|
55
|
+
*/
|
|
56
|
+
export function platformKey(platform = process.platform, arch = process.arch) {
|
|
57
|
+
const os = { darwin: 'darwin', linux: 'linux', win32: 'windows' }[platform]
|
|
58
|
+
const goarch = { arm64: 'arm64', x64: 'amd64' }[arch]
|
|
59
|
+
if (!os || !goarch) throw new Error(`no runnpu build for ${platform}/${arch} (have darwin/linux/windows × arm64/amd64)`)
|
|
60
|
+
return { os, arch: goarch, ext: os === 'windows' ? 'zip' : 'tar.gz', exe: os === 'windows' ? '.exe' : '' }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** `v0.1.0-rc.1` → `0.1.0-rc.1`: the archive names carry the bare version, the tag the `v`. */
|
|
64
|
+
export function bareVersion(version) {
|
|
65
|
+
return String(version).replace(/^v/, '')
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** @param {string} version @param {ReturnType<typeof platformKey>} plat */
|
|
69
|
+
export function runnpuArchiveName(version, plat) {
|
|
70
|
+
return `runnpu_${bareVersion(version)}_${plat.os}_${plat.arch}.${plat.ext}`
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Directory of one release's assets. */
|
|
74
|
+
export function runnpuReleaseDir(pin) {
|
|
75
|
+
return `${pin.releaseBase.replace(/\/+$/, '')}/${pin.tag ?? pin.version}`
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** @param {RunnpuPin} pin @param {ReturnType<typeof platformKey>} plat */
|
|
79
|
+
export function runnpuDownloadUrl(pin, plat) {
|
|
80
|
+
return `${runnpuReleaseDir(pin)}/${runnpuArchiveName(pin.version, plat)}`
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** @param {RunnpuPin} pin */
|
|
84
|
+
export function runnpuChecksumsUrl(pin) {
|
|
85
|
+
return `${runnpuReleaseDir(pin)}/checksums.txt`
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Find `<sha256> <file>` for `file` in a checksums.txt; undefined when absent.
|
|
90
|
+
* @param {string} text
|
|
91
|
+
* @param {string} file
|
|
92
|
+
*/
|
|
93
|
+
export function checksumFor(text, file) {
|
|
94
|
+
for (const line of String(text).split('\n')) {
|
|
95
|
+
const match = line.trim().match(/^([0-9a-f]{64})\s+\*?(.+)$/i)
|
|
96
|
+
if (match && match[2].trim() === file) return match[1].toLowerCase()
|
|
97
|
+
}
|
|
98
|
+
return undefined
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ── versions ────────────────────────────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
/** `runnpu --version` prints `runnpu version <tag or git describe>`; returns that string. */
|
|
104
|
+
export function parseRunnpuVersion(stdout) {
|
|
105
|
+
const match = String(stdout).match(/runnpu version\s+(\S+)/)
|
|
106
|
+
return match ? match[1] : undefined
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Does an installed build satisfy the pin? The same version with or without
|
|
111
|
+
* the `v` prefix, optionally carrying a `-dirty` suffix (a local build of the
|
|
112
|
+
* pinned tag). Anything else — an older tag, a bare commit — is a mismatch,
|
|
113
|
+
* because the skill documents this exact CLI.
|
|
114
|
+
*/
|
|
115
|
+
export function runnpuVersionMatches(actual, pinned) {
|
|
116
|
+
if (!actual || !pinned) return false
|
|
117
|
+
const norm = v => bareVersion(String(v).replace(/-dirty$/, ''))
|
|
118
|
+
return norm(actual) === norm(pinned)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** `swanlab --version` prints `SwanLab 0.9.8`. */
|
|
122
|
+
export function parseSwanlabVersion(stdout) {
|
|
123
|
+
const match = String(stdout).match(/(\d+\.\d+\.\d+)/)
|
|
124
|
+
return match ? match[1] : undefined
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Numeric dotted compare; returns <0, 0, >0. */
|
|
128
|
+
export function compareVersions(a, b) {
|
|
129
|
+
const pa = String(a).split('.').map(Number)
|
|
130
|
+
const pb = String(b).split('.').map(Number)
|
|
131
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
132
|
+
const d = (pa[i] ?? 0) - (pb[i] ?? 0)
|
|
133
|
+
if (d !== 0) return d
|
|
134
|
+
}
|
|
135
|
+
return 0
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ── PATH probing ────────────────────────────────────────────────────────────
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Locate an executable on `env.PATH` without shelling out to `which`.
|
|
142
|
+
* @param {string} name
|
|
143
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
144
|
+
* @returns {Promise<string | undefined>}
|
|
145
|
+
*/
|
|
146
|
+
export async function which(name, env = process.env) {
|
|
147
|
+
const exts = process.platform === 'win32' ? (env.PATHEXT ?? '.EXE;.CMD;.BAT').split(';') : ['']
|
|
148
|
+
for (const dir of (env.PATH ?? '').split(delimiter).filter(Boolean)) {
|
|
149
|
+
for (const ext of exts) {
|
|
150
|
+
const candidate = join(dir, name + ext)
|
|
151
|
+
try {
|
|
152
|
+
await access(candidate, constants.X_OK)
|
|
153
|
+
if ((await stat(candidate)).isFile()) return candidate
|
|
154
|
+
} catch {
|
|
155
|
+
/* keep looking */
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return undefined
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Run `<cmd> <args>` and capture stdout+stderr; never throws. */
|
|
163
|
+
export async function probe(cmd, args, env = process.env) {
|
|
164
|
+
try {
|
|
165
|
+
const { stdout, stderr } = await execFileAsync(cmd, args, { env, timeout: 15_000 })
|
|
166
|
+
return { ok: true, output: `${stdout}${stderr}` }
|
|
167
|
+
} catch (error) {
|
|
168
|
+
const e = /** @type {any} */ (error)
|
|
169
|
+
return { ok: false, output: `${e.stdout ?? ''}${e.stderr ?? ''}${e.message ?? ''}` }
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ── doctor ──────────────────────────────────────────────────────────────────
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* @typedef {object} Finding
|
|
177
|
+
* @property {'runnpu' | 'swanlab'} name
|
|
178
|
+
* @property {string | undefined} path
|
|
179
|
+
* @property {string | undefined} version
|
|
180
|
+
* @property {string} want
|
|
181
|
+
* @property {boolean} ok
|
|
182
|
+
* @property {string} detail
|
|
183
|
+
*/
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Check both CLIs against the pins. Pure reporting — installs nothing.
|
|
187
|
+
* @param {CliPins} pins
|
|
188
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
189
|
+
* @returns {Promise<Finding[]>}
|
|
190
|
+
*/
|
|
191
|
+
export async function doctor(pins, env = process.env) {
|
|
192
|
+
/** @type {Finding[]} */
|
|
193
|
+
const findings = []
|
|
194
|
+
|
|
195
|
+
const runnpuPath = await which('runnpu', env)
|
|
196
|
+
const runnpuVersion = runnpuPath ? parseRunnpuVersion((await probe(runnpuPath, ['--version'], env)).output) : undefined
|
|
197
|
+
const runnpuOk = runnpuVersionMatches(runnpuVersion, pins.runnpu.version)
|
|
198
|
+
findings.push({
|
|
199
|
+
name: 'runnpu',
|
|
200
|
+
path: runnpuPath,
|
|
201
|
+
version: runnpuVersion,
|
|
202
|
+
want: pins.runnpu.version,
|
|
203
|
+
ok: runnpuOk,
|
|
204
|
+
detail: !runnpuPath
|
|
205
|
+
? 'not on PATH'
|
|
206
|
+
: !runnpuVersion
|
|
207
|
+
? '`runnpu --version` did not answer'
|
|
208
|
+
: runnpuOk
|
|
209
|
+
? 'matches the vendored runnpu skill'
|
|
210
|
+
: `build ${runnpuVersion} does not match the skill (${pins.runnpu.version}) — parameters may disagree`,
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
const swanlabPath = await which('swanlab', env)
|
|
214
|
+
const swanlabVersion = swanlabPath ? parseSwanlabVersion((await probe(swanlabPath, ['--version'], env)).output) : undefined
|
|
215
|
+
const swanlabOk = Boolean(swanlabVersion) && compareVersions(swanlabVersion, pins.swanlab.min) >= 0
|
|
216
|
+
findings.push({
|
|
217
|
+
name: 'swanlab',
|
|
218
|
+
path: swanlabPath,
|
|
219
|
+
version: swanlabVersion,
|
|
220
|
+
want: `>=${pins.swanlab.min}`,
|
|
221
|
+
ok: swanlabOk,
|
|
222
|
+
detail: !swanlabPath
|
|
223
|
+
? 'not on PATH (training mode needs it; compute mode does not)'
|
|
224
|
+
: !swanlabVersion
|
|
225
|
+
? '`swanlab --version` did not answer'
|
|
226
|
+
: swanlabOk
|
|
227
|
+
? 'ok'
|
|
228
|
+
: `${swanlabVersion} is below ${pins.swanlab.min} — \`swanlab api\` output contract differs`,
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
return findings
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// ── runnpu install ──────────────────────────────────────────────────────────
|
|
235
|
+
|
|
236
|
+
/** Default install dir — same as the control panel's own install.sh. */
|
|
237
|
+
export function defaultBinDir(env = process.env) {
|
|
238
|
+
return env.RUNNPU_BIN_DIR || join(homedir(), '.local', 'bin')
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Fetch `url` as bytes. Sends a GitHub token when one is in the environment
|
|
243
|
+
* (a private mirror's release assets need it; the public repo ignores it).
|
|
244
|
+
*/
|
|
245
|
+
async function fetchBytes(url, env, fetchImpl) {
|
|
246
|
+
const headers = { 'user-agent': 'dsh-runnpu' }
|
|
247
|
+
const token = env.GITHUB_TOKEN || env.GH_TOKEN
|
|
248
|
+
if (token && /github\.com|githubusercontent\.com/.test(url)) headers.authorization = `Bearer ${token}`
|
|
249
|
+
const response = await fetchImpl(url, { headers, redirect: 'follow' })
|
|
250
|
+
if (!response.ok) throw new Error(`download failed: ${response.status} ${response.statusText} — ${url}`)
|
|
251
|
+
return Buffer.from(await response.arrayBuffer())
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** First `runnpu` / `runnpu.exe` found under `dir` (the archive nests it as `<name>/bin/runnpu`). */
|
|
255
|
+
async function findBinary(dir, exe) {
|
|
256
|
+
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
|
257
|
+
const full = join(dir, entry.name)
|
|
258
|
+
if (entry.isDirectory()) {
|
|
259
|
+
const found = await findBinary(full, exe)
|
|
260
|
+
if (found) return found
|
|
261
|
+
} else if (entry.name === `runnpu${exe}`) return full
|
|
262
|
+
}
|
|
263
|
+
return undefined
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Install the pinned `runnpu` build into `binDir`.
|
|
268
|
+
*
|
|
269
|
+
* Source resolution, in order: `from` (a local archive path, a local
|
|
270
|
+
* directory holding an extracted package, or a URL), else the pin's release
|
|
271
|
+
* URL for this platform — and in that default case the archive is checked
|
|
272
|
+
* against the release's checksums.txt before it is opened. Extraction shells
|
|
273
|
+
* out to `tar`, which reads both .tar.gz and (bsdtar on macOS / Windows 10+)
|
|
274
|
+
* .zip; the binary sits at the archive root.
|
|
275
|
+
*
|
|
276
|
+
* @param {object} options
|
|
277
|
+
* @param {RunnpuPin} options.pin
|
|
278
|
+
* @param {string} [options.from]
|
|
279
|
+
* @param {string} [options.binDir]
|
|
280
|
+
* @param {NodeJS.ProcessEnv} [options.env]
|
|
281
|
+
* @param {typeof fetch} [options.fetchImpl]
|
|
282
|
+
* @param {ReturnType<typeof platformKey>} [options.plat]
|
|
283
|
+
* @returns {Promise<{ path: string, version: string | undefined, source: string }>}
|
|
284
|
+
*/
|
|
285
|
+
export async function installRunnpu({ pin, from, binDir, env = process.env, fetchImpl = fetch, plat = platformKey() }) {
|
|
286
|
+
const dir = binDir ?? defaultBinDir(env)
|
|
287
|
+
const work = await mkdtemp(join(tmpdir(), 'dsh-runnpu-cli-'))
|
|
288
|
+
try {
|
|
289
|
+
let source = from ?? runnpuDownloadUrl(pin, plat)
|
|
290
|
+
let archive
|
|
291
|
+
if (/^https?:\/\//.test(source)) {
|
|
292
|
+
const name = runnpuArchiveName(pin.version, plat)
|
|
293
|
+
archive = join(work, name)
|
|
294
|
+
const bytes = await fetchBytes(source, env, fetchImpl)
|
|
295
|
+
if (!from) {
|
|
296
|
+
// Only the pinned release has a checksums.txt we know the shape of; a
|
|
297
|
+
// user-supplied URL is trusted the way a user-supplied file is.
|
|
298
|
+
const sums = (await fetchBytes(runnpuChecksumsUrl(pin), env, fetchImpl)).toString('utf8')
|
|
299
|
+
const want = checksumFor(sums, name)
|
|
300
|
+
if (!want) throw new Error(`checksums.txt of ${pin.version} has no entry for ${name}`)
|
|
301
|
+
const got = createHash('sha256').update(bytes).digest('hex')
|
|
302
|
+
if (got !== want) throw new Error(`sha256 mismatch for ${name}: got ${got}, checksums.txt says ${want}`)
|
|
303
|
+
}
|
|
304
|
+
await writeFile(archive, bytes)
|
|
305
|
+
} else {
|
|
306
|
+
source = resolve(source)
|
|
307
|
+
const info = await stat(source)
|
|
308
|
+
archive = info.isDirectory() ? undefined : source
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
let extracted = source
|
|
312
|
+
if (archive) {
|
|
313
|
+
extracted = join(work, 'x')
|
|
314
|
+
await mkdir(extracted)
|
|
315
|
+
await execFileAsync('tar', ['-xf', archive, '-C', extracted])
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const binary = await findBinary(extracted, plat.exe)
|
|
319
|
+
if (!binary) throw new Error(`no runnpu${plat.exe} binary inside ${source}`)
|
|
320
|
+
|
|
321
|
+
await mkdir(dir, { recursive: true })
|
|
322
|
+
const target = join(dir, `runnpu${plat.exe}`)
|
|
323
|
+
await copyFile(binary, target)
|
|
324
|
+
await chmod(target, 0o755)
|
|
325
|
+
|
|
326
|
+
const version = parseRunnpuVersion((await probe(target, ['--version'], env)).output)
|
|
327
|
+
if (version && !runnpuVersionMatches(version, pin.version)) {
|
|
328
|
+
throw new Error(`installed ${target} reports ${version}, expected ${pin.version} — wrong archive for this skill`)
|
|
329
|
+
}
|
|
330
|
+
return { path: target, version, source }
|
|
331
|
+
} finally {
|
|
332
|
+
await rm(work, { recursive: true, force: true })
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** Is `dir` on `env.PATH`? */
|
|
337
|
+
export function onPath(dir, env = process.env) {
|
|
338
|
+
const want = resolve(dir)
|
|
339
|
+
return (env.PATH ?? '')
|
|
340
|
+
.split(delimiter)
|
|
341
|
+
.filter(Boolean)
|
|
342
|
+
.some(entry => resolve(entry) === want)
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// ── swanlab install ─────────────────────────────────────────────────────────
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Pick how to install `swanlab` on this machine: an isolated tool install
|
|
349
|
+
* when `uv` or `pipx` is present (no PEP 668 fight with the system Python),
|
|
350
|
+
* else `python -m pip install --user`. `python` forces the interpreter, which
|
|
351
|
+
* is also how a specific venv or Homebrew python is targeted.
|
|
352
|
+
*
|
|
353
|
+
* @param {object} options
|
|
354
|
+
* @param {SwanlabPin} options.pin
|
|
355
|
+
* @param {(name: string) => Promise<string | undefined>} options.which
|
|
356
|
+
* @param {string} [options.python]
|
|
357
|
+
* @returns {Promise<{ tool: 'python' | 'uv' | 'pipx', argv: string[] }>}
|
|
358
|
+
*/
|
|
359
|
+
export async function planSwanlabInstall({ pin, which: lookup, python }) {
|
|
360
|
+
const spec = `${pin.package}>=${pin.min}`
|
|
361
|
+
if (python) return { tool: 'python', argv: [python, '-m', 'pip', 'install', '--upgrade', spec] }
|
|
362
|
+
const uv = await lookup('uv')
|
|
363
|
+
if (uv) return { tool: 'uv', argv: [uv, 'tool', 'install', '--upgrade', spec] }
|
|
364
|
+
const pipx = await lookup('pipx')
|
|
365
|
+
if (pipx) return { tool: 'pipx', argv: [pipx, 'install', '--force', spec] }
|
|
366
|
+
const py = (await lookup('python3')) ?? (await lookup('python'))
|
|
367
|
+
if (!py) throw new Error('no python3 on PATH — install Python ≥ 3.9 (or uv / pipx) first, then re-run')
|
|
368
|
+
return { tool: 'python', argv: [py, '-m', 'pip', 'install', '--user', '--upgrade', spec] }
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Install or upgrade `swanlab`. Returns the plan that ran and its output.
|
|
373
|
+
* @param {object} options
|
|
374
|
+
* @param {SwanlabPin} options.pin
|
|
375
|
+
* @param {NodeJS.ProcessEnv} [options.env]
|
|
376
|
+
* @param {string} [options.python]
|
|
377
|
+
* @param {boolean} [options.dryRun]
|
|
378
|
+
*/
|
|
379
|
+
export async function installSwanlab({ pin, env = process.env, python, dryRun = false }) {
|
|
380
|
+
const plan = await planSwanlabInstall({ pin, which: name => which(name, env), python })
|
|
381
|
+
if (dryRun) return { plan, ok: true, output: '' }
|
|
382
|
+
const result = await probe(plan.argv[0], plan.argv.slice(1), env)
|
|
383
|
+
return { plan, ...result }
|
|
384
|
+
}
|