dsh-code 1.0.7 → 1.3.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 (89) hide show
  1. package/README.en.md +70 -24
  2. package/README.md +71 -25
  3. package/bin/deepseek.mjs +202 -39
  4. package/cordis.patch.yml +13 -4
  5. package/lib/index.mjs +5002 -1061
  6. package/lib/session-query.mjs +3 -2
  7. package/lib/startup.mjs +4 -4
  8. package/lib/{theme-DCT8Y2xf.mjs → theme-B3orFUYz.mjs} +665 -20
  9. package/lib/types/app.d.ts +120 -63
  10. package/lib/types/attachments.d.ts +16 -7
  11. package/lib/types/authorization-panel.d.ts +3 -3
  12. package/lib/types/git-workflow.d.ts +91 -2
  13. package/lib/types/history.d.ts +10 -0
  14. package/lib/types/i18n.d.ts +39 -0
  15. package/lib/types/index.d.ts +74 -2
  16. package/lib/types/input-split.d.ts +1 -1
  17. package/lib/types/kernel-panels.d.ts +89 -32
  18. package/lib/types/language-panel.d.ts +12 -0
  19. package/lib/types/locales/en.d.ts +498 -0
  20. package/lib/types/locales/zh.d.ts +9 -0
  21. package/lib/types/mentions.d.ts +7 -3
  22. package/lib/types/models.d.ts +14 -0
  23. package/lib/types/panel-accent.d.ts +28 -0
  24. package/lib/types/rainbow.d.ts +69 -0
  25. package/lib/types/render/animations.d.ts +42 -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 +95 -4
  30. package/lib/types/render/status.d.ts +12 -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 +42 -1
  34. package/lib/types/session-switch.d.ts +8 -0
  35. package/lib/types/startup.d.ts +1 -1
  36. package/lib/types/terminal-title.d.ts +8 -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 +27 -6
  40. package/lib/types/update.d.ts +10 -1
  41. package/lib/types/version.d.ts +6 -3
  42. package/package.json +26 -7
  43. package/src/app.ts +1426 -627
  44. package/src/approval.ts +166 -166
  45. package/src/attachments.ts +65 -19
  46. package/src/authorization-panel.ts +24 -18
  47. package/src/editor-keys.ts +371 -371
  48. package/src/fork.ts +11 -7
  49. package/src/git-workflow.ts +229 -3
  50. package/src/history.ts +14 -0
  51. package/src/i18n.ts +68 -0
  52. package/src/index.ts +503 -128
  53. package/src/input-split.ts +27 -7
  54. package/src/kernel-panels.ts +528 -113
  55. package/src/keyboard.ts +5 -4
  56. package/src/language-panel.ts +53 -0
  57. package/src/locales/en.ts +538 -0
  58. package/src/locales/zh.ts +537 -0
  59. package/src/mentions.ts +8 -4
  60. package/src/models.ts +264 -212
  61. package/src/panel-accent.ts +41 -0
  62. package/src/presets.ts +1 -1
  63. package/src/provider-settings.ts +1 -1
  64. package/src/rainbow.ts +218 -0
  65. package/src/render/animations.ts +104 -6
  66. package/src/render/editor.ts +20 -20
  67. package/src/render/export.ts +116 -95
  68. package/src/render/inspector.ts +42 -0
  69. package/src/render/lines.ts +628 -415
  70. package/src/render/markdown.ts +15 -3
  71. package/src/render/projection.ts +429 -19
  72. package/src/render/status.ts +119 -62
  73. package/src/render/text.ts +15 -0
  74. package/src/render/tool-preview.ts +77 -77
  75. package/src/render/usage.ts +430 -0
  76. package/src/render/width.ts +2 -2
  77. package/src/session-directory.ts +90 -9
  78. package/src/session-query.ts +8 -4
  79. package/src/session-switch.ts +14 -0
  80. package/src/startup.ts +3 -3
  81. package/src/store.ts +19 -1
  82. package/src/subagents.ts +229 -229
  83. package/src/terminal-title.ts +22 -5
  84. package/src/theme-panel.ts +17 -21
  85. package/src/theme.ts +281 -33
  86. package/src/update-panel.ts +148 -31
  87. package/src/update.ts +19 -3
  88. package/src/version.ts +63 -20
  89. package/src/whale-glyph.ts +23 -23
