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.
@@ -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
+ }