dsh-code 1.0.6 → 1.2.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.
Files changed (86) hide show
  1. package/README.en.md +123 -26
  2. package/README.md +124 -27
  3. package/bin/deepseek.mjs +283 -35
  4. package/cordis.patch.yml +97 -0
  5. package/lib/index.mjs +5008 -881
  6. package/lib/session-query.mjs +150 -0
  7. package/lib/startup.mjs +4 -4
  8. package/lib/{theme-DCT8Y2xf.mjs → theme-7u5Qo3dF.mjs} +657 -20
  9. package/lib/types/app.d.ts +106 -62
  10. package/lib/types/authorization-panel.d.ts +3 -3
  11. package/lib/types/git-workflow.d.ts +91 -2
  12. package/lib/types/i18n.d.ts +39 -0
  13. package/lib/types/index.d.ts +100 -1
  14. package/lib/types/input-split.d.ts +1 -1
  15. package/lib/types/kernel-panels.d.ts +107 -29
  16. package/lib/types/language-panel.d.ts +12 -0
  17. package/lib/types/locales/en.d.ts +450 -0
  18. package/lib/types/locales/zh.d.ts +9 -0
  19. package/lib/types/mentions.d.ts +7 -3
  20. package/lib/types/models.d.ts +14 -0
  21. package/lib/types/panel-accent.d.ts +28 -0
  22. package/lib/types/rainbow.d.ts +69 -0
  23. package/lib/types/render/animations.d.ts +42 -0
  24. package/lib/types/render/editor.d.ts +4 -3
  25. package/lib/types/render/ime-cursor.d.ts +60 -0
  26. package/lib/types/render/inspector.d.ts +26 -0
  27. package/lib/types/render/lines.d.ts +21 -1
  28. package/lib/types/render/markdown.d.ts +1 -1
  29. package/lib/types/render/projection.d.ts +130 -4
  30. package/lib/types/render/status.d.ts +9 -9
  31. package/lib/types/render/text.d.ts +6 -0
  32. package/lib/types/render/usage.d.ts +113 -0
  33. package/lib/types/session-directory.d.ts +17 -0
  34. package/lib/types/session-query.d.ts +92 -0
  35. package/lib/types/startup.d.ts +1 -1
  36. package/lib/types/terminal-title.d.ts +66 -0
  37. package/lib/types/theme-panel.d.ts +2 -2
  38. package/lib/types/theme.d.ts +271 -52
  39. package/lib/types/update-panel.d.ts +49 -0
  40. package/lib/types/update.d.ts +75 -0
  41. package/lib/types/version.d.ts +4 -3
  42. package/package.json +246 -90
  43. package/src/app.ts +1369 -509
  44. package/src/approval.ts +166 -166
  45. package/src/authorization-panel.ts +19 -16
  46. package/src/editor-keys.ts +371 -371
  47. package/src/git-workflow.ts +229 -3
  48. package/src/i18n.ts +68 -0
  49. package/src/index.ts +534 -80
  50. package/src/input-split.ts +3 -3
  51. package/src/internals.ts +5 -0
  52. package/src/kernel-panels.ts +554 -86
  53. package/src/keyboard.ts +5 -4
  54. package/src/language-panel.ts +53 -0
  55. package/src/locales/en.ts +489 -0
  56. package/src/locales/zh.ts +488 -0
  57. package/src/mentions.ts +8 -4
  58. package/src/models.ts +264 -212
  59. package/src/panel-accent.ts +41 -0
  60. package/src/presets.ts +1 -1
  61. package/src/provider-settings.ts +1 -1
  62. package/src/rainbow.ts +208 -0
  63. package/src/render/animations.ts +104 -6
  64. package/src/render/editor.ts +25 -24
  65. package/src/render/export.ts +116 -95
  66. package/src/render/ime-cursor.ts +147 -0
  67. package/src/render/inspector.ts +42 -0
  68. package/src/render/lines.ts +628 -415
  69. package/src/render/markdown.ts +15 -3
  70. package/src/render/projection.ts +572 -21
  71. package/src/render/status.ts +59 -39
  72. package/src/render/text.ts +14 -0
  73. package/src/render/tool-preview.ts +77 -77
  74. package/src/render/usage.ts +430 -0
  75. package/src/render/width.ts +2 -2
  76. package/src/session-directory.ts +8 -6
  77. package/src/session-query.ts +239 -0
  78. package/src/startup.ts +3 -3
  79. package/src/subagents.ts +229 -229
  80. package/src/terminal-title.ts +190 -0
  81. package/src/theme-panel.ts +17 -21
  82. package/src/theme.ts +281 -33
  83. package/src/update-panel.ts +256 -0
  84. package/src/update.ts +126 -0
  85. package/src/version.ts +58 -20
  86. package/src/whale-glyph.ts +23 -23