@@ -11,10 +11,12 @@
11
11
 
12
12
  import { createElement, useEffect, useState, type ReactElement } from 'react'
13
13
  import { Box, Text, useInput, useStdout } from 'ink'
14
- import type { LauncherUpdateStatus } from './update.ts'
14
+ import type { LauncherUpdatePlan, LauncherUpdateStatus } from './update.ts'
15
15
  import { clampScroll, panelViewport } from './render/inspector.ts'
16
16
  import { singleLineText, truncateColumns } from './render/text.ts'
17
+ import { panelAccent } from './panel-accent.ts'
17
18
  import { getPalette, inkColor } from './theme.ts'
19
+ import { t } from './i18n.ts'
18
20
 
19
21
  /** Retained apply-progress lines (ring tail; npm output is ephemeral). */
20
22
  export const UPDATE_OUTPUT_CAP = 800
@@ -24,6 +26,84 @@ export function clipUpdateLines(lines: readonly string[]): readonly string[] {
24
26
  return lines.length <= UPDATE_OUTPUT_CAP ? lines : lines.slice(lines.length - UPDATE_OUTPUT_CAP)
25
27
  }
26
28
 
29
+ /** One in-flight `update --apply`. Closing the panel must not start a second. */
30
+ interface UpdateApplyJob {
31
+ readonly promise: Promise<number>
32
+ readonly lines: string[]
33
+ readonly lineListeners: Set<(line: string) => void>
34
+ }
35
+
36
+ let updateApplyJob: UpdateApplyJob | undefined
37
+ const updateApplyRunningListeners = new Set<(running: boolean) => void>()
38
+
39
+ function emitUpdateApplyRunning(running: boolean): void {
40
+ for (const listener of updateApplyRunningListeners) listener(running)
41
+ }
42
+
43
+ /** True while an apply child is alive, even if the /update panel is closed. */
44
+ export function isUpdateApplyRunning(): boolean {
45
+ return updateApplyJob !== undefined
46
+ }
47
+
48
+ /** Subscribe to apply-running changes (frozen-hint "esc waits"). */
49
+ export function subscribeUpdateApplyRunning(listener: (running: boolean) => void): () => void {
50
+ updateApplyRunningListeners.add(listener)
51
+ listener(updateApplyJob !== undefined)
52
+ return () => { updateApplyRunningListeners.delete(listener) }
53
+ }
54
+
55
+ /** The live apply job, if any: lines already streamed plus the shared promise. */
56
+ export function currentUpdateApply(): UpdateApplyJob | undefined {
57
+ return updateApplyJob
58
+ }
59
+
60
+ /**
61
+ * Run `apply` once. A second call while the child is alive reuses the same
62
+ * promise and replays buffered lines — closing /update and opening it again
63
+ * must not spawn a second `npm install`.
64
+ */
65
+ export function runUpdateApply(
66
+ apply: (onLine: (line: string) => void, plan: LauncherUpdatePlan) => Promise<number>,
67
+ plan: LauncherUpdatePlan,
68
+ onLine?: (line: string) => void,
69
+ ): Promise<number> {
70
+ if (updateApplyJob !== undefined) {
71
+ if (onLine !== undefined) {
72
+ for (const line of updateApplyJob.lines) onLine(line)
73
+ updateApplyJob.lineListeners.add(onLine)
74
+ }
75
+ return updateApplyJob.promise
76
+ }
77
+ const lines: string[] = []
78
+ const lineListeners = new Set<(line: string) => void>()
79
+ if (onLine !== undefined) lineListeners.add(onLine)
80
+ const promise = apply(line => {
81
+ const text = singleLineText(line)
82
+ lines.push(text)
83
+ if (lines.length > UPDATE_OUTPUT_CAP) lines.splice(0, lines.length - UPDATE_OUTPUT_CAP)
84
+ for (const listener of lineListeners) listener(text)
85
+ }, plan)
86
+ updateApplyJob = { promise, lines, lineListeners }
87
+ emitUpdateApplyRunning(true)
88
+ const settle = (): void => {
89
+ if (updateApplyJob?.promise === promise) {
90
+ updateApplyJob = undefined
91
+ emitUpdateApplyRunning(false)
92
+ }
93
+ }
94
+ // Handle both outcomes directly. `finally()` mirrors a rejected source
95
+ // promise onto its returned promise; discarding that returned promise would
96
+ // create an unhandled rejection when the update child cannot start.
97
+ void promise.then(settle, settle)
98
+ return promise
99
+ }
100
+
101
+ /** Drop a leftover job between tests. */
102
+ export function resetUpdateApply(): void {
103
+ updateApplyJob = undefined
104
+ emitUpdateApplyRunning(false)
105
+ }
106
+
27
107
  /** One display row of the update surface. */
