dsh-oc-tui 0.2.0 → 0.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.
- package/README.md +21 -563
- package/bin/mcp-sse-bridge.mjs +499 -499
- package/docs/assets/conversation.png +0 -0
- package/docs/assets/cover.png +0 -0
- package/docs/assets/home.png +0 -0
- package/docs/assets/settings.png +0 -0
- package/docs/dsh-std-/346/216/245/345/205/245/350/257/264/346/230/216.md +566 -0
- package/docs/user-guide.md +616 -0
- package/docs//347/224/250/346/210/267/346/211/213/345/206/214.md +607 -593
- package/dsh-plugin.json +69 -69
- package/lib/boot-block.js +129 -0
- package/lib/diagnostics.js +162 -120
- package/lib/dsh-version.js +161 -0
- package/lib/git-branch.js +157 -157
- package/lib/i18n.js +283 -283
- package/lib/image.js +2 -1
- package/lib/index.js +5183 -4812
- package/lib/locales/ar.js +670 -635
- package/lib/locales/de.js +639 -604
- package/lib/locales/en.js +35 -0
- package/lib/locales/es.js +639 -604
- package/lib/locales/fr.js +639 -604
- package/lib/locales/id.js +641 -606
- package/lib/locales/it.js +639 -604
- package/lib/locales/ja.js +639 -604
- package/lib/locales/ko.js +639 -604
- package/lib/locales/nl.js +639 -604
- package/lib/locales/pl.js +653 -618
- package/lib/locales/pt-BR.js +639 -604
- package/lib/locales/ru.js +653 -618
- package/lib/locales/tr.js +639 -604
- package/lib/locales/vi.js +641 -606
- package/lib/locales/zh-CN.js +640 -605
- package/lib/locales/zh-TW.js +640 -605
- package/lib/markdown.js +101 -27
- package/lib/mcp-monitor.js +189 -0
- package/lib/mcp-skill.js +93 -93
- package/lib/mcp.js +865 -838
- package/lib/model-route.js +66 -0
- package/lib/providers.js +712 -712
- package/lib/screenshot.js +140 -0
- package/lib/splash-opencode.js +29 -26
- package/lib/startup.js +66 -48
- package/lib/term.js +53 -11
- package/lib/themes.js +101 -0
- package/lib/ui.js +685 -443
- package/lib/updates.js +5 -2
- package/lib/web-settings.js +52 -21
- package/package.json +160 -146
- package/skills/mcp-import/SKILL.md +216 -216
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// Whether this build may run on the dsh that started it.
|
|
2
|
+
//
|
|
3
|
+
// The plugin already declares a supported range in package.json (`engines.dsh`)
|
|
4
|
+
// and dsh's own profile preflight reads it: a host outside that range has this
|
|
5
|
+
// bundle's rows *disabled* before they mount, which is why an in-app message
|
|
6
|
+
// used to be impossible to show. Two gaps are left, and both are what this
|
|
7
|
+
// module closes:
|
|
8
|
+
//
|
|
9
|
+
// * an exempted host (`dsh plugin allow-version`) loads anyway and then fails
|
|
10
|
+
// somewhere deep — a model switch, a settings write — instead of saying why;
|
|
11
|
+
// * a version *inside* the range can still be incompatible, because the range
|
|
12
|
+
// is a promise about a whole line of releases: 0.2.2-rc.1 sat inside
|
|
13
|
+
// `>=0.1.1-rc.2 <0.3.0-0` and broke every model switch, because it moved the
|
|
14
|
+
// route to `agent.model.select()` and made the old waterfall throw.
|
|
15
|
+
//
|
|
16
|
+
// So the manifest carries what was actually run (`dshSupport.tested`) beside the
|
|
17
|
+
// range, and a boot on anything with no evidence refuses to start. The refusal
|
|
18
|
+
// is the point: a half-working interface is worse than one page naming the
|
|
19
|
+
// version to install.
|
|
20
|
+
//
|
|
21
|
+
// Everything here is pure and injectable — no terminal, no settings, no boot —
|
|
22
|
+
// so the verdict can be exercised without a host.
|
|
23
|
+
|
|
24
|
+
import { readFileSync } from 'node:fs'
|
|
25
|
+
import { createRequire } from 'node:module'
|
|
26
|
+
import { compareVersions, detectDshInstall } from './updates.js'
|
|
27
|
+
|
|
28
|
+
const MANIFEST = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'))
|
|
29
|
+
|
|
30
|
+
// `v0.2.1` and `0.2.1 ` are the same version; an empty or non-string input is
|
|
31
|
+
// "unknown", never "matches".
|
|
32
|
+
export function normalizeDshVersion(value) {
|
|
33
|
+
if (typeof value !== 'string') return null
|
|
34
|
+
const trimmed = value.trim().replace(/^v/i, '')
|
|
35
|
+
return /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(trimmed) ? trimmed : null
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// The one range syntax this plugin's manifest uses: space-separated comparators
|
|
39
|
+
// are ANDed, `||` separates alternatives. `^`, `~` and `x` wildcards are
|
|
40
|
+
// deliberately rejected instead of half-supported — a manifest typo must fail
|
|
41
|
+
// here, where a test can see it, rather than quietly widen what we accept.
|
|
42
|
+
const COMPARATOR = /^(>=|<=|>|<|=)?\s*v?(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)$/
|
|
43
|
+
|
|
44
|
+
export function parseDshRange(range) {
|
|
45
|
+
if (typeof range !== 'string' || range.trim() === '') return null
|
|
46
|
+
const alternatives = []
|
|
47
|
+
for (const part of range.split('||')) {
|
|
48
|
+
const comparators = []
|
|
49
|
+
for (const token of part.trim().split(/\s+/).filter(Boolean)) {
|
|
50
|
+
const match = COMPARATOR.exec(token)
|
|
51
|
+
if (!match) return null
|
|
52
|
+
comparators.push({ op: match[1] ?? '=', version: match[2] })
|
|
53
|
+
}
|
|
54
|
+
if (comparators.length === 0) return null
|
|
55
|
+
alternatives.push(comparators)
|
|
56
|
+
}
|
|
57
|
+
return alternatives.length === 0 ? null : alternatives
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// true / false, or null when either side cannot be understood — the caller must
|
|
61
|
+
// distinguish "does not match" from "cannot tell", because only the first one
|
|
62
|
+
// justifies refusing to start.
|
|
63
|
+
export function satisfiesDshRange(version, range) {
|
|
64
|
+
const alternatives = parseDshRange(range)
|
|
65
|
+
const actual = normalizeDshVersion(version)
|
|
66
|
+
if (!alternatives || !actual) return null
|
|
67
|
+
return alternatives.some((comparators) => comparators.every(({ op, version: bound }) => {
|
|
68
|
+
const order = compareVersions(actual, bound)
|
|
69
|
+
switch (op) {
|
|
70
|
+
case '>=': return order >= 0
|
|
71
|
+
case '>': return order > 0
|
|
72
|
+
case '<=': return order <= 0
|
|
73
|
+
case '<': return order < 0
|
|
74
|
+
default: return order === 0
|
|
75
|
+
}
|
|
76
|
+
}))
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// What this build declares: the hard range dsh itself enforces, the version to
|
|
80
|
+
// point a user at, and the ones this build was actually run against.
|
|
81
|
+
export function dshSupport(manifest = MANIFEST) {
|
|
82
|
+
const declared = manifest?.dshSupport ?? {}
|
|
83
|
+
const range = typeof manifest?.engines?.dsh === 'string' ? manifest.engines.dsh : ''
|
|
84
|
+
const verified = Array.isArray(declared.tested) ? declared.tested.map(normalizeDshVersion).filter(Boolean) : []
|
|
85
|
+
return {
|
|
86
|
+
range,
|
|
87
|
+
recommended: normalizeDshVersion(declared.recommended) ?? verified.at(-1) ?? '',
|
|
88
|
+
tested: verified,
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// The verdict for one running version.
|
|
93
|
+
//
|
|
94
|
+
// ok — declared by the range *and* actually run against
|
|
95
|
+
// unverified — inside the declared range, but this build never saw it
|
|
96
|
+
// unsupported — outside the declared range (dsh normally disables us here)
|
|
97
|
+
// unknown — the version could not be read, so there is nothing to judge
|
|
98
|
+
//
|
|
99
|
+
// `unknown` never refuses: bricking a distribution we cannot introspect would be
|
|
100
|
+
// a bug in this module, not a fact about the host.
|
|
101
|
+
export function evaluateDshSupport(actual, support = dshSupport()) {
|
|
102
|
+
const version = normalizeDshVersion(actual)
|
|
103
|
+
const base = { version, ...support }
|
|
104
|
+
if (!version) return { verdict: 'unknown', ...base }
|
|
105
|
+
const inRange = satisfiesDshRange(version, support.range)
|
|
106
|
+
if (inRange === null) return { verdict: 'unknown', ...base }
|
|
107
|
+
if (!inRange) return { verdict: 'unsupported', ...base }
|
|
108
|
+
if (support.tested.length > 0 && !support.tested.includes(version)) return { verdict: 'unverified', ...base }
|
|
109
|
+
return { verdict: 'ok', ...base }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// The version of the harness this process is running under.
|
|
113
|
+
//
|
|
114
|
+
// Three sources, most authoritative first:
|
|
115
|
+
//
|
|
116
|
+
// 1. `@deepseek-ai/dsh-app-boot/package.json` resolved from the process entry.
|
|
117
|
+
// That package is what the host itself reads as its runtime version
|
|
118
|
+
// (`getDshRuntimeVersion()` in dsh-app-boot) and it is versioned in lockstep
|
|
119
|
+
// with the CLI, so this answers exactly what a user means by "the dsh
|
|
120
|
+
// version".
|
|
121
|
+
// 2. `@deepseek-ai/dsh/package.json` from the same base, for a host that ships
|
|
122
|
+
// the CLI without app-boot.
|
|
123
|
+
// 3. the `dsh` on PATH, for a host started some other way (a desktop bundle, an
|
|
124
|
+
// SDK runner) — right whenever the process was launched by the CLI that PATH
|
|
125
|
+
// names.
|
|
126
|
+
//
|
|
127
|
+
// Nothing found means "unknown", and the gate lets it through.
|
|
128
|
+
export function runningDshVersion({ entry = process.argv[1], resolve = resolveFromEntry, detect = detectDshInstall } = {}) {
|
|
129
|
+
if (typeof entry === 'string' && entry !== '') {
|
|
130
|
+
for (const spec of ['@deepseek-ai/dsh-app-boot/package.json', '@deepseek-ai/dsh/package.json']) {
|
|
131
|
+
const manifest = resolve(entry, spec)
|
|
132
|
+
const version = normalizeDshVersion(manifest?.version)
|
|
133
|
+
if (version) return { version, source: spec }
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
let install
|
|
137
|
+
try { install = detect() } catch { install = null }
|
|
138
|
+
const version = install?.found ? normalizeDshVersion(install.version) : null
|
|
139
|
+
if (version) return { version, source: 'path' }
|
|
140
|
+
return { version: null, source: 'unknown' }
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function resolveFromEntry(entry, spec) {
|
|
144
|
+
try {
|
|
145
|
+
return JSON.parse(readFileSync(createRequire(entry).resolve(spec), 'utf8'))
|
|
146
|
+
} catch {
|
|
147
|
+
return null
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// The boot gate: one call that answers "may this build start?" with everything a
|
|
152
|
+
// blocking page needs. `DSH_TUI_IGNORE_DSH_VERSION` is the escape hatch for a
|
|
153
|
+
// maintainer testing against a host that is not published or not yet verified.
|
|
154
|
+
export function dshBootGate({ env = process.env, support = dshSupport(), probe = runningDshVersion } = {}) {
|
|
155
|
+
const override = String(env?.DSH_TUI_IGNORE_DSH_VERSION ?? '').trim()
|
|
156
|
+
if (override !== '' && override !== '0') return null
|
|
157
|
+
const { version, source } = probe()
|
|
158
|
+
const result = evaluateDshSupport(version, support)
|
|
159
|
+
if (result.verdict === 'ok' || result.verdict === 'unknown') return null
|
|
160
|
+
return { ...result, source }
|
|
161
|
+
}
|
package/lib/git-branch.js
CHANGED
|
@@ -1,157 +1,157 @@
|
|
|
1
|
-
// Git branch switching for the TUI's empty page: listing local and remote
|
|
2
|
-
// branches, checking one out, and pulling updates. Everything shells out to
|
|
3
|
-
// the `git` on PATH through an argument array (no shell is involved), and
|
|
4
|
-
// every entry point is fenced to a real worktree first, so a workspace that
|
|
5
|
-
// is not a repository — or a machine with no git at all — reports that
|
|
6
|
-
// instead of half-applying anything.
|
|
7
|
-
import { spawn } from 'node:child_process'
|
|
8
|
-
import { resolve } from 'node:path'
|
|
9
|
-
import { t } from './i18n.js'
|
|
10
|
-
|
|
11
|
-
// A network operation can hang on an unreachable host long past the point
|
|
12
|
-
// where the user has stopped watching the window, so every spawn carries a
|
|
13
|
-
// bound; a kill resolves as a failure rather than leaving the promise open.
|
|
14
|
-
const GIT_TIMEOUT_MS = 120_000
|
|
15
|
-
|
|
16
|
-
// GIT_TERMINAL_PROMPT=0 turns a credential prompt into an immediate error:
|
|
17
|
-
// the pipes below already keep git off the terminal, and this makes the
|
|
18
|
-
// promise reject instead of blocking on input that can never arrive.
|
|
19
|
-
function git(cwd, args) {
|
|
20
|
-
return new Promise((resolvePromise) => {
|
|
21
|
-
const child = spawn('git', args, {
|
|
22
|
-
cwd,
|
|
23
|
-
windowsHide: true,
|
|
24
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
25
|
-
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
|
|
26
|
-
})
|
|
27
|
-
const stdout = []
|
|
28
|
-
const stderr = []
|
|
29
|
-
let settled = false
|
|
30
|
-
const finish = (result) => {
|
|
31
|
-
if (settled) return
|
|
32
|
-
settled = true
|
|
33
|
-
resolvePromise(result)
|
|
34
|
-
}
|
|
35
|
-
const timer = setTimeout(() => {
|
|
36
|
-
child.kill()
|
|
37
|
-
finish({ ok: false, spawned: true, stdout: '', stderr: t('git.timedOut') })
|
|
38
|
-
}, GIT_TIMEOUT_MS)
|
|
39
|
-
child.stdout?.on('data', (chunk) => { stdout.push(chunk) })
|
|
40
|
-
child.stderr?.on('data', (chunk) => { stderr.push(chunk) })
|
|
41
|
-
child.on('error', (error) => {
|
|
42
|
-
clearTimeout(timer)
|
|
43
|
-
// A spawn failure (git not on PATH) is not the same as git saying no:
|
|
44
|
-
// `spawned: false` lets callers tell the two apart.
|
|
45
|
-
finish({ ok: false, spawned: false, stdout: '', stderr: error.message })
|
|
46
|
-
})
|
|
47
|
-
child.on('close', (code) => {
|
|
48
|
-
clearTimeout(timer)
|
|
49
|
-
finish({
|
|
50
|
-
ok: code === 0,
|
|
51
|
-
spawned: true,
|
|
52
|
-
stdout: Buffer.concat(stdout).toString('utf8'),
|
|
53
|
-
stderr: Buffer.concat(stderr).toString('utf8'),
|
|
54
|
-
})
|
|
55
|
-
})
|
|
56
|
-
})
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
function lines(text) {
|
|
60
|
-
return String(text ?? '')
|
|
61
|
-
.split(/\r?\n/)
|
|
62
|
-
.map((line) => line.trim())
|
|
63
|
-
.filter((line) => line !== '')
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
// The first line of git's output, which is where its errors put the reason
|
|
67
|
-
// ("error: pathspec ... did not match"). Later lines are hints and blank
|
|
68
|
-
// padding that would only crowd a one-line report.
|
|
69
|
-
function firstLine(text) {
|
|
70
|
-
return lines(text)[0] ?? ''
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
// The last line of the combined output: fetch's "Fetching origin" and pull's
|
|
74
|
-
// "From ..." lead, while the line that answers "did anything change" —
|
|
75
|
-
// "Already up to date.", the fast-forward diffstat — comes last.
|
|
76
|
-
function lastLine(text) {
|
|
77
|
-
const all = lines(text)
|
|
78
|
-
return all.length > 0 ? all[all.length - 1].slice(0, 72) : ''
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
// The worktree root, or a throw that says why there is none. Every public
|
|
82
|
-
// function starts here. `--show-toplevel` is one spawn that answers both
|
|
83
|
-
// questions: it prints the root inside a worktree (an unborn one included)
|
|
84
|
-
// and fails inside a bare repository or outside any repository at all.
|
|
85
|
-
async function worktreeRoot(cwd) {
|
|
86
|
-
const root = await git(resolve(cwd), ['rev-parse', '--show-toplevel'])
|
|
87
|
-
if (!root.spawned) throw new Error(t('git.gitMissing'))
|
|
88
|
-
if (!root.ok || root.stdout.trim() === '') throw new Error(t('git.notRepo'))
|
|
89
|
-
return root.stdout.trim()
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
// Local branches (the current one included, in git's own alphabetical order)
|
|
93
|
-
// and remote-tracking branches. `refs/remotes/*/HEAD` is a symbolic ref that
|
|
94
|
-
// names no branch of its own, so it is filtered out rather than offered as a
|
|
95
|
-
// checkout target.
|
|
96
|
-
export async function listGitBranches(cwd) {
|
|
97
|
-
const root = await worktreeRoot(cwd)
|
|
98
|
-
const [local, remote, head] = await Promise.all([
|
|
99
|
-
git(root, ['for-each-ref', '--format=%(refname:short)', 'refs/heads']),
|
|
100
|
-
git(root, ['for-each-ref', '--format=%(refname:short)', 'refs/remotes']),
|
|
101
|
-
// symbolic-ref exits non-zero on a detached or unborn HEAD, which is the
|
|
102
|
-
// honest "no current branch" answer the window renders as (detached).
|
|
103
|
-
git(root, ['symbolic-ref', '--quiet', '--short', 'HEAD']),
|
|
104
|
-
])
|
|
105
|
-
for (const result of [local, remote, head]) {
|
|
106
|
-
if (!result.ok) throw new Error(firstLine(result.stderr) || t('git.listFailed', { error: '' }))
|
|
107
|
-
}
|
|
108
|
-
return {
|
|
109
|
-
root,
|
|
110
|
-
current: head.stdout.trim(),
|
|
111
|
-
local: lines(local.stdout),
|
|
112
|
-
remote: lines(remote.stdout).filter((name) => !/\/HEAD$/.test(name)),
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
// Check out a branch. A remote-tracking ref is switched through the local
|
|
117
|
-
// branch git's own DWIM would create for it (`origin/feature` -> `feature`),
|
|
118
|
-
// unless a local branch of that name already exists — then that one wins, so
|
|
119
|
-
// the user never lands on a second branch tracking the same upstream.
|
|
120
|
-
export async function switchGitBranch(cwd, ref, { remote = false } = {}) {
|
|
121
|
-
const root = await worktreeRoot(cwd)
|
|
122
|
-
let target = ref
|
|
123
|
-
if (remote) {
|
|
124
|
-
const slash = ref.indexOf('/')
|
|
125
|
-
const local = slash < 0 ? ref : ref.slice(slash + 1)
|
|
126
|
-
const locals = await git(root, ['for-each-ref', '--format=%(refname:short)', 'refs/heads'])
|
|
127
|
-
if (!locals.spawned) throw new Error(t('git.gitMissing'))
|
|
128
|
-
if (!locals.ok) throw new Error(firstLine(locals.stderr))
|
|
129
|
-
const localNames = lines(locals.stdout)
|
|
130
|
-
if (!localNames.includes(local)) {
|
|
131
|
-
const created = await git(root, ['checkout', '-b', local, '--track', ref])
|
|
132
|
-
if (!created.spawned) throw new Error(t('git.gitMissing'))
|
|
133
|
-
if (!created.ok) throw new Error(firstLine(created.stderr) || t('git.switchFailed', { branch: ref, error: '' }))
|
|
134
|
-
return { branch: local, root }
|
|
135
|
-
}
|
|
136
|
-
target = local
|
|
137
|
-
}
|
|
138
|
-
const result = await git(root, ['checkout', target])
|
|
139
|
-
if (!result.spawned) throw new Error(t('git.gitMissing'))
|
|
140
|
-
if (!result.ok) throw new Error(firstLine(result.stderr) || t('git.switchFailed', { branch: target, error: '' }))
|
|
141
|
-
return { branch: target, root }
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
// Manual pull: refresh every remote-tracking ref, then fast-forward the
|
|
145
|
-
// current branch. `--ff-only` is deliberate — a TUI has no way to resolve a
|
|
146
|
-
// merge conflict, so a diverged branch must fail with git's own explanation
|
|
147
|
-
// rather than stop halfway with conflict markers in the worktree.
|
|
148
|
-
export async function pullGitUpdates(cwd) {
|
|
149
|
-
const root = await worktreeRoot(cwd)
|
|
150
|
-
const fetched = await git(root, ['fetch', '--all', '--prune'])
|
|
151
|
-
if (!fetched.spawned) throw new Error(t('git.gitMissing'))
|
|
152
|
-
if (!fetched.ok) throw new Error(firstLine(fetched.stderr) || t('git.pullFailed', { error: '' }))
|
|
153
|
-
const pulled = await git(root, ['pull', '--ff-only'])
|
|
154
|
-
if (!pulled.spawned) throw new Error(t('git.gitMissing'))
|
|
155
|
-
if (!pulled.ok) throw new Error(firstLine(pulled.stderr) || t('git.pullFailed', { error: '' }))
|
|
156
|
-
return { summary: lastLine(fetched.stderr + '\n' + fetched.stdout + '\n' + pulled.stdout + '\n' + pulled.stderr) }
|
|
157
|
-
}
|
|
1
|
+
// Git branch switching for the TUI's empty page: listing local and remote
|
|
2
|
+
// branches, checking one out, and pulling updates. Everything shells out to
|
|
3
|
+
// the `git` on PATH through an argument array (no shell is involved), and
|
|
4
|
+
// every entry point is fenced to a real worktree first, so a workspace that
|
|
5
|
+
// is not a repository — or a machine with no git at all — reports that
|
|
6
|
+
// instead of half-applying anything.
|
|
7
|
+
import { spawn } from 'node:child_process'
|
|
8
|
+
import { resolve } from 'node:path'
|
|
9
|
+
import { t } from './i18n.js'
|
|
10
|
+
|
|
11
|
+
// A network operation can hang on an unreachable host long past the point
|
|
12
|
+
// where the user has stopped watching the window, so every spawn carries a
|
|
13
|
+
// bound; a kill resolves as a failure rather than leaving the promise open.
|
|
14
|
+
const GIT_TIMEOUT_MS = 120_000
|
|
15
|
+
|
|
16
|
+
// GIT_TERMINAL_PROMPT=0 turns a credential prompt into an immediate error:
|
|
17
|
+
// the pipes below already keep git off the terminal, and this makes the
|
|
18
|
+
// promise reject instead of blocking on input that can never arrive.
|
|
19
|
+
function git(cwd, args) {
|
|
20
|
+
return new Promise((resolvePromise) => {
|
|
21
|
+
const child = spawn('git', args, {
|
|
22
|
+
cwd,
|
|
23
|
+
windowsHide: true,
|
|
24
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
25
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
|
|
26
|
+
})
|
|
27
|
+
const stdout = []
|
|
28
|
+
const stderr = []
|
|
29
|
+
let settled = false
|
|
30
|
+
const finish = (result) => {
|
|
31
|
+
if (settled) return
|
|
32
|
+
settled = true
|
|
33
|
+
resolvePromise(result)
|
|
34
|
+
}
|
|
35
|
+
const timer = setTimeout(() => {
|
|
36
|
+
child.kill()
|
|
37
|
+
finish({ ok: false, spawned: true, stdout: '', stderr: t('git.timedOut') })
|
|
38
|
+
}, GIT_TIMEOUT_MS)
|
|
39
|
+
child.stdout?.on('data', (chunk) => { stdout.push(chunk) })
|
|
40
|
+
child.stderr?.on('data', (chunk) => { stderr.push(chunk) })
|
|
41
|
+
child.on('error', (error) => {
|
|
42
|
+
clearTimeout(timer)
|
|
43
|
+
// A spawn failure (git not on PATH) is not the same as git saying no:
|
|
44
|
+
// `spawned: false` lets callers tell the two apart.
|
|
45
|
+
finish({ ok: false, spawned: false, stdout: '', stderr: error.message })
|
|
46
|
+
})
|
|
47
|
+
child.on('close', (code) => {
|
|
48
|
+
clearTimeout(timer)
|
|
49
|
+
finish({
|
|
50
|
+
ok: code === 0,
|
|
51
|
+
spawned: true,
|
|
52
|
+
stdout: Buffer.concat(stdout).toString('utf8'),
|
|
53
|
+
stderr: Buffer.concat(stderr).toString('utf8'),
|
|
54
|
+
})
|
|
55
|
+
})
|
|
56
|
+
})
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function lines(text) {
|
|
60
|
+
return String(text ?? '')
|
|
61
|
+
.split(/\r?\n/)
|
|
62
|
+
.map((line) => line.trim())
|
|
63
|
+
.filter((line) => line !== '')
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// The first line of git's output, which is where its errors put the reason
|
|
67
|
+
// ("error: pathspec ... did not match"). Later lines are hints and blank
|
|
68
|
+
// padding that would only crowd a one-line report.
|
|
69
|
+
function firstLine(text) {
|
|
70
|
+
return lines(text)[0] ?? ''
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// The last line of the combined output: fetch's "Fetching origin" and pull's
|
|
74
|
+
// "From ..." lead, while the line that answers "did anything change" —
|
|
75
|
+
// "Already up to date.", the fast-forward diffstat — comes last.
|
|
76
|
+
function lastLine(text) {
|
|
77
|
+
const all = lines(text)
|
|
78
|
+
return all.length > 0 ? all[all.length - 1].slice(0, 72) : ''
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// The worktree root, or a throw that says why there is none. Every public
|
|
82
|
+
// function starts here. `--show-toplevel` is one spawn that answers both
|
|
83
|
+
// questions: it prints the root inside a worktree (an unborn one included)
|
|
84
|
+
// and fails inside a bare repository or outside any repository at all.
|
|
85
|
+
async function worktreeRoot(cwd) {
|
|
86
|
+
const root = await git(resolve(cwd), ['rev-parse', '--show-toplevel'])
|
|
87
|
+
if (!root.spawned) throw new Error(t('git.gitMissing'))
|
|
88
|
+
if (!root.ok || root.stdout.trim() === '') throw new Error(t('git.notRepo'))
|
|
89
|
+
return root.stdout.trim()
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Local branches (the current one included, in git's own alphabetical order)
|
|
93
|
+
// and remote-tracking branches. `refs/remotes/*/HEAD` is a symbolic ref that
|
|
94
|
+
// names no branch of its own, so it is filtered out rather than offered as a
|
|
95
|
+
// checkout target.
|
|
96
|
+
export async function listGitBranches(cwd) {
|
|
97
|
+
const root = await worktreeRoot(cwd)
|
|
98
|
+
const [local, remote, head] = await Promise.all([
|
|
99
|
+
git(root, ['for-each-ref', '--format=%(refname:short)', 'refs/heads']),
|
|
100
|
+
git(root, ['for-each-ref', '--format=%(refname:short)', 'refs/remotes']),
|
|
101
|
+
// symbolic-ref exits non-zero on a detached or unborn HEAD, which is the
|
|
102
|
+
// honest "no current branch" answer the window renders as (detached).
|
|
103
|
+
git(root, ['symbolic-ref', '--quiet', '--short', 'HEAD']),
|
|
104
|
+
])
|
|
105
|
+
for (const result of [local, remote, head]) {
|
|
106
|
+
if (!result.ok) throw new Error(firstLine(result.stderr) || t('git.listFailed', { error: '' }))
|
|
107
|
+
}
|
|
108
|
+
return {
|
|
109
|
+
root,
|
|
110
|
+
current: head.stdout.trim(),
|
|
111
|
+
local: lines(local.stdout),
|
|
112
|
+
remote: lines(remote.stdout).filter((name) => !/\/HEAD$/.test(name)),
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Check out a branch. A remote-tracking ref is switched through the local
|
|
117
|
+
// branch git's own DWIM would create for it (`origin/feature` -> `feature`),
|
|
118
|
+
// unless a local branch of that name already exists — then that one wins, so
|
|
119
|
+
// the user never lands on a second branch tracking the same upstream.
|
|
120
|
+
export async function switchGitBranch(cwd, ref, { remote = false } = {}) {
|
|
121
|
+
const root = await worktreeRoot(cwd)
|
|
122
|
+
let target = ref
|
|
123
|
+
if (remote) {
|
|
124
|
+
const slash = ref.indexOf('/')
|
|
125
|
+
const local = slash < 0 ? ref : ref.slice(slash + 1)
|
|
126
|
+
const locals = await git(root, ['for-each-ref', '--format=%(refname:short)', 'refs/heads'])
|
|
127
|
+
if (!locals.spawned) throw new Error(t('git.gitMissing'))
|
|
128
|
+
if (!locals.ok) throw new Error(firstLine(locals.stderr))
|
|
129
|
+
const localNames = lines(locals.stdout)
|
|
130
|
+
if (!localNames.includes(local)) {
|
|
131
|
+
const created = await git(root, ['checkout', '-b', local, '--track', ref])
|
|
132
|
+
if (!created.spawned) throw new Error(t('git.gitMissing'))
|
|
133
|
+
if (!created.ok) throw new Error(firstLine(created.stderr) || t('git.switchFailed', { branch: ref, error: '' }))
|
|
134
|
+
return { branch: local, root }
|
|
135
|
+
}
|
|
136
|
+
target = local
|
|
137
|
+
}
|
|
138
|
+
const result = await git(root, ['checkout', target])
|
|
139
|
+
if (!result.spawned) throw new Error(t('git.gitMissing'))
|
|
140
|
+
if (!result.ok) throw new Error(firstLine(result.stderr) || t('git.switchFailed', { branch: target, error: '' }))
|
|
141
|
+
return { branch: target, root }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Manual pull: refresh every remote-tracking ref, then fast-forward the
|
|
145
|
+
// current branch. `--ff-only` is deliberate — a TUI has no way to resolve a
|
|
146
|
+
// merge conflict, so a diverged branch must fail with git's own explanation
|
|
147
|
+
// rather than stop halfway with conflict markers in the worktree.
|
|
148
|
+
export async function pullGitUpdates(cwd) {
|
|
149
|
+
const root = await worktreeRoot(cwd)
|
|
150
|
+
const fetched = await git(root, ['fetch', '--all', '--prune'])
|
|
151
|
+
if (!fetched.spawned) throw new Error(t('git.gitMissing'))
|
|
152
|
+
if (!fetched.ok) throw new Error(firstLine(fetched.stderr) || t('git.pullFailed', { error: '' }))
|
|
153
|
+
const pulled = await git(root, ['pull', '--ff-only'])
|
|
154
|
+
if (!pulled.spawned) throw new Error(t('git.gitMissing'))
|
|
155
|
+
if (!pulled.ok) throw new Error(firstLine(pulled.stderr) || t('git.pullFailed', { error: '' }))
|
|
156
|
+
return { summary: lastLine(fetched.stderr + '\n' + fetched.stdout + '\n' + pulled.stdout + '\n' + pulled.stderr) }
|
|
157
|
+
}
|