@@ -0,0 +1,256 @@
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 { panelAccent } from './panel-accent.ts'
18
+ import { getPalette, inkColor } from './theme.ts'
19
+ import { t } from './i18n.ts'
20
+
21
+ /** Retained apply-progress lines (ring tail; npm output is ephemeral). */
22
+ export const UPDATE_OUTPUT_CAP = 800
23
+
24
+ /** Keep the newest UPDATE_OUTPUT_CAP lines of streamed update output. */
25
+ export function clipUpdateLines(lines: readonly string[]): readonly string[] {
26
+ return lines.length <= UPDATE_OUTPUT_CAP ? lines : lines.slice(lines.length - UPDATE_OUTPUT_CAP)
27
+ }
28
+
29
+ /** One display row of the update surface. */
30
+ export interface UpdateRow {
31
+ readonly key: string
32
+ readonly text: string
33
+ readonly tone?: 'ok' | 'warn' | 'error' | 'dim'
34
+ }
35
+
36
+ /** The plan view derived from one probe: facts to show and whether apply may run. */
37
+ export interface UpdatePlanView {
38
+ readonly rows: readonly UpdateRow[]
39
+ readonly runnable: boolean
40
+ }
41
+
42
+ /** Rendered facts of one probe: current/target versions, actions, refusals. */
43
+ export function updatePlanView(status: LauncherUpdateStatus): UpdatePlanView {
44
+ const rows: UpdateRow[] = []
45
+ const latest = status.code.latest ?? 'unknown'
46
+ rows.push(status.aheadOfRegistry === true
47
+ ? { key: 'code', text: `dsh-code ${status.code.running} (newer than npm ${latest})` }
48
+ : status.code.latest !== null && status.code.latest === status.code.running
49
+ ? { key: 'code', text: `dsh-code ${status.code.running} (latest)` }
50
+ : { key: 'code', text: `dsh-code ${status.code.running} → ${latest}` })
51
+ if (status.host.targetLine === null) {
52
+ rows.push({ key: 'host', text: 'harness pinned line unreadable — update would install @deepseek-ai/dsh@latest', tone: 'warn' })
53
+ } else if (status.host.installed === null) {
54
+ rows.push({ key: 'host', text: `harness not installed → ${status.host.targetLine}` })
55
+ } else if (status.host.installed === status.host.targetLine) {
56
+ rows.push({ key: 'host', text: `harness ${status.host.installed} (on pinned line)` })
57
+ } else {
58
+ rows.push({ key: 'host', text: `harness ${status.host.installed} → ${status.host.targetLine}` })
59
+ }
60
+ const mounted = status.profile.mounted === null
61
+ ? status.profile.spec ?? 'not mounted'
62
+ : `dsh-code ${status.profile.mounted}`
63
+ rows.push({
64
+ key: 'profile',
65
+ text: `profile ${mounted}${status.profile.localCheckout ? ' · local checkout' : ''}`,
66
+ tone: status.profile.localCheckout ? 'dim' : undefined,
67
+ })
68
+ for (const plugin of status.plan.pluginSpecs) {
69
+ rows.push({ key: `plugin:${plugin}`, text: `plugin ${plugin}`, tone: 'dim' })
70
+ }
71
+ // A blocker key may be absent entirely (JSON.stringify drops undefined);
72
+ // a missing value reads as "no blocker", never as an undefined row body.
73
+ const registryBlocker = status.blockers.registry ?? null
74
+ if (registryBlocker !== null) {
75
+ rows.push({ key: 'blocker:registry', text: registryBlocker, tone: 'error' })
76
+ }
77
+ if (status.blockers.downgrade) {
78
+ rows.push({
79
+ key: 'blocker:downgrade',
80
+ 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`,
81
+ tone: 'error',
82
+ })
83
+ }
84
+ const checkoutBlocker = status.blockers.localCheckout ?? null
85
+ if (checkoutBlocker !== null) {
86
+ for (const [index, line] of checkoutBlocker.entries()) {
87
+ rows.push({ key: `blocker:checkout:${index}`, text: line, tone: 'error' })
88
+ }
89
+ }
90
+ if (status.aheadOfRegistry === true) {
91
+ rows.push({ key: 'ahead', text: 'this install is newer than npm latest — refusing to downgrade', tone: 'warn' })
92
+ } else if (status.upToDate) {
93
+ rows.push({ key: 'uptodate', text: 'everything is already on the pinned line — nothing to update', tone: 'ok' })
94
+ }
95
+ const runnable = status.upToDate !== true
96
+ && registryBlocker === null
97
+ && status.blockers.downgrade !== true
98
+ && checkoutBlocker === null
99
+ return { rows, runnable }
100
+ }
101
+
102
+ /** Panel lifecycle phases; the footer names the keys each phase accepts. */
103
+ export type UpdatePhase = 'probe' | 'error' | 'plan' | 'apply' | 'done'
104
+
105
+ /** Footer hint line per phase; the plan phase names the confirm key only when runnable. */
106
+ export function updateFooter(phase: UpdatePhase, runnable: boolean, upToDate: boolean, aheadOfRegistry = false): string {
107
+ if (phase === 'probe') return t('panel.update.footer.probe')
108
+ if (phase === 'error') return t('panel.update.footer.error')
109
+ if (phase === 'apply') return t('panel.update.footer.apply')
110
+ if (phase === 'done') return t('panel.update.footer.error')
111
+ if (aheadOfRegistry) return t('panel.update.footer.ahead')
112
+ if (upToDate) return t('panel.update.footer.current')
113
+ return runnable ? t('panel.update.footer.update') : t('panel.update.footer.blocked')
114
+ }
115
+
116
+ /**
117
+ * The /update surface: probe on open (and on r), confirm with enter/y,
118
+ * stream the aligned apply, and land on a bounded result view. Escape is
119
+ * locked while the apply child runs — killing npm mid-install is exactly
120
+ * the half-updated state this command exists to prevent.
121
+ */
122
+ export function UpdatePanel({ probe, apply, close, notify }: {
123
+ /** Read-only probe of the launcher update status (never installs). */
124
+ probe: () => Promise<LauncherUpdateStatus>
125
+ /** Run the aligned update; streams sanitized progress lines. */
126
+ apply: (onLine: (line: string) => void, plan: LauncherUpdateStatus['plan']) => Promise<number>
127
+ /** Close the panel (App keeps ownership of the flag). */
128
+ close: () => void
129
+ /** One bounded cross-surface notice (phase completions). */
130
+ notify: (text: string, tone?: 'info' | 'warning' | 'error') => void
131
+ }): ReactElement {
132
+ const [phase, setPhase] = useState<UpdatePhase>('probe')
133
+ const [status, setStatus] = useState<LauncherUpdateStatus>()
134
+ const [probeError, setProbeError] = useState<string>()
135
+ const [lines, setLines] = useState<readonly string[]>([])
136
+ const [exit, setExit] = useState<number>()
137
+ const [applyError, setApplyError] = useState<string>()
138
+ // Viewport anchor: 'tail' follows new output; a number pins the first
139
+ // visible row (up moves away from the tail, down onto it re-follows).
140
+ const [anchor, setAnchor] = useState<'tail' | number>('tail')
141
+ const [epoch, setEpoch] = useState(0)
142
+ useEffect(() => {
143
+ let disposed = false
144
+ setPhase('probe')
145
+ setStatus(undefined)
146
+ setProbeError(undefined)
147
+ setAnchor('tail')
148
+ probe().then(value => {
149
+ if (disposed) return
150
+ setStatus(value)
151
+ setPhase('plan')
152
+ }, reason => {
153
+ if (disposed) return
154
+ setProbeError(reason instanceof Error ? reason.message : String(reason))
155
+ setPhase('error')
156
+ })
157
+ return () => { disposed = true }
158
+ }, [epoch, probe])
159
+ const stdout = useStdout().stdout
160
+ const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
161
+ const start = (): void => {
162
+ if (phase !== 'plan' || status === undefined) return
163
+ if (!updatePlanView(status).runnable) return
164
+ setPhase('apply')
165
+ setLines([])
166
+ setExit(undefined)
167
+ setApplyError(undefined)
168
+ setAnchor('tail')
169
+ apply(line => {
170
+ setLines(previous => clipUpdateLines([...previous, singleLineText(line)]))
171
+ }, status.plan).then(code => {
172
+ setExit(code)
173
+ setPhase('done')
174
+ notify(code === 0 ? 'update installed — restart dsh to activate' : `update failed (exit ${code})`, code === 0 ? 'info' : 'error')
175
+ }, reason => {
176
+ const message = reason instanceof Error ? reason.message : String(reason)
177
+ setApplyError(message)
178
+ setPhase('done')
179
+ notify(`update failed: ${message}`, 'error')
180
+ })
181
+ }
182
+ const planView = status === undefined ? undefined : updatePlanView(status)
183
+ const rows: readonly UpdateRow[] = phase === 'probe'
184
+ ? [{ key: 'probe', text: 'checking npm for the aligned update…', tone: 'dim' }]
185
+ : phase === 'error'
186
+ ? [{ key: 'error', text: singleLineText(probeError ?? 'probe failed'), tone: 'error' }]
187
+ : phase === 'plan' && planView !== undefined
188
+ ? planView.rows
189
+ : phase === 'apply'
190
+ ? lines.map((line, index) => ({ key: `out:${index}`, text: line, tone: 'dim' as const }))
191
+ : [
192
+ ...(exit === 0 ? [{ key: 'ok', text: 'update installed — restart dsh to load the new version (/quit or ctrl+c)', tone: 'ok' as const }] : []),
193
+ ...(exit !== undefined && exit !== 0 ? [{ key: 'fail', text: `update failed (exit ${exit})`, tone: 'error' as const }] : []),
194
+ ...(applyError !== undefined ? [{ key: 'fail:start', text: singleLineText(applyError), tone: 'error' as const }] : []),
195
+ ...lines.map((line, index) => ({ key: `out:${index}`, text: line, tone: 'dim' as const })),
196
+ ]
197
+ const budget = Math.max(1, viewport.bodyRows)
198
+ const tailOffset = clampScroll(Math.max(0, rows.length - budget), rows.length, budget)
199
+ const offset = anchor === 'tail' ? tailOffset : clampScroll(anchor, rows.length, budget)
200
+ useInput((input, key) => {
201
+ if (phase === 'apply') {
202
+ // Scroll-only while the installer runs: escape stays locked.
203
+ if (key.upArrow) setAnchor(offset <= 0 ? 0 : offset - 1)
204
+ if (key.downArrow && offset >= tailOffset) setAnchor('tail')
205
+ else if (key.downArrow) setAnchor(offset + 1)
206
+ return
207
+ }
208
+ if (key.escape || input === 'q') return close()
209
+ if (input === 'r') {
210
+ setEpoch(value => value + 1)
211
+ return
212
+ }
213
+ if (key.return || input === 'y') {
214
+ void start()
215
+ return
216
+ }
217
+ if (key.upArrow) setAnchor(offset <= 0 ? 0 : offset - 1)
218
+ if (key.downArrow && offset >= tailOffset) setAnchor('tail')
219
+ else if (key.downArrow) setAnchor(offset + 1)
220
+ })
221
+ if (viewport.maxHeight === 0 || viewport.compact) {
222
+ const summary = phase === 'probe' ? t('panel.update.checking')
223
+ : phase === 'error' ? t('panel.update.probeFailed')
224
+ : phase === 'apply' ? singleLineText(lines[lines.length - 1] ?? t('panel.update.updating'))
225
+ : phase === 'done' ? (exit === 0 ? t('panel.update.installed') : t('panel.update.failed'))
226
+ : status?.aheadOfRegistry === true ? t('panel.update.aheadOfNpm') : status?.upToDate === true ? t('panel.update.upToDate') : planView?.runnable === true ? t('panel.update.enter') : t('panel.update.blocked')
227
+ return createElement(Text, { wrap: 'truncate-end' }, truncateColumns(singleLineText(t('panel.update.compact', { summary })), viewport.contentColumns))
228
+ }
229
+ const visible = rows.slice(offset, offset + budget)
230
+ const toneColor = (tone: UpdateRow['tone']): ReturnType<typeof inkColor> | undefined => tone === 'ok'
231
+ ? inkColor(getPalette().success)
232
+ : tone === 'error'
233
+ ? inkColor(getPalette().error)
234
+ : tone === 'warn'
235
+ ? inkColor(getPalette().warn)
236
+ : tone === 'dim'
237
+ ? inkColor(getPalette().dim)
238
+ : undefined
239
+ const title = status === undefined
240
+ ? t('panel.update.title')
241
+ : status.aheadOfRegistry === true
242
+ ? `/update · dsh-code ${status.code.running}`
243
+ : `/update · dsh-code ${status.code.running}${status.code.latest !== null && status.code.latest !== status.code.running ? ` → ${status.code.latest}` : ''}`
244
+ const accent = panelAccent('update', getPalette().dim, getPalette().brandBright)
245
+ return createElement(
246
+ Box,
247
+ { width: viewport.outerColumns, borderStyle: 'round', borderColor: inkColor(accent.border), flexDirection: 'column', paddingX: 1 },
248
+ createElement(Text, { color: inkColor(accent.title), wrap: 'truncate-end' }, truncateColumns(singleLineText(title), viewport.contentColumns)),
249
+ ...visible.map(row => createElement(Text, {
250
+ key: row.key,
251
+ color: toneColor(row.tone),
252
+ wrap: 'truncate-end',
253
+ }, truncateColumns(` ${singleLineText(row.text)}`, viewport.contentColumns))),
254
+ createElement(Text, { dimColor: true, wrap: 'truncate-end' }, truncateColumns(singleLineText(updateFooter(phase, planView?.runnable ?? false, status?.upToDate === true, status?.aheadOfRegistry === true)), viewport.contentColumns)),
255
+ )
256
+ }
package/src/update.ts ADDED
@@ -0,0 +1,126 @@
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
+ /** True when this install is newer than npm latest (apply would downgrade). */
24
+ readonly aheadOfRegistry?: boolean
25
+ }
26
+
27
+ /** Exact specs the TUI confirmed; apply must install these, not re-query latest. */
28
+ export interface LauncherUpdatePlan {
29
+ readonly dshSpec: string
30
+ readonly codeSpec: string
31
+ readonly pluginSpecs: readonly string[]
32
+ }
33
+
34
+ /** The launcher entrypoint that ships beside this bundle (lib/../bin). */
35
+ export function launcherUpdateCommand(args: readonly string[], moduleUrl: string = import.meta.url): { readonly command: string; readonly args: string[] } {
36
+ return { command: process.execPath, args: [fileURLToPath(new URL('../bin/deepseek.mjs', moduleUrl)), ...args] }
37
+ }
38
+
39
+ /**
40
+ * Split a streamed chunk sequence into complete display lines: CR is
41
+ * stripped, a chunk boundary may split a line, and the trailing partial
42
+ * stays pending until its newline arrives (npm writes whole lines, but a
43
+ * pipe may cut anywhere). Blank lines carry no progress information and
44
+ * are dropped so the panel budget is not spent on gaps.
45
+ */
46
+ export function createLineSplitter(onLine: (line: string) => void): (chunk: string) => void {
47
+ let pending = ''
48
+ return chunk => {
49
+ pending += chunk
50
+ for (let index = pending.indexOf('\n'); index >= 0; index = pending.indexOf('\n')) {
51
+ const line = pending.slice(0, index).replace(/\r$/u, '')
52
+ pending = pending.slice(index + 1)
53
+ if (line !== '') onLine(line)
54
+ }
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Probe the aligned update status. Read-only: `update --json` never
60
+ * installs anything. The probe is bounded (npm view may hang on a broken
61
+ * network) and resolves with the parsed status.
62
+ */
63
+ export async function probeLauncherUpdate(spawnProcess: SpawnLike = spawn as SpawnLike): Promise<LauncherUpdateStatus> {
64
+ const command = launcherUpdateCommand(['update', '--json'])
65
+ return await new Promise((resolve, reject) => {
66
+ const child = spawnProcess(command.command, command.args, { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true })
67
+ const timer = setTimeout(() => {
68
+ child.kill()
69
+ reject(new Error('update probe timed out'))
70
+ }, 30_000)
71
+ let stdout = ''
72
+ let stderr = ''
73
+ child.stdout?.on('data', (chunk: Buffer) => { stdout += chunk.toString() })
74
+ child.stderr?.on('data', (chunk: Buffer) => { stderr += chunk.toString() })
75
+ child.once('error', error => {
76
+ clearTimeout(timer)
77
+ reject(new Error(`update probe failed to start: ${error.message}`))
78
+ })
79
+ child.once('exit', code => {
80
+ clearTimeout(timer)
81
+ if (code === 0) {
82
+ try {
83
+ resolve(JSON.parse(stdout) as LauncherUpdateStatus)
84
+ } catch {
85
+ reject(new Error('update probe returned an unreadable status'))
86
+ }
87
+ return
88
+ }
89
+ const tail = stderr.trim().split(/\r?\n/u).pop() ?? ''
90
+ reject(new Error(`update probe failed${tail === '' ? '' : `: ${tail}`}`))
91
+ })
92
+ })
93
+ }
94
+
95
+ /**
96
+ * Run the aligned update (`update --apply`) as a child process and stream
97
+ * its sanitized progress lines to the caller. Resolves with the child
98
+ * exit code (0 success); rejects only when the process could not start.
99
+ * No timeout: an npm install may legitimately take minutes.
100
+ */
101
+ export function applyPlanArgs(plan: LauncherUpdatePlan): string[] {
102
+ const args = ['update', '--apply', '--dsh', plan.dshSpec, '--code', plan.codeSpec]
103
+ for (const plugin of plan.pluginSpecs) args.push('--plugin', plugin)
104
+ return args
105
+ }
106
+
107
+ export function applyLauncherUpdate(onLine: (line: string) => void, spawnProcess?: SpawnLike, plan?: LauncherUpdatePlan): Promise<number> {
108
+ const spawnFn = spawnProcess ?? (spawn as SpawnLike)
109
+ const command = launcherUpdateCommand(plan === undefined ? ['update', '--apply'] : applyPlanArgs(plan))
110
+ return new Promise((resolve, reject) => {
111
+ const child = spawnFn(command.command, command.args, { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true })
112
+ const split = createLineSplitter(onLine)
113
+ child.stdout?.on('data', (chunk: Buffer) => { split(chunk.toString()) })
114
+ child.stderr?.on('data', (chunk: Buffer) => { split(chunk.toString()) })
115
+ child.once('error', error => {
116
+ reject(new Error(`update failed to start: ${error.message}`))
117
+ })
118
+ // 'close' fires once the stdio streams have ended, so a final line the
119
+ // child left without its newline still reaches the panel; a bare '\n'
120
+ // flushes any pending partial without adding a blank row.
121
+ child.once('close', (code, signal) => {
122
+ split('\n')
123
+ resolve(code ?? (signal === null ? 0 : 1))
124
+ })
125
+ })
126
+ }
package/src/version.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  /** Installed dsh-code version exposed by the terminal header. */
2
2
 
3
- import { readFileSync } from 'node:fs'
3
+ import { readFileSync, realpathSync } from 'node:fs'
4
+ import { createRequire } from 'node:module'
4
5
  import { dirname, join, resolve } from 'node:path'
5
6
 
6
7
  /** Read one package manifest version without making terminal startup depend on it. */
@@ -22,27 +23,28 @@ const DSH_HOST_PACKAGE_NAME = '@deepseek-ai/dsh'
22
23
  /** Parent levels above the host entry allowed to hold its package manifest. */
23
24
  const DSH_HOST_WALK_LIMIT = 4
24
25
 
25
- /**
26
- * Resolve the running dsh CLI host's version from its entry file
27
- * (`process.argv[1]`, e.g. `.../@deepseek-ai/dsh/lib/bin.js`). Only a manifest
28
- * literally named `@deepseek-ai/dsh` counts, so an unrelated entry (vitest, a
29
- * plain node script) resolves to undefined instead of faking a kernel version.
30
- */
31
- export function resolveDshHostVersion(entry: string | undefined = process.argv[1]): string | undefined {
32
- if (entry === undefined || entry === '') return undefined
26
+ /** Read `@deepseek-ai/dsh`'s version from a directory's package.json, or undefined. */
27
+ function readHostVersion(directory: string): string | undefined {
28
+ try {
29
+ const parsed = JSON.parse(readFileSync(join(directory, 'package.json'), 'utf8')) as {
30
+ name?: unknown
31
+ version?: unknown
32
+ }
33
+ if (parsed.name === DSH_HOST_PACKAGE_NAME && typeof parsed.version === 'string' && parsed.version.length > 0) {
34
+ return parsed.version
35
+ }
36
+ } catch {
37
+ // No readable host manifest at this level.
38
+ }
39
+ return undefined
40
+ }
41
+
42
+ /** Climb from an entry file toward a host package.json. */
43
+ function walkFrom(entry: string): string | undefined {
33
44
  let directory = dirname(resolve(entry))
34
45
  for (let depth = 0; depth < DSH_HOST_WALK_LIMIT; depth += 1) {
35
- try {
36
- const parsed = JSON.parse(readFileSync(join(directory, 'package.json'), 'utf8')) as {
37
- name?: unknown
38
- version?: unknown
39
- }
40
- if (parsed.name === DSH_HOST_PACKAGE_NAME && typeof parsed.version === 'string' && parsed.version.length > 0) {
41
- return parsed.version
42
- }
43
- } catch {
44
- // No readable manifest at this level: keep climbing.
45
- }
46
+ const version = readHostVersion(directory)
47
+ if (version !== undefined) return version
46
48
  const parent = dirname(directory)
47
49
  if (parent === directory) return undefined
48
50
  directory = parent
@@ -50,6 +52,42 @@ export function resolveDshHostVersion(entry: string | undefined = process.argv[1
50
52
  return undefined
51
53
  }
52
54
 
55
+ /**
56
+ * Resolve the host package via Node's module lookup from an entry file.
57
+ * Covers global `bin/dsh` shims that are not inside the package tree.
58
+ */
59
+ function resolveFromNode(from: string): string | undefined {
60
+ try {
61
+ const manifest = createRequire(from).resolve(`${DSH_HOST_PACKAGE_NAME}/package.json`)
62
+ return readHostVersion(dirname(manifest))
63
+ } catch {
64
+ return undefined
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Resolve the running dsh CLI host's version from its entry file
70
+ * (`process.argv[1]`, e.g. `.../@deepseek-ai/dsh/lib/bin.js` or a PATH
71
+ * symlink `.../bin/dsh` → that file). Only a manifest literally named
72
+ * `@deepseek-ai/dsh` counts, so an unrelated entry (vitest, a plain node
73
+ * script) resolves to undefined instead of faking a kernel version.
74
+ */
75
+ export function resolveDshHostVersion(entry: string | undefined = process.argv[1]): string | undefined {
76
+ if (entry === undefined || entry === '') return undefined
77
+ const fromWalk = walkFrom(entry)
78
+ if (fromWalk !== undefined) return fromWalk
79
+ try {
80
+ const real = realpathSync(entry)
81
+ if (real !== resolve(entry)) {
82
+ const fromReal = walkFrom(real)
83
+ if (fromReal !== undefined) return fromReal
84
+ }
85
+ } catch {
86
+ // Broken or missing symlink: fall through to Node resolution.
87
+ }
88
+ return resolveFromNode(resolve(entry))
89
+ }
90
+
53
91
  let cachedDshKernelVersion: string | undefined
54
92
  let dshKernelVersionResolved = false
55
93
 
@@ -1,23 +1,23 @@
1
- // GENERATED by scripts/gen-whale-glyph.ts — do not edit by hand. Rerun the
2
- // generator after changing the FishLogo path. Half-block rendering of the
3
- // DeepSeek fish logo (figma I39:24057;88:8943 fillGeometry, exact extract;
4
- // native 23.16x17.04 → 26 columns × 8 half-block rows).
5
- // Blank cells are part of the glyph's fixed 26-column grid: pad, never trim.
6
-
7
- /** Half-block whale glyph rows; render with the brand color. */
8
- export const WHALE_GLYPH: readonly string[] = [
9
- ' ▄▄▄▄▄▄▄▄█ ▄█▄ ▄',
10
- ' ▄▄██████████▄▄ ▀███▄████',
11
- '▄███████████████▄ ███▀▀▀ ',
12
- '██ ▀▀█████▄▀██████ ',
13
- '██▄ ▀████▄▄████ ',
14
- ' ██▄ ▀██████▀ ',
15
- ' ▀██▄▄ ██▄ ▀███▄▄ ',
16
- ' ▀▀███████▀▀ ▀▀▀ ',
17
- ]
18
-
19
- /** Fixed glyph width in terminal columns. */
20
- export const WHALE_GLYPH_COLUMNS = 26
21
-
22
- /** Fixed glyph height in half-block rows. */
23
- export const WHALE_GLYPH_ROWS = 8
1
+ // GENERATED by scripts/gen-whale-glyph.ts — do not edit by hand. Rerun the
2
+ // generator after changing the FishLogo path. Half-block rendering of the
3
+ // DeepSeek fish logo (figma I39:24057;88:8943 fillGeometry, exact extract;
4
+ // native 23.16x17.04 → 26 columns × 8 half-block rows).
5
+ // Blank cells are part of the glyph's fixed 26-column grid: pad, never trim.
6
+
7
+ /** Half-block whale glyph rows; render with the brand color. */
8
+ export const WHALE_GLYPH: readonly string[] = [
9
+ ' ▄▄▄▄▄▄▄▄█ ▄█▄ ▄',
10
+ ' ▄▄██████████▄▄ ▀███▄████',
11
+ '▄███████████████▄ ███▀▀▀ ',
12
+ '██ ▀▀█████▄▀██████ ',
13
+ '██▄ ▀████▄▄████ ',
14
+ ' ██▄ ▀██████▀ ',
15
+ ' ▀██▄▄ ██▄ ▀███▄▄ ',
16
+ ' ▀▀███████▀▀ ▀▀▀ ',
17
+ ]
18
+
19
+ /** Fixed glyph width in terminal columns. */
20
+ export const WHALE_GLYPH_COLUMNS = 26
21
+
22
+ /** Fixed glyph height in half-block rows. */
23
+ export const WHALE_GLYPH_ROWS = 8