28
108
  export interface UpdateRow {
29
109
  readonly key: string
@@ -41,9 +121,11 @@ export interface UpdatePlanView {
41
121
  export function updatePlanView(status: LauncherUpdateStatus): UpdatePlanView {
42
122
  const rows: UpdateRow[] = []
43
123
  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}` })
124
+ rows.push(status.aheadOfRegistry === true
125
+ ? { key: 'code', text: `dsh-code ${status.code.running} (newer than npm ${latest})` }
126
+ : status.code.latest !== null && status.code.latest === status.code.running
127
+ ? { key: 'code', text: `dsh-code ${status.code.running} (latest)` }
128
+ : { key: 'code', text: `dsh-code ${status.code.running} → ${latest}` })
47
129
  if (status.host.targetLine === null) {
48
130
  rows.push({ key: 'host', text: 'harness pinned line unreadable — update would install @deepseek-ai/dsh@latest', tone: 'warn' })
49
131
  } else if (status.host.installed === null) {
@@ -83,7 +165,9 @@ export function updatePlanView(status: LauncherUpdateStatus): UpdatePlanView {
83
165
  rows.push({ key: `blocker:checkout:${index}`, text: line, tone: 'error' })
84
166
  }
85
167
  }
86
- if (status.upToDate) {
168
+ if (status.aheadOfRegistry === true) {
169
+ rows.push({ key: 'ahead', text: 'this install is newer than npm latest — refusing to downgrade', tone: 'warn' })
170
+ } else if (status.upToDate) {
87
171
  rows.push({ key: 'uptodate', text: 'everything is already on the pinned line — nothing to update', tone: 'ok' })
88
172
  }
89
173
  const runnable = status.upToDate !== true
@@ -97,13 +181,14 @@ export function updatePlanView(status: LauncherUpdateStatus): UpdatePlanView {
97
181
  export type UpdatePhase = 'probe' | 'error' | 'plan' | 'apply' | 'done'
98
182
 
99
183
  /** 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'
184
+ export function updateFooter(phase: UpdatePhase, runnable: boolean, upToDate: boolean, aheadOfRegistry = false): string {
185
+ if (phase === 'probe') return t('panel.update.footer.probe')
186
+ if (phase === 'error') return t('panel.update.footer.error')
187
+ if (phase === 'apply') return t('panel.update.footer.apply')
188
+ if (phase === 'done') return t('panel.update.footer.error')
189
+ if (aheadOfRegistry) return t('panel.update.footer.ahead')
190
+ if (upToDate) return t('panel.update.footer.current')
191
+ return runnable ? t('panel.update.footer.update') : t('panel.update.footer.blocked')
107
192
  }
108
193
 
109
194
  /**
@@ -114,13 +199,13 @@ export function updateFooter(phase: UpdatePhase, runnable: boolean, upToDate: bo
114
199
  */
115
200
  export function UpdatePanel({ probe, apply, close, notify }: {
116
201
  /** Read-only probe of the launcher update status (never installs). */
117
- probe(): Promise<LauncherUpdateStatus>
202
+ probe: () => Promise<LauncherUpdateStatus>
118
203
  /** Run the aligned update; streams sanitized progress lines. */
119
- apply(onLine: (line: string) => void): Promise<number>
204
+ apply: (onLine: (line: string) => void, plan: LauncherUpdateStatus['plan']) => Promise<number>
120
205
  /** Close the panel (App keeps ownership of the flag). */
121
- close(): void
206
+ close: () => void
122
207
  /** One bounded cross-surface notice (phase completions). */
123
- notify(text: string, tone?: 'info' | 'warning' | 'error'): void
208
+ notify: (text: string, tone?: 'info' | 'warning' | 'error') => void
124
209
  }): ReactElement {
125
210
  const [phase, setPhase] = useState<UpdatePhase>('probe')
126
211
  const [status, setStatus] = useState<LauncherUpdateStatus>()
@@ -132,8 +217,35 @@ export function UpdatePanel({ probe, apply, close, notify }: {
132
217
  // visible row (up moves away from the tail, down onto it re-follows).
133
218
  const [anchor, setAnchor] = useState<'tail' | number>('tail')
134
219
  const [epoch, setEpoch] = useState(0)
220
+ const appendLine = (line: string): void => {
221
+ setLines(previous => clipUpdateLines([...previous, line]))
222
+ }
135
223
  useEffect(() => {
136
224
  let disposed = false
225
+ const existing = currentUpdateApply()
226
+ if (existing !== undefined) {
227
+ // The apply child outlives the panel: closing for an approval or
228
+ // Ctrl+C must reconnect to the same job, not probe-and-confirm again.
229
+ setPhase('apply')
230
+ setLines(existing.lines)
231
+ setExit(undefined)
232
+ setApplyError(undefined)
233
+ setAnchor('tail')
234
+ existing.lineListeners.add(appendLine)
235
+ void existing.promise.then(code => {
236
+ if (disposed) return
237
+ setExit(code)
238
+ setPhase('done')
239
+ }, reason => {
240
+ if (disposed) return
241
+ setApplyError(reason instanceof Error ? reason.message : String(reason))
242
+ setPhase('done')
243
+ })
244
+ return () => {
245
+ disposed = true
246
+ existing.lineListeners.delete(appendLine)
247
+ }
248
+ }
137
249
  setPhase('probe')
138
250
  setStatus(undefined)
139
251
  setProbeError(undefined)
@@ -147,21 +259,23 @@ export function UpdatePanel({ probe, apply, close, notify }: {
147
259
  setProbeError(reason instanceof Error ? reason.message : String(reason))
148
260
  setPhase('error')
149
261
  })
150
- return () => { disposed = true }
262
+ return () => {
263
+ disposed = true
264
+ currentUpdateApply()?.lineListeners.delete(appendLine)
265
+ }
151
266
  }, [epoch, probe])
152
267
  const stdout = useStdout().stdout
153
268
  const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
154
269
  const start = (): void => {
155
270
  if (phase !== 'plan' || status === undefined) return
156
271
  if (!updatePlanView(status).runnable) return
272
+ if (isUpdateApplyRunning()) return
157
273
  setPhase('apply')
158
274
  setLines([])
159
275
  setExit(undefined)
160
276
  setApplyError(undefined)
161
277
  setAnchor('tail')
162
- apply(line => {
163
- setLines(previous => clipUpdateLines([...previous, singleLineText(line)]))
164
- }).then(code => {
278
+ runUpdateApply(apply, status.plan, appendLine).then(code => {
165
279
  setExit(code)
166
280
  setPhase('done')
167
281
  notify(code === 0 ? 'update installed — restart dsh to activate' : `update failed (exit ${code})`, code === 0 ? 'info' : 'error')
@@ -212,12 +326,12 @@ export function UpdatePanel({ probe, apply, close, notify }: {
212
326
  else if (key.downArrow) setAnchor(offset + 1)
213
327
  })
214
328
  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))
329
+ const summary = phase === 'probe' ? t('panel.update.checking')
330
+ : phase === 'error' ? t('panel.update.probeFailed')
331
+ : phase === 'apply' ? singleLineText(lines[lines.length - 1] ?? t('panel.update.updating'))
332
+ : phase === 'done' ? (exit === 0 ? t('panel.update.installed') : t('panel.update.failed'))
333
+ : 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')
334
+ return createElement(Text, { wrap: 'truncate-end' }, truncateColumns(singleLineText(t('panel.update.compact', { summary })), viewport.contentColumns))
221
335
  }
222
336
  const visible = rows.slice(offset, offset + budget)
223
337
  const toneColor = (tone: UpdateRow['tone']): ReturnType<typeof inkColor> | undefined => tone === 'ok'
@@ -230,17 +344,20 @@ export function UpdatePanel({ probe, apply, close, notify }: {
230
344
  ? inkColor(getPalette().dim)
231
345
  : undefined
232
346
  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}` : ''}`
347
+ ? t('panel.update.title')
348
+ : status.aheadOfRegistry === true
349
+ ? `/update · dsh-code ${status.code.running}`
350
+ : `/update · dsh-code ${status.code.running}${status.code.latest !== null && status.code.latest !== status.code.running ? ` → ${status.code.latest}` : ''}`
351
+ const accent = panelAccent('update', getPalette().dim, getPalette().brandBright)
235
352
  return createElement(
236
353
  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)),
354
+ { width: viewport.outerColumns, borderStyle: 'round', borderColor: inkColor(accent.border), flexDirection: 'column', paddingX: 1 },
355
+ createElement(Text, { color: inkColor(accent.title), wrap: 'truncate-end' }, truncateColumns(singleLineText(title), viewport.contentColumns)),
239
356
  ...visible.map(row => createElement(Text, {
240
357
  key: row.key,
241
358
  color: toneColor(row.tone),
242
359
  wrap: 'truncate-end',
243
360
  }, 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)),
361
+ createElement(Text, { dimColor: true, wrap: 'truncate-end' }, truncateColumns(singleLineText(updateFooter(phase, planView?.runnable ?? false, status?.upToDate === true, status?.aheadOfRegistry === true)), viewport.contentColumns)),
245
362
  )
246
363
  }
package/src/update.ts CHANGED
@@ -20,6 +20,15 @@ export interface LauncherUpdateStatus {
20
20
  readonly plan: { readonly dshSpec: string; readonly codeSpec: string; readonly pluginSpecs: readonly string[] }
21
21
  readonly blockers: { readonly registry: string | null; readonly downgrade: boolean; readonly localCheckout: readonly string[] | null }
22
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[]
23
32
  }
24
33
 
25
34
  /** The launcher entrypoint that ships beside this bundle (lib/../bin). */
@@ -89,10 +98,17 @@ export async function probeLauncherUpdate(spawnProcess: SpawnLike = spawn as Spa
89
98
  * exit code (0 success); rejects only when the process could not start.
90
99
  * No timeout: an npm install may legitimately take minutes.
91
100
  */
92
- export function applyLauncherUpdate(onLine: (line: string) => void, spawnProcess: SpawnLike = spawn as SpawnLike): Promise<number> {
93
- const command = launcherUpdateCommand(['update', '--apply'])
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))
94
110
  return new Promise((resolve, reject) => {
95
- const child = spawnProcess(command.command, command.args, { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true })
111
+ const child = spawnFn(command.command, command.args, { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true })
96
112
  const split = createLineSplitter(onLine)
97
113
  child.stdout?.on('data', (chunk: Buffer) => { split(chunk.toString()) })
98
114
  child.stderr?.on('data', (chunk: Buffer) => { split(chunk.toString()) })
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. */
@@ -16,33 +17,39 @@ function readPackageVersion(manifest = new URL('../package.json', import.meta.ur
16
17
  /** Version of the installed dsh-code package. */
17
18
  export const DSH_CODE_VERSION = readPackageVersion()
18
19
 
20
+ /** Header brand line: hide a missing-manifest fallback so we never paint v0.0.0. */
21
+ export function headerBrandTitle(version: string = DSH_CODE_VERSION): string {
22
+ return version === '0.0.0' ? 'DeepSeek Harness' : `DeepSeek Harness · v${version}`
23
+ }
24
+
19
25
  /** The harness host package: the dsh CLI whose process runs the TUI plugin. */
20
26
  const DSH_HOST_PACKAGE_NAME = '@deepseek-ai/dsh'
21
27
 
22
28
  /** Parent levels above the host entry allowed to hold its package manifest. */
23
29
  const DSH_HOST_WALK_LIMIT = 4
24
30
 
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
31
+ /** Read `@deepseek-ai/dsh`'s version from a directory's package.json, or undefined. */
32
+ function readHostVersion(directory: string): string | undefined {
33
+ try {
34
+ const parsed = JSON.parse(readFileSync(join(directory, 'package.json'), 'utf8')) as {
35
+ name?: unknown
36
+ version?: unknown
37
+ }
38
+ if (parsed.name === DSH_HOST_PACKAGE_NAME && typeof parsed.version === 'string' && parsed.version.length > 0) {
39
+ return parsed.version
40
+ }
41
+ } catch {
42
+ // No readable host manifest at this level.
43
+ }
44
+ return undefined
45
+ }
46
+
47
+ /** Climb from an entry file toward a host package.json. */
48
+ function walkFrom(entry: string): string | undefined {
33
49
  let directory = dirname(resolve(entry))
34
50
  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
- }
51
+ const version = readHostVersion(directory)
52
+ if (version !== undefined) return version
46
53
  const parent = dirname(directory)
47
54
  if (parent === directory) return undefined
48
55
  directory = parent
@@ -50,6 +57,42 @@ export function resolveDshHostVersion(entry: string | undefined = process.argv[1
50
57
  return undefined
51
58
  }
52
59
 
60
+ /**
61
+ * Resolve the host package via Node's module lookup from an entry file.
62
+ * Covers global `bin/dsh` shims that are not inside the package tree.
63
+ */
64
+ function resolveFromNode(from: string): string | undefined {
65
+ try {
66
+ const manifest = createRequire(from).resolve(`${DSH_HOST_PACKAGE_NAME}/package.json`)
67
+ return readHostVersion(dirname(manifest))
68
+ } catch {
69
+ return undefined
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Resolve the running dsh CLI host's version from its entry file
75
+ * (`process.argv[1]`, e.g. `.../@deepseek-ai/dsh/lib/bin.js` or a PATH
76
+ * symlink `.../bin/dsh` → that file). Only a manifest literally named
77
+ * `@deepseek-ai/dsh` counts, so an unrelated entry (vitest, a plain node
78
+ * script) resolves to undefined instead of faking a kernel version.
79
+ */
80
+ export function resolveDshHostVersion(entry: string | undefined = process.argv[1]): string | undefined {
81
+ if (entry === undefined || entry === '') return undefined
82
+ const fromWalk = walkFrom(entry)
83
+ if (fromWalk !== undefined) return fromWalk
84
+ try {
85
+ const real = realpathSync(entry)
86
+ if (real !== resolve(entry)) {
87
+ const fromReal = walkFrom(real)
88
+ if (fromReal !== undefined) return fromReal
89
+ }
90
+ } catch {
91
+ // Broken or missing symlink: fall through to Node resolution.
92
+ }
93
+ return resolveFromNode(resolve(entry))
94
+ }
95
+
53
96
  let cachedDshKernelVersion: string | undefined
54
97
  let dshKernelVersionResolved = false
55
98
 
@@ -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