@xp266/dshtui 0.1.2 → 0.1.4

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/bin/dshtui.js CHANGED
@@ -4,54 +4,133 @@
4
4
  * carries this TUI, equivalent to `dsh --profile <profile> [args...]`.
5
5
  *
6
6
  * The dsh CLI's subcommands are hardcoded upstream, so the command lives in
7
- * this package's bin instead. The profile defaults to `dshtui` and can be
8
- * overridden with DSH_TUI_PROFILE. On first run (profile directory absent)
9
- * the profile is bootstrapped from the published package version, which is
10
- * also the version the profile later upgrades through `dsh plugin add`.
7
+ * this package's bin. Profile resolution: DSH_TUI_PROFILE overrides; else the
8
+ * `dshtui` profile when it exists; else the one profile that already mounts
9
+ * this package (an existing profile carries the user's session history
10
+ * bootstrapping a fresh one would hide it); else the `dshtui` profile is
11
+ * bootstrapped. Before an install or upgrade, the profile's
12
+ * minimumReleaseAgeExclude gains this package's name so pnpm's
13
+ * supply-chain age policy never rejects freshly published releases.
11
14
  *
12
15
  * This file must stay free of lib/ imports so it works from a global
13
16
  * install, a profile copy, and a dev checkout alike.
14
17
  */
15
18
  import { spawn, spawnSync } from 'node:child_process'
16
- import { existsSync, readFileSync } from 'node:fs'
19
+ import { existsSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'
17
20
  import { homedir } from 'node:os'
18
21
  import { dirname, join } from 'node:path'
19
22
  import { fileURLToPath } from 'node:url'
20
23
 
21
24
  const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)))
22
25
  const manifest = JSON.parse(readFileSync(join(packageRoot, 'package.json'), 'utf8'))
23
- const profile = process.env.DSH_TUI_PROFILE ?? 'dshtui'
26
+ const packageName = manifest.name
27
+ const windows = process.platform === 'win32'
24
28
  const dshHome = process.env.DSH_HOME ?? join(homedir(), '.dsh')
25
- const profileDir = join(dshHome, 'profiles', profile)
29
+ const profilesDir = join(dshHome, 'profiles')
26
30
 
27
31
  function fail(message) {
28
32
  console.error(`dshtui: ${message}`)
29
33
  process.exit(1)
30
34
  }
31
35
 
36
+ const quote = arg => /^[A-Za-z0-9_@%+=:,./-]+$/.test(arg) ? arg : `"${arg.replaceAll('"', '\\"')}"`
32
37
  // Windows resolves `dsh` through a .cmd shim, which only spawns with a
