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.

Files changed (72) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +89 -0
  3. package/bin/cli-deps.mjs +384 -0
  4. package/bin/dsh-runnpu.mjs +255 -0
  5. package/bin/presets.mjs +167 -0
  6. package/compute-mode/node/index.mjs +527 -0
  7. package/compute-mode/node/tools.mjs +1440 -0
  8. package/compute-mode/preset/agent.cordis.yml +366 -0
  9. package/compute-mode/preset/preset.yml +3 -0
  10. package/compute-mode/preset/skills/runnpu/SKILL.md +233 -0
  11. package/compute-mode/preset/skills/runnpu/VENDOR.txt +17 -0
  12. package/compute-mode/preset/skills/runnpu/examples/01-dev-environment.sh +26 -0
  13. package/compute-mode/preset/skills/runnpu/examples/02-training-job.sh +26 -0
  14. package/compute-mode/preset/skills/runnpu/examples/03-inference-service.sh +24 -0
  15. package/compute-mode/preset/skills/runnpu/examples/04-fractional-vnpu.sh +14 -0
  16. package/compute-mode/preset/skills/runnpu/examples/05-troubleshoot.sh +17 -0
  17. package/compute-mode/preset/skills/runnpu/references/admin.md +60 -0
  18. package/compute-mode/preset/skills/runnpu/references/data-sources.md +35 -0
  19. package/compute-mode/preset/skills/runnpu/references/templates-assets.md +61 -0
  20. package/compute-mode/preset/skills/runnpu/references/training-runs.md +56 -0
  21. package/compute-mode/preset/skills/runnpu/references/troubleshooting.md +75 -0
  22. package/compute-mode/preset/skills/runnpu/references/workload-types.md +47 -0
  23. package/cordis.patch.yml +80 -0
  24. package/lib/client.js +189 -0
  25. package/lib/client.js.map +7 -0
  26. package/package.json +106 -0
  27. package/shared/config.mjs +496 -0
  28. package/shared/connectors.mjs +322 -0
  29. package/shared/http.mjs +179 -0
  30. package/shared/insights.mjs +250 -0
  31. package/shared/json.mjs +28 -0
  32. package/shared/nonce.mjs +109 -0
  33. package/shared/operations.mjs +427 -0
  34. package/shared/pins.mjs +570 -0
  35. package/shared/reports.mjs +187 -0
  36. package/shared/research.mjs +91 -0
  37. package/shared/runnpu.mjs +527 -0
  38. package/shared/series.mjs +208 -0
  39. package/shared/sse-host.mjs +172 -0
  40. package/shared/swanlab.mjs +523 -0
  41. package/shared/ui-host.mjs +21 -0
  42. package/shared/upload.mjs +928 -0
  43. package/shared/views.mjs +520 -0
  44. package/training-mode/node/index.mjs +750 -0
  45. package/training-mode/node/tools.mjs +1268 -0
  46. package/training-mode/preset/agent.cordis.yml +341 -0
  47. package/training-mode/preset/preset.yml +3 -0
  48. package/training-mode/preset/skills/runnpu/SKILL.md +233 -0
  49. package/training-mode/preset/skills/runnpu/VENDOR.txt +17 -0
  50. package/training-mode/preset/skills/runnpu/examples/01-dev-environment.sh +26 -0
  51. package/training-mode/preset/skills/runnpu/examples/02-training-job.sh +26 -0
  52. package/training-mode/preset/skills/runnpu/examples/03-inference-service.sh +24 -0
  53. package/training-mode/preset/skills/runnpu/examples/04-fractional-vnpu.sh +14 -0
  54. package/training-mode/preset/skills/runnpu/examples/05-troubleshoot.sh +17 -0
  55. package/training-mode/preset/skills/runnpu/references/admin.md +60 -0
  56. package/training-mode/preset/skills/runnpu/references/data-sources.md +35 -0
  57. package/training-mode/preset/skills/runnpu/references/templates-assets.md +61 -0
  58. package/training-mode/preset/skills/runnpu/references/training-runs.md +56 -0
  59. package/training-mode/preset/skills/runnpu/references/troubleshooting.md +75 -0
  60. package/training-mode/preset/skills/runnpu/references/workload-types.md +47 -0
  61. package/training-mode/preset/skills/swanlab-skill/LICENSE +21 -0
  62. package/training-mode/preset/skills/swanlab-skill/SKILL.md +163 -0
  63. package/training-mode/preset/skills/swanlab-skill/VENDOR.txt +9 -0
  64. package/training-mode/preset/skills/swanlab-skill/references/ANALYSIS_GUIDE.md +83 -0
  65. package/training-mode/preset/skills/swanlab-skill/references/CLI_REFERENCE.md +510 -0
  66. package/training-mode/preset/skills/swanlab-skill/references/SDK_QUICKSTART.md +341 -0
  67. package/training-mode/preset/skills/swanlab-skill/references/SWANLAB_CONCEPTS.md +523 -0
  68. package/training-mode/preset/skills/swanlab-skill/scripts/plot_metrics.py +353 -0
  69. package/training-mode/preset/skills/swanlab-skill/scripts/runs_benchmark.py +550 -0
  70. package/training-mode/preset/skills/training-research/SKILL.md +33 -0
  71. package/training-mode/preset/skills/training-research/references/pipeline.md +45 -0
  72. package/training-mode/preset/skills/training-research/references/research.md +76 -0
