dsh-code 1.0.6 → 1.0.7
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/README.en.md +60 -9
- package/README.md +60 -9
- package/bin/deepseek.mjs +86 -1
- package/cordis.patch.yml +88 -0
- package/lib/index.mjs +1002 -94
- package/lib/session-query.mjs +149 -0
- package/lib/types/app.d.ts +9 -2
- package/lib/types/index.d.ts +27 -0
- package/lib/types/kernel-panels.d.ts +23 -0
- package/lib/types/render/editor.d.ts +4 -3
- package/lib/types/render/ime-cursor.d.ts +60 -0
- package/lib/types/render/projection.d.ts +35 -0
- package/lib/types/render/status.d.ts +1 -1
- package/lib/types/session-query.d.ts +92 -0
- package/lib/types/terminal-title.d.ts +58 -0
- package/lib/types/update-panel.d.ts +49 -0
- package/lib/types/update.d.ts +66 -0
- package/package.json +228 -89
- package/src/app.ts +252 -69
- package/src/index.ts +129 -11
- package/src/internals.ts +5 -0
- package/src/kernel-panels.ts +89 -3
- package/src/render/editor.ts +5 -4
- package/src/render/ime-cursor.ts +147 -0
- package/src/render/projection.ts +144 -3
- package/src/render/status.ts +18 -4
- package/src/session-query.ts +235 -0
- package/src/terminal-title.ts +173 -0
- package/src/update-panel.ts +246 -0
- package/src/update.ts +110 -0
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal tab/window title management for the TUI.
|
|
3
|
+
*
|
|
4
|
+
* Terminals label their tab from the window title, which an application sets
|
|
5
|
+
* with an OSC 0 sequence; without one the tab shows the process name ("node").
|
|
6
|
+
* The title text is untrusted display content (session names arrive through
|
|
7
|
+
* events and user input), so it is sanitized before emission: control
|
|
8
|
+
* characters and bidi/invisible formatting codepoints are stripped, whitespace
|
|
9
|
+
* runs collapse to single spaces, and the result is bounded. Clearing writes
|
|
10
|
+
* an empty OSC payload and the terminal falls back to its own default; the
|
|
11
|
+
* previously set title is not portable to read back and is never restored.
|
|
12
|
+
*
|
|
13
|
+
* @module @deepseek-ai/dsh-code/terminal-title
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
17
|
+
import { dirname, join } from 'node:path'
|
|
18
|
+
import { useLayoutEffect, useRef } from 'react'
|
|
19
|
+
import { useStdout } from 'ink'
|
|
20
|
+
|
|
21
|
+
/** The host process title at module load, restored on unmount. On Windows
|
|
22
|
+
* this reaches the tab through SetConsoleTitleW (ConPTY reflects it to VS
|
|
23
|
+
* Code and friends without any user setting); on POSIX it becomes the ps
|
|
24
|
+
* name. Node replaces the argv memory, so the original must be saved before
|
|
25
|
+
* the first assignment. */
|
|
26
|
+
const initialProcessTitle = process.title
|
|
27
|
+
|
|
28
|
+
/** Tab label before a session carries a name. */
|
|
29
|
+
export const DEFAULT_TERMINAL_TITLE = 'deepseek'
|
|
30
|
+
|
|
31
|
+
/** Practical upper bound on title length, in visible characters: long enough
|
|
32
|
+
* for session names, short enough for tab bars and window managers. */
|
|
33
|
+
export const MAX_TERMINAL_TITLE_CHARS = 240
|
|
34
|
+
|
|
35
|
+
/** Control characters, DEL/C1 range, and bidi or invisible formatting
|
|
36
|
+
* codepoints that could terminate the OSC sequence or visually reorder the
|
|
37
|
+
* title relative to its underlying text. */
|
|
38
|
+
const DISALLOWED_TITLE_CHARS = /[\u0000-\u001F\u007F-\u009F\u00AD\u034F\u061C\u180E\u200B-\u200F\u202A-\u202E\u2060-\u206F\uFEFF]/
|
|
39
|
+
|
|
40
|
+
/** Normalize untrusted title text into one bounded display line: disallowed
|
|
41
|
+
* codepoints dropped, whitespace runs collapsed to single spaces, leading and
|
|
42
|
+
* trailing whitespace removed, length bounded. */
|
|
43
|
+
export function sanitizeTerminalTitle(text: string): string {
|
|
44
|
+
const chars: string[] = []
|
|
45
|
+
let pendingSpace = false
|
|
46
|
+
for (const ch of text) {
|
|
47
|
+
if (DISALLOWED_TITLE_CHARS.test(ch)) continue
|
|
48
|
+
if (ch.trim() === '') {
|
|
49
|
+
if (chars.length > 0) pendingSpace = true
|
|
50
|
+
continue
|
|
51
|
+
}
|
|
52
|
+
if (pendingSpace) {
|
|
53
|
+
if (chars.length + 1 >= MAX_TERMINAL_TITLE_CHARS) break
|
|
54
|
+
chars.push(' ')
|
|
55
|
+
pendingSpace = false
|
|
56
|
+
}
|
|
57
|
+
if (chars.length >= MAX_TERMINAL_TITLE_CHARS) break
|
|
58
|
+
chars.push(ch)
|
|
59
|
+
}
|
|
60
|
+
return chars.join('')
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Build one OSC 0 title sequence. An empty sanitized title yields an empty
|
|
64
|
+
* string: emitting nothing is distinct from clearing, which is a separate
|
|
65
|
+
* policy decision made by the caller. */
|
|
66
|
+
export function terminalTitleSequence(text: string): string {
|
|
67
|
+
const title = sanitizeTerminalTitle(text)
|
|
68
|
+
return title === '' ? '' : `\x1b]0;${title}\x07`
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Clear the managed title with an empty OSC payload; the terminal falls back
|
|
72
|
+
* to its own default label. */
|
|
73
|
+
export function clearTerminalTitleSequence(): string {
|
|
74
|
+
return '\x1b]0;\x07'
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Outcome of the VS Code settings alignment. */
|
|
78
|
+
export interface VsCodeTitleSettingResult {
|
|
79
|
+
wrote: boolean
|
|
80
|
+
reason?: 'not-vscode' | 'unparseable' | 'key-present' | 'error'
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** VS Code renders an application-set tab title only when
|
|
84
|
+
* "terminal.integrated.tabs.title" maps to the sequence variable; the editor
|
|
85
|
+
* default shows the process name instead ("node" for a Node CLI). Inside a VS
|
|
86
|
+
* Code integrated terminal, align the user settings once: if the key is
|
|
87
|
+
* absent, insert it and keep a one-shot backup of the original file. A value
|
|
88
|
+
* the user already set is never overwritten, an unparseable file is never
|
|
89
|
+
* touched, and every failure degrades to a no-op - the OSC and process-title
|
|
90
|
+
* channels keep working everywhere else. */
|
|
91
|
+
export function ensureVsCodeTabTitleSetting(options: {
|
|
92
|
+
env?: NodeJS.ProcessEnv
|
|
93
|
+
settingsFile?: string
|
|
94
|
+
isTTY?: boolean
|
|
95
|
+
} = {}): VsCodeTitleSettingResult {
|
|
96
|
+
const env = options.env ?? process.env
|
|
97
|
+
if (env['TERM_PROGRAM'] !== 'vscode') return { wrote: false, reason: 'not-vscode' }
|
|
98
|
+
if ((options.isTTY ?? process.stdout.isTTY) !== true) return { wrote: false, reason: 'not-vscode' }
|
|
99
|
+
let file = options.settingsFile
|
|
100
|
+
if (file === undefined) {
|
|
101
|
+
const base = process.platform === 'win32' ? env['APPDATA'] : env['HOME']
|
|
102
|
+
if (base === undefined || base === '') return { wrote: false, reason: 'error' }
|
|
103
|
+
file = process.platform === 'win32'
|
|
104
|
+
? join(base, 'Code', 'User', 'settings.json')
|
|
105
|
+
: join(base, '.config', 'Code', 'User', 'settings.json')
|
|
106
|
+
}
|
|
107
|
+
try {
|
|
108
|
+
if (!existsSync(file)) {
|
|
109
|
+
mkdirSync(dirname(file), { recursive: true })
|
|
110
|
+
writeFileSync(file, '{\n "terminal.integrated.tabs.title": "${sequence}"\n}\n', 'utf8')
|
|
111
|
+
return { wrote: true }
|
|
112
|
+
}
|
|
113
|
+
const text = readFileSync(file, 'utf8')
|
|
114
|
+
if (text.includes('"terminal.integrated.tabs.title"')) return { wrote: false, reason: 'key-present' }
|
|
115
|
+
try {
|
|
116
|
+
JSON.parse(text)
|
|
117
|
+
} catch {
|
|
118
|
+
return { wrote: false, reason: 'unparseable' }
|
|
119
|
+
}
|
|
120
|
+
const close = text.lastIndexOf('}')
|
|
121
|
+
if (close < 0) return { wrote: false, reason: 'unparseable' }
|
|
122
|
+
const before = text.slice(0, close)
|
|
123
|
+
const trimmedBefore = before.replace(/[ \t\r\n]+$/, '')
|
|
124
|
+
const tail = before.slice(trimmedBefore.length)
|
|
125
|
+
const needsComma = trimmedBefore.trim() !== '' && !trimmedBefore.trimEnd().endsWith('{')
|
|
126
|
+
const inserted = (needsComma ? ',' : '') + '\n "terminal.integrated.tabs.title": "${sequence}"\n'
|
|
127
|
+
writeFileSync(file + '.dsh-backup', text, 'utf8')
|
|
128
|
+
writeFileSync(file, trimmedBefore + inserted + tail + text.slice(close), 'utf8')
|
|
129
|
+
return { wrote: true }
|
|
130
|
+
} catch {
|
|
131
|
+
return { wrote: false, reason: 'error' }
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Keep the terminal tab label on `title` (sanitized; empty titles leave the
|
|
137
|
+
* current label alone). Two delivery channels run in parallel: the OSC 0
|
|
138
|
+
* sequence to stdout, and the host process title. Writes are deduplicated by
|
|
139
|
+
* title, and on unmount the managed title is cleared and the process title
|
|
140
|
+
* restored so the host shell regains its default label.
|
|
141
|
+
*/
|
|
142
|
+
export function useTerminalTitle(title: string, options: { clearOnUnmount?: boolean } = {}): void {
|
|
143
|
+
const { clearOnUnmount = true } = options
|
|
144
|
+
const { stdout } = useStdout()
|
|
145
|
+
const writtenRef = useRef<string | undefined>(undefined)
|
|
146
|
+
const processTitleRef = useRef(false)
|
|
147
|
+
// Layout effects: the clear must run synchronously at unmount (passive
|
|
148
|
+
// cleanups are not flushed synchronously when Ink tears the tree down).
|
|
149
|
+
useLayoutEffect(() => {
|
|
150
|
+
if (stdout === undefined) return undefined
|
|
151
|
+
return () => {
|
|
152
|
+
if (processTitleRef.current) {
|
|
153
|
+
processTitleRef.current = false
|
|
154
|
+
process.title = initialProcessTitle
|
|
155
|
+
}
|
|
156
|
+
if (clearOnUnmount && writtenRef.current !== undefined) stdout.write(clearTerminalTitleSequence())
|
|
157
|
+
}
|
|
158
|
+
}, [stdout, clearOnUnmount])
|
|
159
|
+
useLayoutEffect(() => {
|
|
160
|
+
if (stdout === undefined) return
|
|
161
|
+
if (title === writtenRef.current) return
|
|
162
|
+
const sequence = terminalTitleSequence(title)
|
|
163
|
+
if (sequence === '') return
|
|
164
|
+
stdout.write(sequence)
|
|
165
|
+
// The process title is the second delivery channel: on Windows it drives
|
|
166
|
+
// the console title that ConPTY reflects onto the tab without any user
|
|
167
|
+
// setting, where the OSC channel alone only shows once the host maps
|
|
168
|
+
// "terminal.integrated.tabs.title" to the sequence variable.
|
|
169
|
+
process.title = sanitizeTerminalTitle(title)
|
|
170
|
+
processTitleRef.current = true
|
|
171
|
+
writtenRef.current = title
|
|
172
|
+
}, [stdout, title])
|
|
173
|
+
}
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The /update panel: one bounded surface over the launcher update
|
|
3
|
+
* pipeline. The panel never decides versions itself — it renders the
|
|
4
|
+
* launcher `update --json` probe (plan plus refusals), takes one
|
|
5
|
+
* confirmation, then streams `update --apply` progress and reports the
|
|
6
|
+
* result with a restart hint. Every alignment guarantee (host pinned to
|
|
7
|
+
* the release peers line, companion plugins carried, downgrade and
|
|
8
|
+
* local-checkout refusals) lives in the launcher and is only displayed
|
|
9
|
+
* here.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { createElement, useEffect, useState, type ReactElement } from 'react'
|
|
13
|
+
import { Box, Text, useInput, useStdout } from 'ink'
|
|
14
|
+
import type { LauncherUpdateStatus } from './update.ts'
|
|
15
|
+
import { clampScroll, panelViewport } from './render/inspector.ts'
|
|
16
|
+
import { singleLineText, truncateColumns } from './render/text.ts'
|
|
17
|
+
import { getPalette, inkColor } from './theme.ts'
|
|
18
|
+
|
|
19
|
+
/** Retained apply-progress lines (ring tail; npm output is ephemeral). */
|
|
20
|
+
export const UPDATE_OUTPUT_CAP = 800
|
|
21
|
+
|
|
22
|
+
/** Keep the newest UPDATE_OUTPUT_CAP lines of streamed update output. */
|
|
23
|
+
export function clipUpdateLines(lines: readonly string[]): readonly string[] {
|
|
24
|
+
return lines.length <= UPDATE_OUTPUT_CAP ? lines : lines.slice(lines.length - UPDATE_OUTPUT_CAP)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** One display row of the update surface. */
|
|
28
|
+
export interface UpdateRow {
|
|
29
|
+
readonly key: string
|
|
30
|
+
readonly text: string
|
|
31
|
+
readonly tone?: 'ok' | 'warn' | 'error' | 'dim'
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The plan view derived from one probe: facts to show and whether apply may run. */
|
|
35
|
+
export interface UpdatePlanView {
|
|
36
|
+
readonly rows: readonly UpdateRow[]
|
|
37
|
+
readonly runnable: boolean
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Rendered facts of one probe: current/target versions, actions, refusals. */
|
|
41
|
+
export function updatePlanView(status: LauncherUpdateStatus): UpdatePlanView {
|
|
42
|
+
const rows: UpdateRow[] = []
|
|
43
|
+
const latest = status.code.latest ?? 'unknown'
|
|
44
|
+
rows.push(status.code.latest !== null && status.code.latest === status.code.running
|
|
45
|
+
? { key: 'code', text: `dsh-code ${status.code.running} (latest)` }
|
|
46
|
+
: { key: 'code', text: `dsh-code ${status.code.running} → ${latest}` })
|
|
47
|
+
if (status.host.targetLine === null) {
|
|
48
|
+
rows.push({ key: 'host', text: 'harness pinned line unreadable — update would install @deepseek-ai/dsh@latest', tone: 'warn' })
|
|
49
|
+
} else if (status.host.installed === null) {
|
|
50
|
+
rows.push({ key: 'host', text: `harness not installed → ${status.host.targetLine}` })
|
|
51
|
+
} else if (status.host.installed === status.host.targetLine) {
|
|
52
|
+
rows.push({ key: 'host', text: `harness ${status.host.installed} (on pinned line)` })
|
|
53
|
+
} else {
|
|
54
|
+
rows.push({ key: 'host', text: `harness ${status.host.installed} → ${status.host.targetLine}` })
|
|
55
|
+
}
|
|
56
|
+
const mounted = status.profile.mounted === null
|
|
57
|
+
? status.profile.spec ?? 'not mounted'
|
|
58
|
+
: `dsh-code ${status.profile.mounted}`
|
|
59
|
+
rows.push({
|
|
60
|
+
key: 'profile',
|
|
61
|
+
text: `profile ${mounted}${status.profile.localCheckout ? ' · local checkout' : ''}`,
|
|
62
|
+
tone: status.profile.localCheckout ? 'dim' : undefined,
|
|
63
|
+
})
|
|
64
|
+
for (const plugin of status.plan.pluginSpecs) {
|
|
65
|
+
rows.push({ key: `plugin:${plugin}`, text: `plugin ${plugin}`, tone: 'dim' })
|
|
66
|
+
}
|
|
67
|
+
// A blocker key may be absent entirely (JSON.stringify drops undefined);
|
|
68
|
+
// a missing value reads as "no blocker", never as an undefined row body.
|
|
69
|
+
const registryBlocker = status.blockers.registry ?? null
|
|
70
|
+
if (registryBlocker !== null) {
|
|
71
|
+
rows.push({ key: 'blocker:registry', text: registryBlocker, tone: 'error' })
|
|
72
|
+
}
|
|
73
|
+
if (status.blockers.downgrade) {
|
|
74
|
+
rows.push({
|
|
75
|
+
key: 'blocker:downgrade',
|
|
76
|
+
text: `refusing to downgrade the host: dsh-code@${latest} needs ${status.host.targetLine ?? 'the pinned line'}, but ${status.host.installed ?? 'the installed host'} is newer — wait for the next dsh-code release`,
|
|
77
|
+
tone: 'error',
|
|
78
|
+
})
|
|
79
|
+
}
|
|
80
|
+
const checkoutBlocker = status.blockers.localCheckout ?? null
|
|
81
|
+
if (checkoutBlocker !== null) {
|
|
82
|
+
for (const [index, line] of checkoutBlocker.entries()) {
|
|
83
|
+
rows.push({ key: `blocker:checkout:${index}`, text: line, tone: 'error' })
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
if (status.upToDate) {
|
|
87
|
+
rows.push({ key: 'uptodate', text: 'everything is already on the pinned line — nothing to update', tone: 'ok' })
|
|
88
|
+
}
|
|
89
|
+
const runnable = status.upToDate !== true
|
|
90
|
+
&& registryBlocker === null
|
|
91
|
+
&& status.blockers.downgrade !== true
|
|
92
|
+
&& checkoutBlocker === null
|
|
93
|
+
return { rows, runnable }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Panel lifecycle phases; the footer names the keys each phase accepts. */
|
|
97
|
+
export type UpdatePhase = 'probe' | 'error' | 'plan' | 'apply' | 'done'
|
|
98
|
+
|
|
99
|
+
/** Footer hint line per phase; the plan phase names the confirm key only when runnable. */
|
|
100
|
+
export function updateFooter(phase: UpdatePhase, runnable: boolean, upToDate: boolean): string {
|
|
101
|
+
if (phase === 'probe') return 'checking… · esc close'
|
|
102
|
+
if (phase === 'error') return 'r recheck · esc close'
|
|
103
|
+
if (phase === 'apply') return 'updating… · ↑↓ scroll · esc waits'
|
|
104
|
+
if (phase === 'done') return 'r recheck · esc close'
|
|
105
|
+
if (upToDate) return 'up to date · r recheck · esc close'
|
|
106
|
+
return runnable ? 'enter update · r recheck · esc close' : 'blocked · r recheck · esc close'
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The /update surface: probe on open (and on r), confirm with enter/y,
|
|
111
|
+
* stream the aligned apply, and land on a bounded result view. Escape is
|
|
112
|
+
* locked while the apply child runs — killing npm mid-install is exactly
|
|
113
|
+
* the half-updated state this command exists to prevent.
|
|
114
|
+
*/
|
|
115
|
+
export function UpdatePanel({ probe, apply, close, notify }: {
|
|
116
|
+
/** Read-only probe of the launcher update status (never installs). */
|
|
117
|
+
probe(): Promise<LauncherUpdateStatus>
|
|
118
|
+
/** Run the aligned update; streams sanitized progress lines. */
|
|
119
|
+
apply(onLine: (line: string) => void): Promise<number>
|
|
120
|
+
/** Close the panel (App keeps ownership of the flag). */
|
|
121
|
+
close(): void
|
|
122
|
+
/** One bounded cross-surface notice (phase completions). */
|
|
123
|
+
notify(text: string, tone?: 'info' | 'warning' | 'error'): void
|
|
124
|
+
}): ReactElement {
|
|
125
|
+
const [phase, setPhase] = useState<UpdatePhase>('probe')
|
|
126
|
+
const [status, setStatus] = useState<LauncherUpdateStatus>()
|
|
127
|
+
const [probeError, setProbeError] = useState<string>()
|
|
128
|
+
const [lines, setLines] = useState<readonly string[]>([])
|
|
129
|
+
const [exit, setExit] = useState<number>()
|
|
130
|
+
const [applyError, setApplyError] = useState<string>()
|
|
131
|
+
// Viewport anchor: 'tail' follows new output; a number pins the first
|
|
132
|
+
// visible row (up moves away from the tail, down onto it re-follows).
|
|
133
|
+
const [anchor, setAnchor] = useState<'tail' | number>('tail')
|
|
134
|
+
const [epoch, setEpoch] = useState(0)
|
|
135
|
+
useEffect(() => {
|
|
136
|
+
let disposed = false
|
|
137
|
+
setPhase('probe')
|
|
138
|
+
setStatus(undefined)
|
|
139
|
+
setProbeError(undefined)
|
|
140
|
+
setAnchor('tail')
|
|
141
|
+
probe().then(value => {
|
|
142
|
+
if (disposed) return
|
|
143
|
+
setStatus(value)
|
|
144
|
+
setPhase('plan')
|
|
145
|
+
}, reason => {
|
|
146
|
+
if (disposed) return
|
|
147
|
+
setProbeError(reason instanceof Error ? reason.message : String(reason))
|
|
148
|
+
setPhase('error')
|
|
149
|
+
})
|
|
150
|
+
return () => { disposed = true }
|
|
151
|
+
}, [epoch, probe])
|
|
152
|
+
const stdout = useStdout().stdout
|
|
153
|
+
const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
|
|
154
|
+
const start = (): void => {
|
|
155
|
+
if (phase !== 'plan' || status === undefined) return
|
|
156
|
+
if (!updatePlanView(status).runnable) return
|
|
157
|
+
setPhase('apply')
|
|
158
|
+
setLines([])
|
|
159
|
+
setExit(undefined)
|
|
160
|
+
setApplyError(undefined)
|
|
161
|
+
setAnchor('tail')
|
|
162
|
+
apply(line => {
|
|
163
|
+
setLines(previous => clipUpdateLines([...previous, singleLineText(line)]))
|
|
164
|
+
}).then(code => {
|
|
165
|
+
setExit(code)
|
|
166
|
+
setPhase('done')
|
|
167
|
+
notify(code === 0 ? 'update installed — restart dsh to activate' : `update failed (exit ${code})`, code === 0 ? 'info' : 'error')
|
|
168
|
+
}, reason => {
|
|
169
|
+
const message = reason instanceof Error ? reason.message : String(reason)
|
|
170
|
+
setApplyError(message)
|
|
171
|
+
setPhase('done')
|
|
172
|
+
notify(`update failed: ${message}`, 'error')
|
|
173
|
+
})
|
|
174
|
+
}
|
|
175
|
+
const planView = status === undefined ? undefined : updatePlanView(status)
|
|
176
|
+
const rows: readonly UpdateRow[] = phase === 'probe'
|
|
177
|
+
? [{ key: 'probe', text: 'checking npm for the aligned update…', tone: 'dim' }]
|
|
178
|
+
: phase === 'error'
|
|
179
|
+
? [{ key: 'error', text: singleLineText(probeError ?? 'probe failed'), tone: 'error' }]
|
|
180
|
+
: phase === 'plan' && planView !== undefined
|
|
181
|
+
? planView.rows
|
|
182
|
+
: phase === 'apply'
|
|
183
|
+
? lines.map((line, index) => ({ key: `out:${index}`, text: line, tone: 'dim' as const }))
|
|
184
|
+
: [
|
|
185
|
+
...(exit === 0 ? [{ key: 'ok', text: 'update installed — restart dsh to load the new version (/quit or ctrl+c)', tone: 'ok' as const }] : []),
|
|
186
|
+
...(exit !== undefined && exit !== 0 ? [{ key: 'fail', text: `update failed (exit ${exit})`, tone: 'error' as const }] : []),
|
|
187
|
+
...(applyError !== undefined ? [{ key: 'fail:start', text: singleLineText(applyError), tone: 'error' as const }] : []),
|
|
188
|
+
...lines.map((line, index) => ({ key: `out:${index}`, text: line, tone: 'dim' as const })),
|
|
189
|
+
]
|
|
190
|
+
const budget = Math.max(1, viewport.bodyRows)
|
|
191
|
+
const tailOffset = clampScroll(Math.max(0, rows.length - budget), rows.length, budget)
|
|
192
|
+
const offset = anchor === 'tail' ? tailOffset : clampScroll(anchor, rows.length, budget)
|
|
193
|
+
useInput((input, key) => {
|
|
194
|
+
if (phase === 'apply') {
|
|
195
|
+
// Scroll-only while the installer runs: escape stays locked.
|
|
196
|
+
if (key.upArrow) setAnchor(offset <= 0 ? 0 : offset - 1)
|
|
197
|
+
if (key.downArrow && offset >= tailOffset) setAnchor('tail')
|
|
198
|
+
else if (key.downArrow) setAnchor(offset + 1)
|
|
199
|
+
return
|
|
200
|
+
}
|
|
201
|
+
if (key.escape || input === 'q') return close()
|
|
202
|
+
if (input === 'r') {
|
|
203
|
+
setEpoch(value => value + 1)
|
|
204
|
+
return
|
|
205
|
+
}
|
|
206
|
+
if (key.return || input === 'y') {
|
|
207
|
+
void start()
|
|
208
|
+
return
|
|
209
|
+
}
|
|
210
|
+
if (key.upArrow) setAnchor(offset <= 0 ? 0 : offset - 1)
|
|
211
|
+
if (key.downArrow && offset >= tailOffset) setAnchor('tail')
|
|
212
|
+
else if (key.downArrow) setAnchor(offset + 1)
|
|
213
|
+
})
|
|
214
|
+
if (viewport.maxHeight === 0 || viewport.compact) {
|
|
215
|
+
const summary = phase === 'probe' ? 'checking…'
|
|
216
|
+
: phase === 'error' ? 'probe failed'
|
|
217
|
+
: phase === 'apply' ? singleLineText(lines[lines.length - 1] ?? 'updating…')
|
|
218
|
+
: phase === 'done' ? (exit === 0 ? 'installed — restart to activate' : 'failed')
|
|
219
|
+
: status?.upToDate === true ? 'up to date' : planView?.runnable === true ? 'enter updates' : 'blocked'
|
|
220
|
+
return createElement(Text, { wrap: 'truncate-end' }, truncateColumns(singleLineText(`/update · ${summary}`), viewport.contentColumns))
|
|
221
|
+
}
|
|
222
|
+
const visible = rows.slice(offset, offset + budget)
|
|
223
|
+
const toneColor = (tone: UpdateRow['tone']): ReturnType<typeof inkColor> | undefined => tone === 'ok'
|
|
224
|
+
? inkColor(getPalette().success)
|
|
225
|
+
: tone === 'error'
|
|
226
|
+
? inkColor(getPalette().error)
|
|
227
|
+
: tone === 'warn'
|
|
228
|
+
? inkColor(getPalette().warn)
|
|
229
|
+
: tone === 'dim'
|
|
230
|
+
? inkColor(getPalette().dim)
|
|
231
|
+
: undefined
|
|
232
|
+
const title = status === undefined
|
|
233
|
+
? '/update · aligned upgrade'
|
|
234
|
+
: `/update · dsh-code ${status.code.running}${status.code.latest !== null && status.code.latest !== status.code.running ? ` → ${status.code.latest}` : ''}`
|
|
235
|
+
return createElement(
|
|
236
|
+
Box,
|
|
237
|
+
{ width: viewport.outerColumns, borderStyle: 'round', borderColor: inkColor(getPalette().dim), flexDirection: 'column', paddingX: 1 },
|
|
238
|
+
createElement(Text, { color: inkColor(getPalette().brandBright), wrap: 'truncate-end' }, truncateColumns(singleLineText(title), viewport.contentColumns)),
|
|
239
|
+
...visible.map(row => createElement(Text, {
|
|
240
|
+
key: row.key,
|
|
241
|
+
color: toneColor(row.tone),
|
|
242
|
+
wrap: 'truncate-end',
|
|
243
|
+
}, truncateColumns(` ${singleLineText(row.text)}`, viewport.contentColumns))),
|
|
244
|
+
createElement(Text, { dimColor: true, wrap: 'truncate-end' }, truncateColumns(singleLineText(updateFooter(phase, planView?.runnable ?? false, status?.upToDate === true)), viewport.contentColumns)),
|
|
245
|
+
)
|
|
246
|
+
}
|
package/src/update.ts
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Child-process adapter for the launcher update pipeline. The launcher
|
|
3
|
+
* (bin/deepseek.mjs) stays the single owner of update semantics — plan,
|
|
4
|
+
* guards, and the aligned install sequence — so the TUI only spawns
|
|
5
|
+
* `update --json` (read-only probe) and `update --apply` (streamed run)
|
|
6
|
+
* and never re-implements version-line decisions.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { spawn, type ChildProcess } from 'node:child_process'
|
|
10
|
+
import { fileURLToPath } from 'node:url'
|
|
11
|
+
|
|
12
|
+
/** Spawn double acceptable to the adapter (tests inject an EventEmitter). */
|
|
13
|
+
export type SpawnLike = (command: string, args: readonly string[], options: { readonly stdio: readonly string[]; readonly windowsHide: boolean }) => ChildProcess
|
|
14
|
+
|
|
15
|
+
/** Structured `update --json` payload; mirrors the launcher buildUpdateStatus. */
|
|
16
|
+
export interface LauncherUpdateStatus {
|
|
17
|
+
readonly code: { readonly running: string; readonly latest: string | null }
|
|
18
|
+
readonly host: { readonly installed: string | null; readonly targetLine: string | null }
|
|
19
|
+
readonly profile: { readonly spec: string | null; readonly mounted: string | null; readonly localCheckout: boolean }
|
|
20
|
+
readonly plan: { readonly dshSpec: string; readonly codeSpec: string; readonly pluginSpecs: readonly string[] }
|
|
21
|
+
readonly blockers: { readonly registry: string | null; readonly downgrade: boolean; readonly localCheckout: readonly string[] | null }
|
|
22
|
+
readonly upToDate: boolean
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The launcher entrypoint that ships beside this bundle (lib/../bin). */
|
|
26
|
+
export function launcherUpdateCommand(args: readonly string[], moduleUrl: string = import.meta.url): { readonly command: string; readonly args: string[] } {
|
|
27
|
+
return { command: process.execPath, args: [fileURLToPath(new URL('../bin/deepseek.mjs', moduleUrl)), ...args] }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Split a streamed chunk sequence into complete display lines: CR is
|
|
32
|
+
* stripped, a chunk boundary may split a line, and the trailing partial
|
|
33
|
+
* stays pending until its newline arrives (npm writes whole lines, but a
|
|
34
|
+
* pipe may cut anywhere). Blank lines carry no progress information and
|
|
35
|
+
* are dropped so the panel budget is not spent on gaps.
|
|
36
|
+
*/
|
|
37
|
+
export function createLineSplitter(onLine: (line: string) => void): (chunk: string) => void {
|
|
38
|
+
let pending = ''
|
|
39
|
+
return chunk => {
|
|
40
|
+
pending += chunk
|
|
41
|
+
for (let index = pending.indexOf('\n'); index >= 0; index = pending.indexOf('\n')) {
|
|
42
|
+
const line = pending.slice(0, index).replace(/\r$/u, '')
|
|
43
|
+
pending = pending.slice(index + 1)
|
|
44
|
+
if (line !== '') onLine(line)
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Probe the aligned update status. Read-only: `update --json` never
|
|
51
|
+
* installs anything. The probe is bounded (npm view may hang on a broken
|
|
52
|
+
* network) and resolves with the parsed status.
|
|
53
|
+
*/
|
|
54
|
+
export async function probeLauncherUpdate(spawnProcess: SpawnLike = spawn as SpawnLike): Promise<LauncherUpdateStatus> {
|
|
55
|
+
const command = launcherUpdateCommand(['update', '--json'])
|
|
56
|
+
return await new Promise((resolve, reject) => {
|
|
57
|
+
const child = spawnProcess(command.command, command.args, { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true })
|
|
58
|
+
const timer = setTimeout(() => {
|
|
59
|
+
child.kill()
|
|
60
|
+
reject(new Error('update probe timed out'))
|
|
61
|
+
}, 30_000)
|
|
62
|
+
let stdout = ''
|
|
63
|
+
let stderr = ''
|
|
64
|
+
child.stdout?.on('data', (chunk: Buffer) => { stdout += chunk.toString() })
|
|
65
|
+
child.stderr?.on('data', (chunk: Buffer) => { stderr += chunk.toString() })
|
|
66
|
+
child.once('error', error => {
|
|
67
|
+
clearTimeout(timer)
|
|
68
|
+
reject(new Error(`update probe failed to start: ${error.message}`))
|
|
69
|
+
})
|
|
70
|
+
child.once('exit', code => {
|
|
71
|
+
clearTimeout(timer)
|
|
72
|
+
if (code === 0) {
|
|
73
|
+
try {
|
|
74
|
+
resolve(JSON.parse(stdout) as LauncherUpdateStatus)
|
|
75
|
+
} catch {
|
|
76
|
+
reject(new Error('update probe returned an unreadable status'))
|
|
77
|
+
}
|
|
78
|
+
return
|
|
79
|
+
}
|
|
80
|
+
const tail = stderr.trim().split(/\r?\n/u).pop() ?? ''
|
|
81
|
+
reject(new Error(`update probe failed${tail === '' ? '' : `: ${tail}`}`))
|
|
82
|
+
})
|
|
83
|
+
})
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Run the aligned update (`update --apply`) as a child process and stream
|
|
88
|
+
* its sanitized progress lines to the caller. Resolves with the child
|
|
89
|
+
* exit code (0 success); rejects only when the process could not start.
|
|
90
|
+
* No timeout: an npm install may legitimately take minutes.
|
|
91
|
+
*/
|
|
92
|
+
export function applyLauncherUpdate(onLine: (line: string) => void, spawnProcess: SpawnLike = spawn as SpawnLike): Promise<number> {
|
|
93
|
+
const command = launcherUpdateCommand(['update', '--apply'])
|
|
94
|
+
return new Promise((resolve, reject) => {
|
|
95
|
+
const child = spawnProcess(command.command, command.args, { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true })
|
|
96
|
+
const split = createLineSplitter(onLine)
|
|
97
|
+
child.stdout?.on('data', (chunk: Buffer) => { split(chunk.toString()) })
|
|
98
|
+
child.stderr?.on('data', (chunk: Buffer) => { split(chunk.toString()) })
|
|
99
|
+
child.once('error', error => {
|
|
100
|
+
reject(new Error(`update failed to start: ${error.message}`))
|
|
101
|
+
})
|
|
102
|
+
// 'close' fires once the stdio streams have ended, so a final line the
|
|
103
|
+
// child left without its newline still reaches the panel; a bare '\n'
|
|
104
|
+
// flushes any pending partial without adding a blank row.
|
|
105
|
+
child.once('close', (code, signal) => {
|
|
106
|
+
split('\n')
|
|
107
|
+
resolve(code ?? (signal === null ? 0 : 1))
|
|
108
|
+
})
|
|
109
|
+
})
|
|
110
|
+
}
|