33
- // shell; the command line is built as one quoted string there because
34
- // passing an args array alongside shell:true is deprecated (DEP0190).
35
- const windows = process.platform === 'win32'
36
- const dshProbe = spawnSync('dsh', ['--version'], { stdio: 'ignore', shell: windows })
37
- if (dshProbe.error !== undefined) {
38
+ // shell; the command line is one quoted string there because passing an
39
+ // args array alongside shell:true is deprecated (DEP0190).
40
+ function runDsh(args) {
41
+ return windows
42
+ ? spawnSync(['dsh', ...args].map(quote).join(' '), { stdio: 'inherit', shell: true })
43
+ : spawnSync('dsh', args, { stdio: 'inherit' })
44
+ }
45
+
46
+ function readJson(path) {
47
+ try {
48
+ return JSON.parse(readFileSync(path, 'utf8'))
49
+ } catch {
50
+ return undefined
51
+ }
52
+ }
53
+
54
+ function profileMountsPackage(profileDir) {
55
+ const profileManifest = readJson(join(profileDir, 'package.json'))
56
+ if (profileManifest === undefined) return false
57
+ const bundles = profileManifest?.dsh?.profile?.bundles ?? []
58
+ if (bundles.includes(packageName)) return true
59
+ return packageName in (profileManifest.dependencies ?? {})
60
+ }
61
+
62
+ function installedVersion(profileDir) {
63
+ return readJson(join(profileDir, 'node_modules', packageName, 'package.json'))?.version
64
+ }
65
+
66
+ function versionLessThan(installed, wanted) {
67
+ const core = version => version.split('-')[0].split('.').map(Number)
68
+ const [a, b] = [core(installed), core(wanted)]
69
+ for (let index = 0; index < 3; index++) {
70
+ if ((a[index] ?? 0) !== (b[index] ?? 0)) return (a[index] ?? 0) < (b[index] ?? 0)
71
+ }
72
+ return false
73
+ }
74
+
75
+ function ensureReleaseAgeExclude(profileDir) {
76
+ // The exclude matches the bare package name, so every version of this
77
+ // package bypasses the age policy while the rest of the tree stays
78
+ // protected by it.
79
+ const workspaceFile = join(profileDir, 'pnpm-workspace.yaml')
80
+ let content = ''
81
+ try {
82
+ content = readFileSync(workspaceFile, 'utf8')
83
+ } catch {
84
+ return
85
+ }
86
+ if (new RegExp(`- ['"]?${packageName.replace('/', '\\/')}['"]?\\s*$`, 'm').test(content)) return
87
+ const line = ` - '${packageName}'`
88
+ content = /^minimumReleaseAgeExclude:\s*$/m.test(content)
89
+ ? content.replace(/^minimumReleaseAgeExclude:\s*$/m, `minimumReleaseAgeExclude:\n${line}`)
90
+ : `${content.trimEnd()}\nminimumReleaseAgeExclude:\n${line}\n`
91
+ writeFileSync(workspaceFile, content)
92
+ }
93
+
94
+ function resolveProfile() {
95
+ const override = process.env.DSH_TUI_PROFILE
96
+ if (override !== undefined) return override
97
+ if (profileMountsPackage(join(profilesDir, 'dshtui'))) return 'dshtui'
98
+ const candidates = existsSync(profilesDir)
99
+ ? readdirSync(profilesDir).filter(name => name !== 'node_modules' && profileMountsPackage(join(profilesDir, name)))
100
+ : []
101
+ if (candidates.length === 1) return candidates[0]
102
+ if (candidates.length > 1) {
103
+ fail(`multiple profiles mount ${packageName} (${candidates.join(', ')}); pick one with DSH_TUI_PROFILE=<name>`)
104
+ }
105
+ return 'dshtui'
106
+ }
107
+
108
+ const profile = resolveProfile()
109
+ const profileDir = join(profilesDir, profile)
110
+ const dshProbe = runDsh(['--version'])
111
+ if (dshProbe.error !== undefined || dshProbe.status !== 0) {
38
112
  fail('the dsh CLI was not found on PATH; install it with: npm install -g @deepseek-ai/dsh')
39
113
  }
40
114
 
41
115
  if (!existsSync(profileDir)) {
42
- console.error(`dshtui: profile "${profile}" not found under ${join(dshHome, 'profiles')}; installing ${manifest.name}@${manifest.version} into it`)
43
- const bootstrap = spawnSync(
44
- 'dsh',
45
- ['plugin', '--profile', profile, 'add', `${manifest.name}@${manifest.version}`],
46
- { stdio: 'inherit', shell: process.platform === 'win32' },
47
- )
116
+ console.error(`dshtui: profile "${profile}" not found under ${profilesDir}; installing ${packageName}@${manifest.version} into it`)
117
+ const bootstrap = runDsh(['plugin', '--profile', profile, 'add', `${packageName}@${manifest.version}`])
48
118
  if (bootstrap.status !== 0) {
49
- fail(`bootstrapping the profile failed; create it manually with: dsh plugin --profile ${profile} add ${manifest.name}`)
119
+ fail(`bootstrapping the profile failed; run manually: dsh plugin --profile ${profile} add ${packageName}`)
120
+ }
121
+ } else {
122
+ const installed = installedVersion(profileDir)
123
+ if (installed !== undefined && versionLessThan(installed, manifest.version)) {
124
+ ensureReleaseAgeExclude(profileDir)
125
+ console.error(`dshtui: upgrading profile "${profile}": ${packageName} ${installed} -> ${manifest.version}`)
126
+ const upgrade = runDsh(['plugin', '--profile', profile, 'add', `${packageName}@${manifest.version}`])
127
+ if (upgrade.status !== 0) {
128
+ fail(`the upgrade failed; run manually: dsh plugin --profile ${profile} add ${packageName}@${manifest.version}`)
129
+ }
50
130
  }
51
131
  }
52
132
 
53
133
  const argv = process.argv.slice(2)
54
- const quote = arg => /^[A-Za-z0-9_@%+=:,./-]+$/.test(arg) ? arg : `"${arg.replaceAll('"', '\\"')}"`
55
134
  const command = ['dsh', '--profile', profile, ...argv].map(quote).join(' ')
56
135
  const child = spawn(windows ? command : 'dsh', windows ? [] : ['--profile', profile, ...argv], {
57
136
  stdio: 'inherit',
package/lib/index.mjs CHANGED
@@ -7193,6 +7193,14 @@ function sessionTime(session) {
7193
7193
  * (one log read, then persisted), never a repeated full read.
7194
7194
  */
7195
7195
  async function computeSessionList(ctx) {
7196
+ try {
7197
+ return await computeSessionListInner(ctx);
7198
+ } catch (error$2) {
7199
+ error("boot", `session list failed: ${error$2 instanceof Error ? error$2.message : String(error$2)}`);
7200
+ throw error$2;
7201
+ }
7202
+ }
7203
+ async function computeSessionListInner(ctx) {
7196
7204
  const workspacePaths = collectWorkspacePaths(ctx);
7197
7205
  const archived = collectArchivedIds(ctx);
7198
7206
  const summaries = [];
@@ -10497,12 +10505,12 @@ async function createChatBridge(ctx) {
10497
10505
  currentEffort,
10498
10506
  effortName,
10499
10507
  selectEffort,
10500
- permissionMode: () => permission()?.current(activeAgent.session.snapshotEvents()) ?? PERMISSION_PRESETS[0],
10508
+ permissionMode: () => permission()?.current(activeAgent.session) ?? PERMISSION_PRESETS[0],
10501
10509
  cyclePermission: () => {
10502
10510
  const service = permission();
10503
10511
  if (service === void 0) return;
10504
10512
  try {
10505
- const current = service.current(activeAgent.session.snapshotEvents());
10513
+ const current = service.current(activeAgent.session);
10506
10514
  const names = service.names.length > 0 ? service.names : PERMISSION_PRESETS;
10507
10515
  const next = names[(names.indexOf(current) + 1) % names.length];
10508
10516
  service.set(activeAgent.session, next);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xp266/dshtui",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "type": "module",
5
5
  "description": "ink-based terminal UI plugin for DeepSeek Harness",
6
6
  "engines": {
@@ -46,7 +46,7 @@ import { applyTheme } from '../apply-theme.ts'
46
46
  import { THEME_SETTINGS_NAMESPACE } from '../theme-settings.ts'
47
47
  import { themeMode } from '../theme.ts'
48
48
  import type { ThemeMode } from '../theme.ts'
49
- import { warn } from '../log.ts'
49
+ import { error as logError, warn } from '../log.ts'
50
50
 
51
51
  interface AttachmentsServiceLike {
52
52
  saveImage(input: { data: Uint8Array; mediaType: string; name?: string }): Promise<ImageAttachmentRef>
@@ -143,7 +143,7 @@ interface PresetSessionLike {
143
143
  export const PERMISSION_PRESETS = ['workspace-write', 'danger-full-access', 'read-only'] as const
144
144
 
145
145
  interface PermissionPresetsLike {
146
- current(events: readonly SessionEvent[]): string
146
+ current(session: Session): string
147
147
  set(session: Session, name: string): void
148
148
  readonly names: readonly string[]
149
149
  readonly defaultPreset: string
@@ -1040,12 +1040,12 @@ export async function createChatBridge(ctx: Context): Promise<ChatBridge> {
1040
1040
  currentEffort,
1041
1041
  effortName,
1042
1042
  selectEffort,
1043
- permissionMode: () => permission()?.current(activeAgent.session.snapshotEvents()) ?? PERMISSION_PRESETS[0],
1043
+ permissionMode: () => permission()?.current(activeAgent.session) ?? PERMISSION_PRESETS[0],
1044
1044
  cyclePermission: () => {
1045
1045
  const service = permission()
1046
1046
  if (service === undefined) return
1047
1047
  try {
1048
- const current = service.current(activeAgent.session.snapshotEvents())
1048
+ const current = service.current(activeAgent.session)
1049
1049
  const names = service.names.length > 0 ? service.names : PERMISSION_PRESETS
1050
1050
  const index = names.indexOf(current)
1051
1051
  const next = names[(index + 1) % names.length]
@@ -2,6 +2,7 @@ import { stat } from 'node:fs/promises'
2
2
  import type { Context } from '@deepseek-ai/cordis'
3
3
  import type { SessionEvent } from '@deepseek-ai/dsh-session'
4
4
  import { textFromBlocks } from './blocks.ts'
5
+ import { error as logError } from '../log.ts'
5
6
  import { isBlankSession } from './presets.ts'
6
7
 
7
8
  export interface SessionSummary {
@@ -27,6 +28,15 @@ export function sessionTime(session: SessionSummary): number {
27
28
  * (one log read, then persisted), never a repeated full read.
28
29
  */
29
30
  export async function computeSessionList(ctx: Context): Promise<SessionSummary[]> {
31
+ try {
32
+ return await computeSessionListInner(ctx)
33
+ } catch (error) {
34
+ logError('boot', `session list failed: ${error instanceof Error ? error.message : String(error)}`)
35
+ throw error
36
+ }
37
+ }
38
+
39
+ async function computeSessionListInner(ctx: Context): Promise<SessionSummary[]> {
30
40
  const workspacePaths = collectWorkspacePaths(ctx)
31
41
  const archived = collectArchivedIds(ctx)
32
42
  const summaries: SessionSummary[] = []