@@ -0,0 +1,255 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `dsh-runnpu` — the parts of installing this package that npm cannot do:
4
+ * the agent presets (real directories under $DSH_HOME/.agent-presets) and the
5
+ * two host CLIs the modes drive.
6
+ *
7
+ * dsh-runnpu install <compute|training|all> [--force] [--profile <name|dir>] [--with-cli]
8
+ * dsh-runnpu uninstall <compute|training|all> [--force]
9
+ * dsh-runnpu status
10
+ * dsh-runnpu doctor
11
+ * dsh-runnpu cli <runnpu|swanlab|all> [--from <archive|dir|url>] [--bin-dir <dir>] [--python <exe>] [--dry-run]
12
+ *
13
+ * `--profile web` additionally adds "dsh-runnpu" to that profile's
14
+ * `dsh.profile.bundles` (a name resolves under $DSH_HOME/profiles/, a path is
15
+ * used as is). The dependency itself is `pnpm add dsh-runnpu`'s job.
16
+ *
17
+ * `--with-cli` runs `cli` for what the mode needs after the presets:
18
+ * compute → runnpu; training → runnpu + swanlab. `doctor` only reports.
19
+ *
20
+ * DSH_HOME overrides ~/.dsh. RUNNPU_BIN_DIR overrides ~/.local/bin.
21
+ * GITHUB_TOKEN / GH_TOKEN is sent when downloading from a private GitHub mirror.
22
+ */
23
+
24
+ import { delimiter, join } from 'node:path'
25
+
26
+ import {
27
+ defaultBinDir,
28
+ doctor,
29
+ installRunnpu,
30
+ installSwanlab,
31
+ onPath,
32
+ platformKey,
33
+ readPins,
34
+ runnpuDownloadUrl,
35
+ runnpuReleaseDir,
36
+ } from './cli-deps.mjs'
37
+ import {
38
+ MODES,
39
+ PACKAGE_NAME,
40
+ dshHome,
41
+ inspectTarget,
42
+ installPreset,
43
+ mountInProfile,
44
+ presetRoot,
45
+ profileDir,
46
+ selectModes,
47
+ uninstallPreset,
48
+ } from './presets.mjs'
49
+
50
+ const USAGE = `usage:
51
+ ${PACKAGE_NAME} install <compute|training|all> [--force] [--link] [--profile <name|dir>] [--with-cli]
52
+ ${PACKAGE_NAME} uninstall <compute|training|all> [--force]
53
+ ${PACKAGE_NAME} status
54
+ ${PACKAGE_NAME} doctor
55
+ ${PACKAGE_NAME} cli <runnpu|swanlab|all> [--from <archive|dir|url>] [--bin-dir <dir>] [--python <exe>] [--dry-run]
56
+
57
+ install copy the preset(s) into $DSH_HOME/.agent-presets/<id> (DSH_HOME defaults to ~/.dsh)
58
+ --force replace / remove a preset directory this package did not install
59
+ --profile also add "${PACKAGE_NAME}" to <profile>/package.json → dsh.profile.bundles
60
+ --with-cli then install the host CLIs the mode needs (compute: runnpu; training: runnpu + swanlab)
61
+ --link dev only: symlink instead of copy (a symlinked preset is NOT discovered by dsh)
62
+ doctor check runnpu / swanlab on PATH against the versions this package was built for
63
+ cli install runnpu (pinned build, from the release or --from) and/or swanlab (uv > pipx > pip)
64
+ --from runnpu source: a local runnpu_<ver>_<os>_<arch>.tar.gz/.zip, an extracted directory, or a URL
65
+ --bin-dir where runnpu goes (default $RUNNPU_BIN_DIR or ~/.local/bin)
66
+ --python interpreter for swanlab (default: uv tool, else pipx, else python3 -m pip --user)
67
+ `
68
+
69
+ /** @param {string[]} argv */
70
+ function parse(argv) {
71
+ const flags = {
72
+ force: false,
73
+ link: false,
74
+ withCli: false,
75
+ dryRun: false,
76
+ profile: /** @type {string | undefined} */ (undefined),
77
+ from: /** @type {string | undefined} */ (undefined),
78
+ binDir: /** @type {string | undefined} */ (undefined),
79
+ python: /** @type {string | undefined} */ (undefined),
80
+ }
81
+ const valued = { '--profile': 'profile', '--from': 'from', '--bin-dir': 'binDir', '--python': 'python' }
82
+ const positional = []
83
+ for (let i = 0; i < argv.length; i++) {
84
+ const arg = argv[i]
85
+ const eq = arg.indexOf('=')
86
+ const key = eq > 0 ? arg.slice(0, eq) : arg
87
+ if (key in valued) {
88
+ const value = eq > 0 ? arg.slice(eq + 1) : argv[++i]
89
+ if (!value) throw new Error(`${key} needs a value`)
90
+ flags[valued[key]] = value
91
+ } else if (arg === '--force') flags.force = true
92
+ else if (arg === '--link') flags.link = true
93
+ else if (arg === '--with-cli') flags.withCli = true
94
+ else if (arg === '--dry-run') flags.dryRun = true
95
+ else if (arg === '-h' || arg === '--help') positional.push('help')
96
+ else if (arg.startsWith('-')) throw new Error(`unknown flag ${arg}`)
97
+ else positional.push(arg)
98
+ }
99
+ return { command: positional[0], selector: positional[1], flags }
100
+ }
101
+
102
+ /** @param {string} home */
103
+ async function status(home) {
104
+ console.log(`preset root: ${presetRoot(home)}`)
105
+ for (const mode of MODES) {
106
+ const target = join(presetRoot(home), mode.id)
107
+ const state = await inspectTarget(target, mode.from)
108
+ const label = { absent: 'not installed', ours: 'installed', 'our-link': 'symlinked (dev only, not discovered)', foreign: 'present, not ours' }[state]
109
+ console.log(` ${mode.id.padEnd(9)} ${mode.display} ${label}`)
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Print the doctor report; returns whether everything checked out.
115
+ * @param {NodeJS.ProcessEnv} [env] PATH to probe (default: this process's)
116
+ */
117
+ async function runDoctor(env = process.env) {
118
+ const pins = await readPins()
119
+ const findings = await doctor(pins, env)
120
+ for (const f of findings) {
121
+ const mark = f.ok ? '✓' : '✗'
122
+ const where = f.path ? `${f.path}` : '(not found)'
123
+ console.log(`${mark} ${f.name.padEnd(8)} ${(f.version ?? '-').padEnd(16)} want ${f.want.padEnd(10)} ${where}`)
124
+ if (!f.ok) console.log(` ${f.detail}`)
125
+ }
126
+ const bad = findings.filter(f => !f.ok)
127
+ if (bad.length) {
128
+ console.log(`\nfix: ${PACKAGE_NAME} cli ${bad.length === 2 ? 'all' : bad[0].name}`)
129
+ }
130
+ return bad.length === 0
131
+ }
132
+
133
+ /**
134
+ * @param {('runnpu' | 'swanlab')[]} which
135
+ * @param {ReturnType<typeof parse>['flags']} flags
136
+ */
137
+ async function runCli(which, flags) {
138
+ const pins = await readPins()
139
+ // The verification pass after installing looks in the install dir first, so
140
+ // it reports the binary just installed even when that dir is not on PATH yet.
141
+ let checkEnv = process.env
142
+ if (which.includes('runnpu')) {
143
+ const plat = platformKey()
144
+ const binDir = flags.binDir ?? defaultBinDir()
145
+ checkEnv = { ...process.env, PATH: [binDir, process.env.PATH ?? ''].filter(Boolean).join(delimiter) }
146
+ const source = flags.from ?? runnpuDownloadUrl(pins.runnpu, plat)
147
+ if (flags.dryRun) {
148
+ console.log(`runnpu: would install build ${pins.runnpu.version} from ${source} → ${join(binDir, `runnpu${plat.exe}`)}`)
149
+ } else {
150
+ console.log(`runnpu: installing build ${pins.runnpu.version} (${plat.os}/${plat.arch}) from ${source}`)
151
+ try {
152
+ const result = await installRunnpu({ pin: pins.runnpu, from: flags.from, binDir, plat })
153
+ console.log(`✓ runnpu ${result.version ?? ''} → ${result.path}`)
154
+ } catch (error) {
155
+ const message = error instanceof Error ? error.message : String(error)
156
+ throw new Error(
157
+ `${message}\n` +
158
+ ` The runnpu build must match the vendored skill (${pins.runnpu.version}). If the release asset is not\n` +
159
+ ` reachable (offline cluster → use the archive the RunNPU console ships, a mirror → its URL),\n` +
160
+ ` pass it with --from <archive|dir|url>. Release: ${runnpuReleaseDir(pins.runnpu)}`,
161
+ )
162
+ }
163
+ if (!onPath(binDir)) {
164
+ console.log(`\n⚠ ${binDir} is not on PATH — add it, e.g.\n fish: fish_add_path ${binDir}\n bash/zsh: echo 'export PATH="${binDir}:$PATH"' >> ~/.profile`)
165
+ }
166
+ }
167
+ }
168
+ if (which.includes('swanlab')) {
169
+ const result = await installSwanlab({ pin: pins.swanlab, python: flags.python, dryRun: flags.dryRun })
170
+ const cmd = result.plan.argv.join(' ')
171
+ if (flags.dryRun) console.log(`swanlab: would run (${result.plan.tool}): ${cmd}`)
172
+ else if (result.ok) console.log(`✓ swanlab installed via ${result.plan.tool}: ${cmd}`)
173
+ else throw new Error(`swanlab install failed (${cmd}):\n${result.output}`)
174
+ }
175
+ if (!flags.dryRun) {
176
+ console.log('')
177
+ await runDoctor(checkEnv)
178
+ }
179
+ }
180
+
181
+ async function main() {
182
+ const { command, selector, flags } = parse(process.argv.slice(2))
183
+ const home = dshHome()
184
+
185
+ if (!command || command === 'help') {
186
+ console.log(USAGE)
187
+ return
188
+ }
189
+ if (command === 'status') {
190
+ await status(home)
191
+ return
192
+ }
193
+ if (command === 'doctor') {
194
+ process.exitCode = (await runDoctor()) ? 0 : 1
195
+ return
196
+ }
197
+ if (command === 'cli') {
198
+ const which = selector === 'all' ? ['runnpu', 'swanlab'] : selector === 'runnpu' || selector === 'swanlab' ? [selector] : undefined
199
+ if (!which) throw new Error(`cli: expected runnpu | swanlab | all, got ${JSON.stringify(selector ?? '')}`)
200
+ await runCli(/** @type {('runnpu' | 'swanlab')[]} */ (which), flags)
201
+ return
202
+ }
203
+ if (command !== 'install' && command !== 'uninstall') throw new Error(`unknown command ${command}\n${USAGE}`)
204
+
205
+ const modes = selectModes(selector)
206
+
207
+ if (command === 'uninstall') {
208
+ for (const mode of modes) {
209
+ const result = await uninstallPreset(mode, { home, force: flags.force })
210
+ console.log(`${result === 'removed' ? 'removed' : 'not installed'}: ${mode.id} (${mode.display})`)
211
+ }
212
+ return
213
+ }
214
+
215
+ for (const mode of modes) {
216
+ const target = await installPreset(mode, { home, force: flags.force, link: flags.link })
217
+ console.log(`installed ${mode.id} (${mode.display}) → ${target}${flags.link ? ' (symlink, dev only)' : ''}`)
218
+ }
219
+
220
+ if (flags.profile) {
221
+ const dir = profileDir(flags.profile, home)
222
+ const changed = await mountInProfile(dir)
223
+ console.log(`${changed ? 'added' : 'already in'} dsh.profile.bundles: ${join(dir, 'package.json')}`)
224
+ }
225
+
226
+ if (flags.withCli) {
227
+ const needSwanlab = modes.some(mode => mode.id === 'training')
228
+ console.log('')
229
+ await runCli(needSwanlab ? ['runnpu', 'swanlab'] : ['runnpu'], flags)
230
+ }
231
+
232
+ if (flags.profile) {
233
+ const dir = profileDir(flags.profile, home)
234
+ console.log(`
235
+ Next:
236
+ cd ${dir} && pnpm install # if "${PACKAGE_NAME}" is not yet in dependencies: pnpm add ${PACKAGE_NAME}
237
+ restart dsh, then start a NEW session and pick the mode in the picker.${flags.withCli ? '' : `\n host CLIs: ${PACKAGE_NAME} doctor (install with: ${PACKAGE_NAME} cli all)`}
238
+ `)
239
+ return
240
+ }
241
+
242
+ console.log(`
243
+ Next, mount the plugin bundle in the dsh profile you run (usually $DSH_HOME/profiles/web):
244
+
245
+ 1. dependencies: pnpm add ${PACKAGE_NAME} (already done if you are running this via npx from that profile)
246
+ 2. dsh.profile.bundles: add "${PACKAGE_NAME}" (or re-run with --profile <name|dir>)
247
+ 3. host CLIs: ${PACKAGE_NAME} doctor (install with: ${PACKAGE_NAME} cli all)
248
+ 4. restart dsh, then start a NEW session and pick the mode in the picker.
249
+ `)
250
+ }
251
+
252
+ main().catch(error => {
253
+ console.error(`${PACKAGE_NAME}: ${error instanceof Error ? error.message : String(error)}`)
254
+ process.exit(1)
255
+ })
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Preset installation — the logic behind the `dsh-runnpu` bin and
3
+ * `scripts/install-presets.mjs`.
4
+ *
5
+ * A dsh agent preset lives at `<dshHome>/.agent-presets/<id>` and MUST be a
6
+ * real directory: a symlinked preset is silently not discovered by the roster
7
+ * (the picker just lists the shipped presets, no error anywhere). npm cannot
8
+ * put files there, which is the whole reason this installer exists.
9
+ *
10
+ * The preset yml cannot carry an absolute skill path from the author's
11
+ * machine, so it carries `__DSH_PRESET_ROOT__` and the real root is resolved
12
+ * here, on the machine the preset is installed onto.
13
+ *
14
+ * @module dsh-runnpu/bin/presets
15
+ */
16
+
17
+ import { cp, lstat, mkdir, readFile, readlink, rm, symlink, writeFile } from 'node:fs/promises'
18
+ import { existsSync } from 'node:fs'
19
+ import { homedir } from 'node:os'
20
+ import { dirname, isAbsolute, join, resolve } from 'node:path'
21
+ import { fileURLToPath } from 'node:url'
22
+
23
+ export const PACKAGE_NAME = 'dsh-runnpu'
24
+
25
+ /** Repo / installed-package root: this file sits in `<root>/bin/`. */
26
+ export const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..')
27
+
28
+ /** @typedef {{ id: 'compute' | 'training', display: string, from: string }} Mode */
29
+
30
+ /** @type {readonly Mode[]} */
31
+ export const MODES = Object.freeze([
32
+ { id: 'compute', display: '算力管理模式', from: join(packageRoot, 'compute-mode', 'preset') },
33
+ { id: 'training', display: '训练模式', from: join(packageRoot, 'training-mode', 'preset') },
34
+ ])
35
+
36
+ /** Marks a copy as ours, so a re-install replaces it without --force. */
37
+ export const MARKER = `.installed-by-${PACKAGE_NAME}`
38
+ /** Markers written by earlier releases of this package, still recognised as ours. */
39
+ const LEGACY_MARKERS = ['.installed-by-dsh-runnpu-modes']
40
+
41
+ export const PLACEHOLDER = '__DSH_PRESET_ROOT__'
42
+
43
+ /** @param {NodeJS.ProcessEnv} [env] */
44
+ export function dshHome(env = process.env) {
45
+ return env.DSH_HOME || join(homedir(), '.dsh')
46
+ }
47
+
48
+ /** @param {string} home */
49
+ export function presetRoot(home) {
50
+ return join(home, '.agent-presets')
51
+ }
52
+
53
+ /**
54
+ * Resolve `compute` / `training` / `all` to modes; throws on anything else.
55
+ * @param {string | undefined} selector
56
+ * @returns {Mode[]}
57
+ */
58
+ export function selectModes(selector) {
59
+ if (selector === 'all') return [...MODES]
60
+ const mode = MODES.find(m => m.id === selector)
61
+ if (!mode) {
62
+ throw new Error(`unknown mode ${JSON.stringify(selector ?? '')}: expected ${MODES.map(m => m.id).join(' | ')} | all`)
63
+ }
64
+ return [mode]
65
+ }
66
+
67
+ /**
68
+ * What sits at the preset's target path, if anything.
69
+ * @param {string} target
70
+ * @param {string} from
71
+ * @returns {Promise<'absent' | 'ours' | 'our-link' | 'foreign'>}
72
+ */
73
+ export async function inspectTarget(target, from) {
74
+ let existing
75
+ try {
76
+ existing = await lstat(target)
77
+ } catch {
78
+ return 'absent'
79
+ }
80
+ if (existing.isSymbolicLink()) return (await readlink(target)) === from ? 'our-link' : 'foreign'
81
+ if ([MARKER, ...LEGACY_MARKERS].some(marker => existsSync(join(target, marker)))) return 'ours'
82
+ return 'foreign'
83
+ }
84
+
85
+ /**
86
+ * Install one mode's preset. Returns the target path.
87
+ *
88
+ * `link` symlinks instead of copying — dev-only: the roster does NOT discover
89
+ * a symlinked preset, and the placeholder is served verbatim. Kept for
90
+ * experiments, never for a working install.
91
+ *
92
+ * @param {Mode} mode
93
+ * @param {{ home?: string, force?: boolean, link?: boolean }} [options]
94
+ */
95
+ export async function installPreset(mode, { home = dshHome(), force = false, link = false } = {}) {
96
+ const root = presetRoot(home)
97
+ const target = join(root, mode.id)
98
+ await mkdir(root, { recursive: true })
99
+
100
+ const state = await inspectTarget(target, mode.from)
101
+ if (state === 'foreign' && !force) {
102
+ throw new Error(
103
+ `refusing to replace ${target}: it exists and was not installed by ${PACKAGE_NAME} (pass --force to replace)`,
104
+ )
105
+ }
106
+ if (state !== 'absent') await rm(target, { recursive: true, force: true })
107
+
108
+ if (link) {
109
+ await symlink(mode.from, target, 'dir')
110
+ return target
111
+ }
112
+
113
+ await cp(mode.from, target, { recursive: true, filter: src => !src.endsWith('.DS_Store') })
114
+ const cordis = join(target, 'agent.cordis.yml')
115
+ await writeFile(cordis, (await readFile(cordis, 'utf8')).replaceAll(PLACEHOLDER, root), 'utf8')
116
+ await writeFile(join(target, MARKER), `installed from ${mode.from}\n`, 'utf8')
117
+ return target
118
+ }
119
+
120
+ /**
121
+ * Remove one mode's preset. Only removes what we installed unless forced.
122
+ * @param {Mode} mode
123
+ * @param {{ home?: string, force?: boolean }} [options]
124
+ * @returns {Promise<'removed' | 'absent'>}
125
+ */
126
+ export async function uninstallPreset(mode, { home = dshHome(), force = false } = {}) {
127
+ const target = join(presetRoot(home), mode.id)
128
+ const state = await inspectTarget(target, mode.from)
129
+ if (state === 'absent') return 'absent'
130
+ if (state === 'foreign' && !force) {
131
+ throw new Error(`refusing to remove ${target}: it was not installed by ${PACKAGE_NAME} (pass --force to remove)`)
132
+ }
133
+ await rm(target, { recursive: true, force: true })
134
+ return 'removed'
135
+ }
136
+
137
+ /**
138
+ * Resolve `--profile web` (a name under `<dshHome>/profiles/`) or a path to
139
+ * the profile directory.
140
+ * @param {string} profile
141
+ * @param {string} home
142
+ */
143
+ export function profileDir(profile, home) {
144
+ if (isAbsolute(profile) || profile.startsWith('.') || profile.includes('/')) return resolve(profile)
145
+ return join(home, 'profiles', profile)
146
+ }
147
+
148
+ /**
149
+ * Add this package to a profile's `dsh.profile.bundles`. Does NOT touch
150
+ * `dependencies` — that is `pnpm add`'s job — and does not run the package
151
+ * manager. Returns whether the file changed.
152
+ *
153
+ * @param {string} dir profile directory (holds package.json)
154
+ */
155
+ export async function mountInProfile(dir) {
156
+ const file = join(dir, 'package.json')
157
+ if (!existsSync(file)) throw new Error(`no package.json in ${dir}: is this a dsh profile directory?`)
158
+ const text = await readFile(file, 'utf8')
159
+ const pkg = JSON.parse(text)
160
+ const profile = { ...(pkg.dsh?.profile ?? {}) }
161
+ const bundles = Array.isArray(profile.bundles) ? [...profile.bundles] : []
162
+ if (bundles.includes(PACKAGE_NAME)) return false
163
+ bundles.push(PACKAGE_NAME)
164
+ pkg.dsh = { ...(pkg.dsh ?? {}), profile: { ...profile, bundles } }
165
+ await writeFile(file, JSON.stringify(pkg, null, 2) + '\n', 'utf8')
166
+ return true
167
+ }