@aiwayds/dsh-opentui 0.4.3 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +67 -0
- package/LICENSE +202 -0
- package/NOTICE +63 -0
- package/README.md +132 -0
- package/bin/dsh-opentui.mjs +0 -0
- package/dist-host/index.js +1790 -287
- package/package.json +8 -2
- package/scripts/e2e-client.mjs +1042 -0
- package/scripts/e2e-live.mjs +27 -4
- package/scripts/idle-reaper.mjs +80 -0
- package/scripts/install-profile.mjs +13 -22
- package/scripts/link-dsh-closure.mjs +18 -10
- package/scripts/pack-meta.mjs +17 -0
- package/scripts/provision-opentui-profile.mjs +212 -0
- package/scripts/solid-js-pin.mjs +286 -0
- package/scripts/sync-profile.mjs +62 -0
package/scripts/e2e-live.mjs
CHANGED
|
@@ -58,6 +58,29 @@ const check = (name, ok, hint = '') => {
|
|
|
58
58
|
console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${ok ? '' : ' ← ' + hint}`)
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
// Wait for the current turn to END before driving the next phase. Fixed
|
|
62
|
+
// sleeps raced provider latency: a slow counting turn (~5 tok/s that day)
|
|
63
|
+
// ran past the budget and every later keystroke landed mid-turn, where the
|
|
64
|
+
// TUI has no command/picker surface — 5 phantom FAILs with zero markers in
|
|
65
|
+
// the stream. Detection: the live think/tool dock keeps re-painting into
|
|
66
|
+
// the stream tail while a turn runs (its timer ticks every second); after
|
|
67
|
+
// the turn ends only the powerline clock repaints, so a tail window free
|
|
68
|
+
// of 💭/thinking/⚙ means idle. The powerline clock keeps raw length
|
|
69
|
+
// growing forever, so whole-stream length quiescence never fires.
|
|
70
|
+
const waitTurnEnd = async (minMs, maxMs) => {
|
|
71
|
+
await sleep(minMs)
|
|
72
|
+
const t0 = Date.now()
|
|
73
|
+
let idleStreak = 0
|
|
74
|
+
while (Date.now() - t0 < maxMs) {
|
|
75
|
+
await sleep(2000)
|
|
76
|
+
const tail = out.slice(-3000)
|
|
77
|
+
const busy = tail.includes('💭') || tail.includes('thinking') || tail.includes('⚙')
|
|
78
|
+
idleStreak = busy ? 0 : idleStreak + 1
|
|
79
|
+
if (idleStreak >= 3) return true // 6s of clock-only tail = turn over
|
|
80
|
+
}
|
|
81
|
+
return false
|
|
82
|
+
}
|
|
83
|
+
|
|
61
84
|
try {
|
|
62
85
|
await sleep(9000) // boot + welcome + powerline
|
|
63
86
|
|
|
@@ -65,13 +88,13 @@ try {
|
|
|
65
88
|
await send('用 bash 工具运行 date 命令,然后告诉我今天是几月几号。只回答一句话。')
|
|
66
89
|
await sleep(6000) // mid-turn: TPS segment + thinking + tool active
|
|
67
90
|
const midTurn = out.length
|
|
68
|
-
await
|
|
91
|
+
await waitTurnEnd(10000, 120000)
|
|
69
92
|
|
|
70
93
|
// --- turn 2: todos + steering
|
|
71
94
|
await send('先用 todo 工具记录两个任务:测试多行、收尾,然后从 1 数到 20,每个数字单独一行。')
|
|
72
95
|
await sleep(5000)
|
|
73
96
|
await send('改一下:只要数到 10 就行。')
|
|
74
|
-
await
|
|
97
|
+
await waitTurnEnd(30000, 300000)
|
|
75
98
|
|
|
76
99
|
// --- /theme picker opens, esc closes
|
|
77
100
|
await send('/theme')
|
|
@@ -91,7 +114,7 @@ try {
|
|
|
91
114
|
|
|
92
115
|
// --- subagent turn: dispatch a child, expect panel rows (tree rail)
|
|
93
116
|
await send('派一个子代理去运行 ls 命令看看当前目录有什么,然后把结果告诉我。')
|
|
94
|
-
await
|
|
117
|
+
await waitTurnEnd(30000, 300000)
|
|
95
118
|
|
|
96
119
|
// --- /history fork at the newest turn boundary + context probe
|
|
97
120
|
await send('/history')
|
|
@@ -99,7 +122,7 @@ try {
|
|
|
99
122
|
child.stdin.write('\r') // pick the highlighted (newest) turn
|
|
100
123
|
await sleep(6000)
|
|
101
124
|
await send('刚才我最后请你做的是什么?一句话回答。')
|
|
102
|
-
await
|
|
125
|
+
await waitTurnEnd(30000, 240000)
|
|
103
126
|
|
|
104
127
|
child.stdin.end()
|
|
105
128
|
await Promise.race([sleep(4000), new Promise((r) => child.once('exit', r))])
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// Idle-exit reaper (ADR-0005 decision 5, entry-side companion of
|
|
2
|
+
// lifecycle.ts): the lifecycle's idle-exit timer is unref()ed — a
|
|
3
|
+
// refcount-0 manager must stay free to exit — so a cleanly quitting TUI
|
|
4
|
+
// cannot deliver "the host exits after idleExitSec of idleness" with an
|
|
5
|
+
// in-process timer. The quitting client spawns this script DETACHED
|
|
6
|
+
// instead (spawnIdleReaper in src/client-entry.tsx):
|
|
7
|
+
//
|
|
8
|
+
// bun idle-reaper.mjs <pid> <port> <lockfilePath> <graceSec>
|
|
9
|
+
//
|
|
10
|
+
// After the grace window it shuts the host down — unless:
|
|
11
|
+
// - the lockfile now describes a DIFFERENT pid (a fresh boot replaced the
|
|
12
|
+
// host): never touch what we do not own;
|
|
13
|
+
// - a client re-attached inside the grace window (established TCP
|
|
14
|
+
// connections to the host port — the adoption path keeps the host alive
|
|
15
|
+
// under its new owner; that owner's own quit spawns the next reaper).
|
|
16
|
+
// The probe is `lsof -sTCP:ESTABLISHED`; when the tool is unavailable the
|
|
17
|
+
// reaper falls back to the plain lifecycle semantics (idle exit).
|
|
18
|
+
import { readFileSync, rmSync } from 'node:fs'
|
|
19
|
+
import { spawnSync } from 'node:child_process'
|
|
20
|
+
|
|
21
|
+
const [pidArg, portArg, lockPath, graceArg] = process.argv.slice(2)
|
|
22
|
+
const pid = Number(pidArg)
|
|
23
|
+
const port = Number(portArg)
|
|
24
|
+
const graceSec = Number(graceArg ?? 60)
|
|
25
|
+
|
|
26
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
27
|
+
|
|
28
|
+
const pidAlive = (target) => {
|
|
29
|
+
try {
|
|
30
|
+
process.kill(target, 0)
|
|
31
|
+
return true
|
|
32
|
+
} catch {
|
|
33
|
+
return false
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Established TCP connections involving the host port (0 = none, -1 =
|
|
38
|
+
* the probe tool is unavailable). lsof exits 1 with no output when the
|
|
39
|
+
* filter matches nothing — that IS the zero case. */
|
|
40
|
+
function establishedTo(targetPort) {
|
|
41
|
+
const res = spawnSync('lsof', ['-nP', `-iTCP:${targetPort}`, '-sTCP:ESTABLISHED'], { encoding: 'utf8' })
|
|
42
|
+
if (res.error) return -1
|
|
43
|
+
if (res.status === 1) return 0
|
|
44
|
+
const lines = String(res.stdout ?? '').split('\n')
|
|
45
|
+
return Math.max(0, lines.slice(1).filter((l) => l.trim().length > 0).length)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function readLock() {
|
|
49
|
+
try {
|
|
50
|
+
const raw = JSON.parse(readFileSync(lockPath, 'utf8'))
|
|
51
|
+
return typeof raw?.pid === 'number' ? raw : null
|
|
52
|
+
} catch {
|
|
53
|
+
return null
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
await sleep(graceSec * 1000)
|
|
58
|
+
|
|
59
|
+
const lock = readLock()
|
|
60
|
+
if (!lock || lock.pid !== pid) process.exit(0) // replaced by a fresh boot — not ours
|
|
61
|
+
|
|
62
|
+
const established = establishedTo(port)
|
|
63
|
+
if (established > 0) process.exit(0) // a client re-attached — the host stays under its new owner
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
process.kill(pid, 'SIGTERM')
|
|
67
|
+
} catch {}
|
|
68
|
+
await sleep(2000)
|
|
69
|
+
if (pidAlive(pid)) {
|
|
70
|
+
try {
|
|
71
|
+
process.kill(pid, 'SIGKILL')
|
|
72
|
+
} catch {}
|
|
73
|
+
}
|
|
74
|
+
// Clear the lockfile only if it still describes THIS host (never clobber
|
|
75
|
+
// a newer host's lock — lifecycle parity).
|
|
76
|
+
try {
|
|
77
|
+
const now = readLock()
|
|
78
|
+
if (now && now.pid === pid && now.port === port) rmSync(lockPath, { force: true })
|
|
79
|
+
} catch {}
|
|
80
|
+
process.exit(0)
|
|
@@ -22,6 +22,7 @@ import { execFileSync, spawnSync } from 'node:child_process'
|
|
|
22
22
|
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
23
23
|
import { homedir } from 'node:os'
|
|
24
24
|
import { dirname, join } from 'node:path'
|
|
25
|
+
import { ensureSolidPatch, pinSolidJsExports } from './solid-js-pin.mjs'
|
|
25
26
|
|
|
26
27
|
const args = process.argv.slice(2)
|
|
27
28
|
const flag = (name) => {
|
|
@@ -59,11 +60,15 @@ pkg.dsh = { profile: { bundles: [...bundles] } }
|
|
|
59
60
|
pkg.dependencies = { ...(pkg.dependencies || {}), '@aiwayds/dsh-opentui': depSpec }
|
|
60
61
|
writeFileSync(pkgPath, JSON.stringify(pkg, null, 2))
|
|
61
62
|
|
|
62
|
-
// --- cordis.yml (empty root) + workspace ---
|
|
63
|
+
// --- cordis.yml (empty root) + workspace + solid-js patch ---
|
|
63
64
|
const cordisYml = join(profileDir, 'cordis.yml')
|
|
64
65
|
if (!existsSync(cordisYml)) writeFileSync(cordisYml, '# dsh profile root — an empty entry list. The tree is composed as patches.\n[]\n')
|
|
65
|
-
|
|
66
|
-
|
|
66
|
+
// pnpm-workspace.yaml (created with the hoisted layout when missing) + the
|
|
67
|
+
// solid-js@1.9.12 patchedDependencies entry. This MUST run before the
|
|
68
|
+
// `pnpm install` steps below: with the patch registered, every install
|
|
69
|
+
// itself produces the client-build-pinned solid-js instead of wiping it.
|
|
70
|
+
const { workspaceNote } = ensureSolidPatch(profileDir)
|
|
71
|
+
console.log(`[dsh-opentui] ${workspaceNote}`)
|
|
67
72
|
|
|
68
73
|
// --- cordis.patch.yml (provider route + model pin + our insert) ---
|
|
69
74
|
const patchPath = join(profileDir, 'cordis.patch.yml')
|
|
@@ -121,26 +126,12 @@ const pnpm = (() => {
|
|
|
121
126
|
run(pnpm[0], [...pnpm.slice(1), 'install'])
|
|
122
127
|
run(pnpm[0], [...pnpm.slice(1), 'install'])
|
|
123
128
|
|
|
124
|
-
// --- pin solid-js to its CLIENT build
|
|
125
|
-
//
|
|
126
|
-
// first frame renders and then the screen goes dead — no reactive updates.
|
|
127
|
-
// Under bun dev the @opentui/solid preload remaps this; under the host's
|
|
128
|
-
// node runtime the fix is rewriting the installed package's exports.
|
|
129
|
+
// --- pin solid-js to its CLIENT build (belt-and-braces; the primary fix is
|
|
130
|
+
// the patchedDependencies patch registered above) ---
|
|
129
131
|
{
|
|
130
|
-
const
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
pkgJson.exports = {
|
|
134
|
-
'.': { types: './types/index.d.ts', import: './dist/solid.js', require: './dist/solid.cjs' },
|
|
135
|
-
'./dist/solid.js': './dist/solid.js',
|
|
136
|
-
'./dist/solid.cjs': './dist/solid.cjs',
|
|
137
|
-
'./package.json': './package.json',
|
|
138
|
-
}
|
|
139
|
-
writeFileSync(solidPkg, JSON.stringify(pkgJson, null, 2))
|
|
140
|
-
console.log('[dsh-opentui] solid-js pinned to client build (dist/solid.js)')
|
|
141
|
-
} catch {
|
|
142
|
-
console.warn('[dsh-opentui] could not pin solid-js — TUI updates may be dead')
|
|
143
|
-
}
|
|
132
|
+
const state = pinSolidJsExports(profileDir)
|
|
133
|
+
if (state === 'missing') console.warn('[dsh-opentui] could not pin solid-js — TUI updates may be dead')
|
|
134
|
+
else console.log(`[dsh-opentui] solid-js ${state === 'pinned' ? 'pinned to' : 'already pinned to'} client build (dist/solid.js)`)
|
|
144
135
|
}
|
|
145
136
|
|
|
146
137
|
// --- closure link for the plugin's dsh-llm import (hoisted layout: the
|
|
@@ -2,9 +2,11 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* Dev/publish closure linker for @aiwayds/dsh-opentui.
|
|
4
4
|
*
|
|
5
|
-
* The plugin imports `@deepseek-ai/dsh-llm` (createUserMessage)
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* The plugin imports `@deepseek-ai/dsh-llm` (createUserMessage); the
|
|
6
|
+
* client-mode transport also imports `@deepseek-ai/dsh-client-connection`
|
|
7
|
+
* (ConnectionGenerationSource / installConnection). Those packages are not
|
|
8
|
+
* consumable from the public npm registry, so they resolve from the global
|
|
9
|
+
* dsh install's own closure:
|
|
8
10
|
* $(realpath $(which dsh))/node_modules/@deepseek-ai
|
|
9
11
|
* via a plain symlink. ALL @deepseek-ai/* must resolve to that single
|
|
10
12
|
* closure instance (one @deepseek-ai/cordis in the type/runtime graph).
|
|
@@ -44,6 +46,9 @@ function findDshClosure() {
|
|
|
44
46
|
}
|
|
45
47
|
}
|
|
46
48
|
|
|
49
|
+
/** Closure packages the shell imports but npm cannot provide. */
|
|
50
|
+
const CLOSURE_PACKAGES = ['dsh-llm', 'dsh-client-connection']
|
|
51
|
+
|
|
47
52
|
const closure = findDshClosure()
|
|
48
53
|
if (!closure) {
|
|
49
54
|
if (process.env.DSH_OPENTUI_DEBUG) console.warn('[dsh-opentui] no global dsh closure found; skipping link')
|
|
@@ -56,13 +61,16 @@ const targets = [join(pkgRoot, 'node_modules', '@deepseek-ai'), join(dirname(pkg
|
|
|
56
61
|
for (const scopeDir of targets) {
|
|
57
62
|
if (!scopeDir.includes('node_modules')) continue
|
|
58
63
|
mkdirSync(scopeDir, { recursive: true })
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
64
|
+
for (const name of CLOSURE_PACKAGES) {
|
|
65
|
+
if (!existsSync(join(closure, name))) continue // older closure — skip, never throw
|
|
66
|
+
const link = join(scopeDir, name)
|
|
67
|
+
try {
|
|
68
|
+
rmSync(link, { force: true })
|
|
69
|
+
symlinkSync(join(closure, name), link, 'dir')
|
|
70
|
+
} catch {
|
|
71
|
+
/* never fail an install over a link */
|
|
72
|
+
}
|
|
65
73
|
}
|
|
66
74
|
}
|
|
67
|
-
if (process.env.DSH_OPENTUI_DEBUG) console.log(`[dsh-opentui] linked
|
|
75
|
+
if (process.env.DSH_OPENTUI_DEBUG) console.log(`[dsh-opentui] linked ${CLOSURE_PACKAGES.join(', ')} from ${closure}`)
|
|
68
76
|
process.exit(0)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// `npm pack` runs from this package dir, but LICENSE / NOTICE / README.md /
|
|
3
|
+
// CHANGELOG.md live at the repo ROOT — the files-array entries pointed at
|
|
4
|
+
// paths that do not exist here, so every published tarball (0.1.0-0.4.3)
|
|
5
|
+
// silently shipped without them. prepublishOnly copies the four root files
|
|
6
|
+
// in; they are gitignored and never committed (single source of truth).
|
|
7
|
+
import { copyFileSync } from 'node:fs'
|
|
8
|
+
import { dirname, join, resolve } from 'node:path'
|
|
9
|
+
import { fileURLToPath } from 'node:url'
|
|
10
|
+
|
|
11
|
+
const here = dirname(fileURLToPath(import.meta.url))
|
|
12
|
+
const pkgDir = resolve(here, '..')
|
|
13
|
+
const root = resolve(pkgDir, '../..')
|
|
14
|
+
for (const f of ['LICENSE', 'NOTICE', 'README.md', 'CHANGELOG.md']) {
|
|
15
|
+
copyFileSync(join(root, f), join(pkgDir, f))
|
|
16
|
+
}
|
|
17
|
+
console.log('pack-meta: copied LICENSE NOTICE README.md CHANGELOG.md from repo root')
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// provision-opentui-profile.mjs — idempotent bootstrap of the fixed `opentui`
|
|
3
|
+
// host profile (ADR-0005 decisions 3 and 5).
|
|
4
|
+
//
|
|
5
|
+
// What it does:
|
|
6
|
+
// 1. resolves $DSH_HOME (env or ~/.dsh)
|
|
7
|
+
// 2. if $DSH_HOME/profiles/opentui already exists → keeps it (idempotent,
|
|
8
|
+
// never recreates); otherwise recreates it from the shipped `web`
|
|
9
|
+
// template via `dsh opentui --from-default-profile web --dump-config`
|
|
10
|
+
// (--dump-config makes dsh compose-and-exit, so the template copy
|
|
11
|
+
// happens WITHOUT booting the profile; verified against the shipped CLI
|
|
12
|
+
// whose help documents "dsh rescue --from-default-profile web — recreate
|
|
13
|
+
// rescue from the shipped web template")
|
|
14
|
+
// 3. writes/merges the model route into the profile's `cordis.patch.yml`
|
|
15
|
+
// — the shipped web template ships NO provider route, so without this
|
|
16
|
+
// the profile cannot complete a real model turn (provider auth fails).
|
|
17
|
+
// Flags: --provider <id> --model <id> --reasoning-effort <id>
|
|
18
|
+
// (defaults zai-coding-cn / glm-5.3-flash / high — the same defaults as
|
|
19
|
+
// scripts/install-profile.mjs). Same patch-body shape as
|
|
20
|
+
// install-profile.mjs: an `llm-pi-ai` provider entry (apiKeyEnv derived
|
|
21
|
+
// the dsh way — uppercased, non-alnum → underscore, + _API_KEY) plus an
|
|
22
|
+
// `agent-default-model` row.
|
|
23
|
+
// 4. prints what it did and the plugin-install next step:
|
|
24
|
+
// `dsh plugin --profile opentui add <pkg>`
|
|
25
|
+
//
|
|
26
|
+
// Route idempotency contract (follows install-profile.mjs): per managed
|
|
27
|
+
// entry (`llm-pi-ai`, `agent-default-model`), an entry already present in the
|
|
28
|
+
// patch layer is left untouched — a re-run never duplicates entries and never
|
|
29
|
+
// clobbers a model/provider the user edited. Missing entries are appended
|
|
30
|
+
// (never rewritten) so the template's own patch entries survive; the file is
|
|
31
|
+
// backed up (*.bak-<ts>) before the first modification.
|
|
32
|
+
//
|
|
33
|
+
// Hard guard: this script only ever touches the ONE profile named `opentui`.
|
|
34
|
+
// It never touches any other profile — in particular the pre-existing
|
|
35
|
+
// `dsh-opentui` dev profile (which install-profile.mjs owns).
|
|
36
|
+
//
|
|
37
|
+
// Requires a global `dsh` on PATH (the real host CLI). The first host boot
|
|
38
|
+
// resolves the profile's bundles (@deepseek-ai/dsh-base + dsh-web-app) from
|
|
39
|
+
// the installed dsh closure; no explicit pnpm install is needed. The provider
|
|
40
|
+
// key is read by the host from the apiKeyEnv environment variable (this
|
|
41
|
+
// script only names it in the patch; it never handles key values).
|
|
42
|
+
|
|
43
|
+
import { spawnSync } from 'node:child_process'
|
|
44
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
45
|
+
import { homedir } from 'node:os'
|
|
46
|
+
import { join } from 'node:path'
|
|
47
|
+
|
|
48
|
+
const PROFILE = 'opentui'
|
|
49
|
+
const TEMPLATE = 'web'
|
|
50
|
+
|
|
51
|
+
const args = process.argv.slice(2)
|
|
52
|
+
const flag = (name) => {
|
|
53
|
+
const i = args.indexOf(`--${name}`)
|
|
54
|
+
return i >= 0 ? args[i + 1] : undefined
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const provider = flag('provider') || 'zai-coding-cn'
|
|
58
|
+
const model = flag('model') || 'glm-5.3-flash'
|
|
59
|
+
const reasoningEffort = flag('reasoning-effort') || 'high'
|
|
60
|
+
// dsh convention: deriveKeyRef — uppercased, non-alnum → underscore, +_API_KEY
|
|
61
|
+
const apiKeyEnv = `${provider.toUpperCase().replace(/[^A-Z0-9]+/g, '_')}_API_KEY`
|
|
62
|
+
|
|
63
|
+
function fail(message, detail = '') {
|
|
64
|
+
console.error(`provision-opentui-profile: FAIL — ${message}`)
|
|
65
|
+
if (detail) console.error(detail.trimEnd())
|
|
66
|
+
process.exit(1)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
// 1. Resolve the dsh home.
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
const dshHome = process.env.DSH_HOME || join(homedir(), '.dsh')
|
|
73
|
+
const profileDir = join(dshHome, 'profiles', PROFILE)
|
|
74
|
+
console.log(`provision-opentui-profile: dsh home ${dshHome}`)
|
|
75
|
+
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
// 2. The profile: keep an existing one; otherwise recreate from the shipped
|
|
78
|
+
// web template, without booting.
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
if (!existsSync(profileDir)) {
|
|
81
|
+
const which = spawnSync('which', ['dsh'], { encoding: 'utf8' })
|
|
82
|
+
if (which.status !== 0 || !which.stdout.trim()) {
|
|
83
|
+
fail('no global `dsh` on PATH — install the dsh host CLI first')
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
console.log(`provision-opentui-profile: creating ${profileDir} from the shipped '${TEMPLATE}' template`)
|
|
87
|
+
const run = spawnSync(
|
|
88
|
+
'dsh',
|
|
89
|
+
[PROFILE, '--from-default-profile', TEMPLATE, '--dump-config'],
|
|
90
|
+
{
|
|
91
|
+
// DSH_HOME is explicit so the profile lands in the resolved home even
|
|
92
|
+
// when the caller's env was missing or divergent.
|
|
93
|
+
env: { ...process.env, DSH_HOME: dshHome },
|
|
94
|
+
encoding: 'utf8',
|
|
95
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
96
|
+
},
|
|
97
|
+
)
|
|
98
|
+
if (run.error) fail(`failed to run dsh: ${run.error.message}`)
|
|
99
|
+
if (run.status !== 0) {
|
|
100
|
+
fail(
|
|
101
|
+
`\`dsh ${PROFILE} --from-default-profile ${TEMPLATE} --dump-config\` exited ${run.status}`,
|
|
102
|
+
[run.stderr, run.stdout].filter(Boolean).join('\n').slice(-2000),
|
|
103
|
+
)
|
|
104
|
+
}
|
|
105
|
+
if (!existsSync(profileDir)) {
|
|
106
|
+
fail(
|
|
107
|
+
`dsh exited 0 but ${profileDir} was not created — unexpected CLI behavior; refusing to continue`,
|
|
108
|
+
run.stdout.slice(-2000),
|
|
109
|
+
)
|
|
110
|
+
}
|
|
111
|
+
} else {
|
|
112
|
+
console.log(`provision-opentui-profile: ${profileDir} already provisioned — leaving it untouched`)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
// 3. Model route: write/merge the provider route into cordis.patch.yml.
|
|
117
|
+
// Per-entry idempotent append (see the contract in the header).
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
const patchPath = join(profileDir, 'cordis.patch.yml')
|
|
120
|
+
|
|
121
|
+
const providerEntry = `- id: llm-pi-ai
|
|
122
|
+
name: "@deepseek-ai/dsh-llm-pi-ai"
|
|
123
|
+
config:
|
|
124
|
+
providers:
|
|
125
|
+
${provider}:
|
|
126
|
+
apiKeyEnv: ${apiKeyEnv}
|
|
127
|
+
models:
|
|
128
|
+
- id: ${model}
|
|
129
|
+
name: ${model}
|
|
130
|
+
input:
|
|
131
|
+
- text
|
|
132
|
+
`
|
|
133
|
+
const modelEntry = `- id: agent-default-model
|
|
134
|
+
name: "@deepseek-ai/dsh-agent-default-model"
|
|
135
|
+
config:
|
|
136
|
+
provider: ${provider}
|
|
137
|
+
model: ${model}
|
|
138
|
+
reasoningEffort: ${reasoningEffort}
|
|
139
|
+
`
|
|
140
|
+
|
|
141
|
+
// Top-level entries only: the file is a YAML array of `- id:` entries, and
|
|
142
|
+
// nested plugin ids are indented — the ^ anchor keeps the check off them.
|
|
143
|
+
const hasEntry = (text, id) => new RegExp(`^- id: ${id}\\s*$`, 'm').test(text)
|
|
144
|
+
|
|
145
|
+
// True when the patch layer carries NO top-level entries at all (missing
|
|
146
|
+
// file, empty, comments only, or a bare `[]` flow document — the shipped web
|
|
147
|
+
// template writes exactly that). Such a file has nothing to merge into: it is
|
|
148
|
+
// replaced rather than appended to, because appending after the `[]` document
|
|
149
|
+
// terminator would produce an invalid two-document YAML stream (the host's
|
|
150
|
+
// single-document parser rejects it at boot).
|
|
151
|
+
const isEmptyListDoc = (text) => {
|
|
152
|
+
const body = text
|
|
153
|
+
.split('\n')
|
|
154
|
+
.filter((line) => { const t = line.trim(); return t !== '' && !t.startsWith('#') })
|
|
155
|
+
.join('\n')
|
|
156
|
+
.trim()
|
|
157
|
+
return body === '' || body === '[]'
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
let existing = ''
|
|
161
|
+
try {
|
|
162
|
+
existing = readFileSync(patchPath, 'utf8')
|
|
163
|
+
} catch {
|
|
164
|
+
/* no patch layer yet — created below */
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const missing = [
|
|
168
|
+
['llm-pi-ai', providerEntry],
|
|
169
|
+
['agent-default-model', modelEntry],
|
|
170
|
+
].filter(([id, body]) => !hasEntry(existing, id))
|
|
171
|
+
|
|
172
|
+
if (missing.length === 0) {
|
|
173
|
+
console.log(
|
|
174
|
+
'provision-opentui-profile: model route already in cordis.patch.yml (llm-pi-ai + agent-default-model) — leaving it untouched',
|
|
175
|
+
)
|
|
176
|
+
} else {
|
|
177
|
+
const routeBody = missing.map(([, body]) => body).join('')
|
|
178
|
+
const banner =
|
|
179
|
+
`# model route (managed by provision-opentui-profile.mjs — provider ${provider}, ` +
|
|
180
|
+
`key read by the host from ${apiKeyEnv}):\n`
|
|
181
|
+
if (existing && !isEmptyListDoc(existing)) {
|
|
182
|
+
// Append after the existing entries; back up before the first
|
|
183
|
+
// modification, like install-profile.mjs.
|
|
184
|
+
const backupPath = `${patchPath}.bak-${Date.now()}`
|
|
185
|
+
renameSync(patchPath, backupPath)
|
|
186
|
+
console.log(`provision-opentui-profile: backed up existing patch to ${backupPath}`)
|
|
187
|
+
writeFileSync(patchPath, `${existing.replace(/\n*$/, '\n\n')}${banner}${routeBody}`)
|
|
188
|
+
console.log(
|
|
189
|
+
`provision-opentui-profile: appended route entries [${missing.map(([id]) => id).join(', ')}] to cordis.patch.yml`,
|
|
190
|
+
)
|
|
191
|
+
} else {
|
|
192
|
+
// Missing file or an entry-less document (`[]`): write the list fresh.
|
|
193
|
+
if (existing) {
|
|
194
|
+
const backupPath = `${patchPath}.bak-${Date.now()}`
|
|
195
|
+
renameSync(patchPath, backupPath)
|
|
196
|
+
console.log(`provision-opentui-profile: backed up existing patch to ${backupPath}`)
|
|
197
|
+
}
|
|
198
|
+
mkdirSync(profileDir, { recursive: true })
|
|
199
|
+
writeFileSync(patchPath, `${banner}${routeBody}`)
|
|
200
|
+
console.log(
|
|
201
|
+
`provision-opentui-profile: wrote route entries [${missing.map(([id]) => id).join(', ')}] to cordis.patch.yml`,
|
|
202
|
+
)
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ---------------------------------------------------------------------------
|
|
207
|
+
// 4. Report.
|
|
208
|
+
// ---------------------------------------------------------------------------
|
|
209
|
+
console.log(`provision-opentui-profile: model pinned to ${provider}/${model} (effort ${reasoningEffort}, key env ${apiKeyEnv})`)
|
|
210
|
+
console.log('provision-opentui-profile: PASS')
|
|
211
|
+
console.log(`the '${PROFILE}' profile serves the host Remote protocol (web-app template)`)
|
|
212
|
+
console.log(`install plugins with: dsh plugin --profile ${PROFILE} add <package>`)
|