@young1lin/dsh-ui-gitworkbench 0.1.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.
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Per-project and global drawer styling: a background image and custom CSS.
3
+ *
4
+ * These live on the host rather than in the browser for two reasons. A project
5
+ * setting belongs to the project, so it must survive a different browser or a
6
+ * cleared origin; and a background image is far larger than a localStorage
7
+ * origin quota is willing to hold.
8
+ *
9
+ * The file is a durable boundary: everything read back is validated here, and
10
+ * anything unrecognized is dropped rather than propagated. The image in
11
+ * particular is interpolated into a CSS `url()` by the client, so it is held to
12
+ * a base64 `data:` URL with no character that could close the function and
13
+ * continue the stylesheet.
14
+ */
15
+ import { join } from 'node:path';
16
+ /** No image, no CSS, and the defaults the sliders open on. */
17
+ export const DEFAULT_STYLE = { css: '', image: '', blur: 18, veil: 78 };
18
+ /** Largest accepted image data URL. A 2560px JPEG lands far below this; the cap
19
+ * exists so a hand-edited file cannot make every drawer open drag megabytes. */
20
+ export const STYLE_IMAGE_MAX = 3_000_000;
21
+ /** Largest accepted custom stylesheet. */
22
+ export const STYLE_CSS_MAX = 200_000;
23
+ /** Largest accepted blur radius, in px. */
24
+ export const STYLE_BLUR_MAX = 60;
25
+ /**
26
+ * Images this plugin will render.
27
+ *
28
+ * Deliberately narrow: the client interpolates the value into `url("…")`, and a
29
+ * base64 alphabet cannot contain a quote, a parenthesis, a backslash or a
30
+ * semicolon, so a stored value can never close the function and append rules of
31
+ * its own. It is also exactly what a canvas `toDataURL` produces, so nothing a
32
+ * user can select through the picker is rejected.
33
+ */
34
+ const IMAGE_PATTERN = /^data:image\/(?:png|jpeg|webp|gif|avif);base64,[A-Za-z0-9+/]+={0,2}$/;
35
+ /**
36
+ * @param home - the user's home directory.
37
+ * @returns the style file's path, forward-slashed.
38
+ */
39
+ export function stylePath(home) {
40
+ return join(home, '.dsh', 'gitworkbench-style.json').replace(/\\/g, '/');
41
+ }
42
+ /**
43
+ * @param value - a number from a file or an RPC argument.
44
+ * @param min - lower bound.
45
+ * @param max - upper bound.
46
+ * @param fallback - used when the value is not a finite number.
47
+ * @returns the value clamped into range.
48
+ */
49
+ function clampNumber(value, min, max, fallback) {
50
+ if (typeof value !== 'number' || !Number.isFinite(value))
51
+ return fallback;
52
+ return Math.min(Math.max(value, min), max);
53
+ }
54
+ /**
55
+ * Narrow an arbitrary value to a complete, in-range style entry.
56
+ * @param value - parsed JSON or an RPC argument.
57
+ * @returns a valid entry; every rejected field falls back to its default.
58
+ */
59
+ export function sanitizeEntry(value) {
60
+ if (typeof value !== 'object' || value === null)
61
+ return DEFAULT_STYLE;
62
+ const record = value;
63
+ const css = typeof record['css'] === 'string' && record['css'].length <= STYLE_CSS_MAX ? record['css'] : '';
64
+ const raw = record['image'];
65
+ const image = typeof raw === 'string' && raw.length <= STYLE_IMAGE_MAX && IMAGE_PATTERN.test(raw) ? raw : '';
66
+ return {
67
+ css,
68
+ image,
69
+ blur: clampNumber(record['blur'], 0, STYLE_BLUR_MAX, DEFAULT_STYLE.blur),
70
+ veil: clampNumber(record['veil'], 0, 100, DEFAULT_STYLE.veil),
71
+ };
72
+ }
73
+ /**
74
+ * @returns a style file with nothing configured.
75
+ */
76
+ export function emptyStyleFile() {
77
+ return { v: 1, global: DEFAULT_STYLE, projects: {} };
78
+ }
79
+ /**
80
+ * @param entry - a style entry.
81
+ * @returns whether it configures anything at all; an entry that does not is not
82
+ * worth storing, and storing it would shadow the global scope with nothing.
83
+ */
84
+ export function isBlankEntry(entry) {
85
+ return entry.css.length === 0 && entry.image.length === 0;
86
+ }
87
+ /**
88
+ * Parse the style file, dropping anything malformed.
89
+ * @param raw - the file's text.
90
+ * @returns a valid style file; a corrupt one reads as empty rather than failing.
91
+ */
92
+ export function parseStyle(raw) {
93
+ try {
94
+ const parsed = JSON.parse(raw);
95
+ if (parsed?.v !== 1)
96
+ return emptyStyleFile();
97
+ const projects = {};
98
+ if (typeof parsed.projects === 'object' && parsed.projects !== null) {
99
+ for (const [root, entry] of Object.entries(parsed.projects)) {
100
+ if (root.length > 0)
101
+ projects[root] = sanitizeEntry(entry);
102
+ }
103
+ }
104
+ return { v: 1, global: sanitizeEntry(parsed.global), projects };
105
+ }
106
+ catch {
107
+ // A half-written or hand-edited file must not take the drawer down with it.
108
+ return emptyStyleFile();
109
+ }
110
+ }
111
+ /**
112
+ * @param readText - reads a file's text.
113
+ * @param path - the style file's path.
114
+ * @returns the stored styles, or an empty file when absent or unreadable.
115
+ */
116
+ export async function loadStyle(readText, path) {
117
+ try {
118
+ return parseStyle(await readText(path));
119
+ }
120
+ catch {
121
+ return emptyStyleFile();
122
+ }
123
+ }
@@ -0,0 +1,112 @@
1
+ // src/worktree.ts — Task 1 delivers only the binding storage; later tasks append.
2
+ import { join } from 'node:path';
3
+ import { saveJsonAtomic } from './atomic-json.js';
4
+ const BINDINGS_FIELDS = ['repoRoot', 'worktreePath', 'name', 'enteredAt'];
5
+ function emptyFile() { return { v: 1, bindings: {} }; }
6
+ function isBinding(value) {
7
+ if (typeof value !== 'object' || value === null)
8
+ return false;
9
+ const record = value;
10
+ if (!BINDINGS_FIELDS.every(field => typeof record[field] === 'string' && record[field].length > 0))
11
+ return false;
12
+ // Absent is normal; present-but-malformed is corruption, and dropping the
13
+ // whole record beats trusting half of it.
14
+ const base = record['baseCommit'];
15
+ return base === undefined || (typeof base === 'string' && base.length > 0);
16
+ }
17
+ export function bindingsPath(home) {
18
+ return join(home, '.dsh', 'gitworkbench-worktree-bindings.json').replace(/\\/g, '/');
19
+ }
20
+ export function parseBindings(raw) {
21
+ try {
22
+ const parsed = JSON.parse(raw);
23
+ if (parsed?.v !== 1 || typeof parsed.bindings !== 'object' || parsed.bindings === null)
24
+ return emptyFile();
25
+ const out = emptyFile();
26
+ for (const [sessionId, value] of Object.entries(parsed.bindings)) {
27
+ if (sessionId.length > 0 && isBinding(value))
28
+ out.bindings[sessionId] = value;
29
+ }
30
+ return out;
31
+ }
32
+ catch {
33
+ return emptyFile();
34
+ }
35
+ }
36
+ export async function loadBindings(readText, path) {
37
+ try {
38
+ return parseBindings(await readText(path));
39
+ }
40
+ catch {
41
+ return emptyFile();
42
+ }
43
+ }
44
+ export async function saveBindings(ensureDir, writeText, rename, path, file) {
45
+ await saveJsonAtomic(ensureDir, writeText, rename, path, file);
46
+ }
47
+ // ---- Task 2: worktree name/branch/path derivation + porcelain parsing ----
48
+ const NAME_PATTERN = /^[A-Za-z0-9._-]{1,40}$/;
49
+ export function sanitizeName(raw, rng) {
50
+ if (raw !== undefined && NAME_PATTERN.test(raw) && raw !== '.' && raw !== '..')
51
+ return raw;
52
+ return `wt-${rng()}`;
53
+ }
54
+ export function branchFor(name) { return `wt/${name}`; }
55
+ /** Longest ref name accepted — well past any real branch, short of a payload. */
56
+ const REF_MAX_LENGTH = 200;
57
+ /** The character set git allows in a branch or tag name. */
58
+ const REF_CHARS = /^[A-Za-z0-9._/-]+$/;
59
+ /**
60
+ * Decide whether a ref name from an untrusted caller may be passed to git.
61
+ *
62
+ * A ref arrives from the browser as free text and becomes a POSITIONAL argument,
63
+ * so three things are rejected before it gets there: a leading `-`, which git
64
+ * would read as an option rather than a ref; `..`, which is range syntax and
65
+ * would silently change what a comparison covers; and any character outside the
66
+ * set git accepts in a ref name.
67
+ * @param ref - candidate ref name.
68
+ * @returns true when the value is safe to pass to git as a ref.
69
+ */
70
+ export function isRefName(ref) {
71
+ return typeof ref === 'string'
72
+ && ref.length > 0 && ref.length <= REF_MAX_LENGTH
73
+ && !ref.startsWith('-')
74
+ && !ref.includes('..')
75
+ && REF_CHARS.test(ref);
76
+ }
77
+ export function worktreeDir(repoRoot, name) {
78
+ return `${repoRoot.replace(/\/+$/, '')}/.agents/worktrees/${name}`;
79
+ }
80
+ export function parseWorktreeList(porcelain) {
81
+ const out = [];
82
+ let path = '';
83
+ let head = '';
84
+ let branch = '';
85
+ const flush = () => {
86
+ if (path.length > 0 && head.length > 0 && branch.length > 0) {
87
+ out.push({ path, head, branch });
88
+ }
89
+ path = '';
90
+ head = '';
91
+ branch = '';
92
+ };
93
+ for (const line of porcelain.split('\n')) {
94
+ if (line.length === 0) {
95
+ flush();
96
+ continue;
97
+ }
98
+ if (line.startsWith('worktree '))
99
+ path = line.slice('worktree '.length);
100
+ else if (line.startsWith('HEAD '))
101
+ head = line.slice('HEAD '.length);
102
+ else if (line.startsWith('branch refs/heads/'))
103
+ branch = line.slice('branch refs/heads/'.length);
104
+ else if (line === 'detached') {
105
+ path = '';
106
+ head = '';
107
+ branch = '';
108
+ }
109
+ }
110
+ flush();
111
+ return out;
112
+ }
package/package.json ADDED
@@ -0,0 +1,86 @@
1
+ {
2
+ "name": "@young1lin/dsh-ui-gitworkbench",
3
+ "version": "0.1.0",
4
+ "description": "Out-of-tree dsh web UI plugin: a session-header git workbench chip opening a drawer with the file tree, per-file diff, history, compare, staging, commit, and sync (fetch/pull/push).",
5
+ "type": "module",
6
+ "main": "lib/index.js",
7
+ "engines": {
8
+ "node": ">=20"
9
+ },
10
+ "publishConfig": {
11
+ "access": "public"
12
+ },
13
+ "files": [
14
+ "lib",
15
+ "!lib/*.map",
16
+ "src",
17
+ "scripts/install.sh",
18
+ "scripts/install.ps1",
19
+ "README.md",
20
+ "README_EN.md",
21
+ "AGENTS.md",
22
+ "LICENSE",
23
+ "cordis.patch.yml"
24
+ ],
25
+ "exports": {
26
+ ".": {
27
+ "default": "./lib/index.js"
28
+ },
29
+ "./client": {
30
+ "default": "./lib/client.js"
31
+ },
32
+ "./package.json": "./package.json"
33
+ },
34
+ "dsh": {
35
+ "bundle": {
36
+ "patch": "./cordis.patch.yml"
37
+ },
38
+ "client": {
39
+ "platform": "web",
40
+ "inject": [
41
+ "@deepseek-ai/dsh-client-runtime",
42
+ "@deepseek-ai/dsh-client-ui-slots",
43
+ "@deepseek-ai/dsh-client-ui-primitives"
44
+ ]
45
+ }
46
+ },
47
+ "scripts": {
48
+ "bundle": "tsc -p tsconfig.json && tsc -p tsconfig.client.json && tsdown",
49
+ "bundle:publish": "tsc -p tsconfig.json && tsc -p tsconfig.client.json && tsdown --no-sourcemap",
50
+ "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.client.json",
51
+ "test": "vitest run",
52
+ "watch": "tsdown --watch",
53
+ "prepack": "npm run bundle:publish"
54
+ },
55
+ "license": "MIT",
56
+ "repository": {
57
+ "type": "git",
58
+ "url": "git+https://github.com/young1lin/dsh-ui-gitworkbench.git"
59
+ },
60
+ "homepage": "https://github.com/young1lin/dsh-ui-gitworkbench#readme",
61
+ "bugs": {
62
+ "url": "https://github.com/young1lin/dsh-ui-gitworkbench/issues"
63
+ },
64
+ "packageManager": "pnpm@10.20.0",
65
+ "peerDependencies": {
66
+ "@deepseek-ai/cordis": "*",
67
+ "@deepseek-ai/dsh-client-runtime": "*",
68
+ "@deepseek-ai/dsh-client-ui-slots": "*",
69
+ "@deepseek-ai/dsh-tools": "*",
70
+ "@deepseek-ai/dsh-typert-protocol": "*",
71
+ "react": "^18.2.0"
72
+ },
73
+ "devDependencies": {
74
+ "@types/node": "^22.0.0",
75
+ "@types/react": "~18.3.1",
76
+ "lightningcss": "^1.33.0",
77
+ "react": "^18.2.0",
78
+ "tsdown": "0.22.2",
79
+ "typescript": "^6.0.3",
80
+ "vitest": "^4.1.10"
81
+ },
82
+ "dependencies": {
83
+ "@shikijs/langs": "4.3.1",
84
+ "shiki": "4.3.1"
85
+ }
86
+ }
@@ -0,0 +1,240 @@
1
+ # =============================================================================
2
+ # dsh-ui-gitworkbench one-shot installer (official CLI path; pwsh 7 / PS 5.1)
3
+ #
4
+ # Installs the npm package and mounts it through the official plugin command:
5
+ # dsh plugin --profile web add @young1lin/dsh-ui-gitworkbench@<version>
6
+ #
7
+ # The package declares dsh.bundle.patch (cordis.patch.yml), so the CLI's bundle
8
+ # coordination registers it into the profile's dsh.profile.bundles and mounts
9
+ # the host half on next start -- no manual cordis.patch.yml mount entry.
10
+ #
11
+ # Usage:
12
+ # irm https://raw.githubusercontent.com/young1lin/dsh-ui-gitworkbench/main/scripts/install.ps1 | iex
13
+ # & ([scriptblock]::Create((irm '<raw url>'))) -Version 0.1.0 -Restart
14
+ # powershell -ExecutionPolicy Bypass -File install.ps1 -DryRun
15
+ #
16
+ # Parameters:
17
+ # -Version npm version/range; default latest (resolved against the registry).
18
+ # -Restart After install, try `pm2 restart dsh-web` (hint only when pm2 is absent).
19
+ # -DryRun Print the planned steps, write nothing.
20
+ #
21
+ # Environment (all optional):
22
+ # DSH_HOME default %USERPROFILE%\.dsh
23
+ # REGISTRY default https://registry.npmjs.org
24
+ # DSH_CMD default: dsh on PATH, else npx -y --package @deepseek-ai/dsh
25
+ #
26
+ # Notes:
27
+ # - pnpm 11 minimumReleaseAge rejects versions published <24h ago. The script
28
+ # pre-writes minimumReleaseAgeExclude for this package (idempotent) so a
29
+ # fresh release installs on first try.
30
+ # - Older installs used a manual link: dependency + cordis.patch.yml mount
31
+ # entry. The script removes both idempotently (double mounting = two host
32
+ # halves and two chips).
33
+ # =============================================================================
34
+ param(
35
+ [string]$Version = '',
36
+ [switch]$Restart,
37
+ [switch]$DryRun
38
+ )
39
+
40
+ $PKG = '@young1lin/dsh-ui-gitworkbench'
41
+ $REGISTRY = if ($env:REGISTRY) { $env:REGISTRY } else { 'https://registry.npmjs.org' }
42
+
43
+ if ($env:DSH_HOME) {
44
+ $DSH_HOME = $env:DSH_HOME
45
+ } elseif ($env:USERPROFILE) {
46
+ $DSH_HOME = Join-Path $env:USERPROFILE '.dsh'
47
+ } else {
48
+ $DSH_HOME = Join-Path $HOME '.dsh'
49
+ }
50
+ $PROFILE_DIR = Join-Path $DSH_HOME 'profiles\web'
51
+ $WS_YML = Join-Path $PROFILE_DIR 'pnpm-workspace.yaml'
52
+ $PATCH_YML = Join-Path $PROFILE_DIR 'cordis.patch.yml'
53
+
54
+ function Say([string]$m) { Write-Host "[install] $m" -ForegroundColor Green }
55
+ function Warn([string]$m) { Write-Host "[warn] $m" -ForegroundColor Yellow }
56
+ function Die([string]$m) { Write-Host "[error] $m" -ForegroundColor Red; exit 1 }
57
+
58
+ function Resolve-Spec {
59
+ param([string]$Given)
60
+ if ([string]::IsNullOrWhiteSpace($Given) -or $Given -eq 'latest') {
61
+ # Accept only exit-code-0 output that looks like a version: on a 404 the
62
+ # registry query fails, and PS 5.1 can wrap stderr remnants (e.g. a
63
+ # help-remedy line) into the captured pipeline -- a truthiness check once
64
+ # resolved "latest" to the garbage string "npm help".
65
+ foreach ($tool in @('npm', 'pnpm')) {
66
+ if (Get-Command $tool -ErrorAction SilentlyContinue) {
67
+ $raw = & $tool view $PKG version "--registry=$REGISTRY" 2>$null
68
+ if ($LASTEXITCODE -eq 0) {
69
+ $v = ([string]($raw | Select-Object -Last 1)).Trim()
70
+ if ($v -match '^\d+\.\d+\.\d+') { return $v }
71
+ }
72
+ }
73
+ }
74
+ Warn 'Cannot resolve latest version (npm/pnpm query failed); falling back to latest.'
75
+ Warn 'If you know the version, pass it explicitly: -Version 0.1.0'
76
+ return 'latest'
77
+ }
78
+ return $Given
79
+ }
80
+
81
+ function Get-DshCli {
82
+ if ($env:DSH_CMD) { return $env:DSH_CMD }
83
+ if (Get-Command dsh -ErrorAction SilentlyContinue) { return 'dsh' }
84
+ if (Get-Command npx -ErrorAction SilentlyContinue) { return 'npx' }
85
+ return $null
86
+ }
87
+
88
+ if (-not (Get-Command node -ErrorAction SilentlyContinue)) {
89
+ Die 'node not found (DSH needs Node.js >= 20). Install Node.js first.'
90
+ }
91
+ if (-not (Test-Path $PROFILE_DIR)) {
92
+ Die "Profile directory not found: $PROFILE_DIR (run dsh web once first)"
93
+ }
94
+ if (-not (Test-Path $WS_YML)) {
95
+ Die "$WS_YML not found (initialize the web profile first)"
96
+ }
97
+
98
+ $SPEC = Resolve-Spec $Version
99
+ $CLI = Get-DshCli
100
+ if (-not $CLI) {
101
+ Die 'Neither dsh nor npx found. Install DSH (and Node/npm), or set DSH_CMD.'
102
+ }
103
+ $cliDisplay = if ($CLI -eq 'npx') { 'npx -y --package @deepseek-ai/dsh dsh' } else { $CLI }
104
+ Say "Target: $cliDisplay plugin --profile web add $PKG@$SPEC (profile: $PROFILE_DIR)"
105
+
106
+ if ($DryRun) {
107
+ Say "[dry-run] step 1: ensure $WS_YML has minimumReleaseAgeExclude ($PKG)"
108
+ Say "[dry-run] step 2: run $CLI plugin --profile web add $PKG@$SPEC (install + bundle registration)"
109
+ Say "[dry-run] step 3: verify dsh.profile.bundles contains $PKG"
110
+ Say "[dry-run] step 4: idempotently drop the old manual gitworkbench mount entry in $PATCH_YML (if any)"
111
+ if ($Restart) { Say '[dry-run] step 5: pm2 restart dsh-web' } else { Say '[dry-run] step 5: prompt for manual DSH restart' }
112
+ exit 0
113
+ }
114
+
115
+ # Step 1: pre-write workspace settings (idempotent) so pnpm 11 accepts a
116
+ # version published less than 24h ago.
117
+ $wsScript = @'
118
+ const fs = require("fs");
119
+ const p = process.argv[2];
120
+ const pkg = process.argv[3];
121
+ let t = fs.readFileSync(p, "utf8");
122
+ const before = t;
123
+ if (!new RegExp("^\\s*-\\s+" + pkg.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + "\\s*$", "m").test(t)) {
124
+ if (/^\s*minimumReleaseAgeExclude:\s*$/m.test(t)) {
125
+ t = t.replace(/^(\s*minimumReleaseAgeExclude:\s*)$/m, "$1\n - " + pkg);
126
+ } else {
127
+ t += "\nminimumReleaseAgeExclude:\n - " + pkg + "\n";
128
+ }
129
+ }
130
+ if (t !== before) fs.writeFileSync(p, t);
131
+ console.log(t === before ? "unchanged" : "updated");
132
+ '@
133
+ # PS 5.1 mangles embedded double quotes when a multi-line script is passed to
134
+ # `node -e` through the Windows command line; a temp file sidesteps that.
135
+ $wsJs = Join-Path $env:TEMP ("dshgw-ws-" + [guid]::NewGuid().ToString("N") + ".js")
136
+ Set-Content -LiteralPath $wsJs -Value $wsScript -Encoding UTF8
137
+ $wsOut = node $wsJs "$WS_YML" "$PKG" 2>&1
138
+ $wsCode = $LASTEXITCODE
139
+ Remove-Item -LiteralPath $wsJs -Force -ErrorAction SilentlyContinue
140
+ $wsResult = (($wsOut | Out-String)).Trim()
141
+ if ($wsCode -ne 0) { Die "Failed to update $WS_YML (node exit $wsCode): $wsResult" }
142
+ if ($wsResult -eq 'updated') {
143
+ Say "Ensured ${WS_YML}: minimumReleaseAgeExclude ($PKG)"
144
+ } else {
145
+ Say 'Workspace settings already fine, skipped'
146
+ }
147
+
148
+ # Step 2: official CLI install + bundle registration (mount included)
149
+ if ($CLI -eq 'dsh') {
150
+ $cliArgs = @('plugin', '--profile', 'web', 'add', "$PKG@$SPEC")
151
+ } else {
152
+ $cliArgs = @('-y', '--package', '@deepseek-ai/dsh', 'dsh', 'plugin', '--profile', 'web', 'add', "$PKG@$SPEC")
153
+ }
154
+ Say "Running $cliDisplay plugin --profile web add $PKG@$SPEC ..."
155
+ $addOut = & $CLI @cliArgs 2>&1
156
+ $addCode = $LASTEXITCODE
157
+ $addOut | ForEach-Object { $_ }
158
+ if ($addCode -ne 0) {
159
+ Warn 'dsh plugin add failed. Likely causes:'
160
+ Warn ' - network/auth: npm registry unreachable or auth required.'
161
+ Warn " - dependency conflict: retry manually: cd $PROFILE_DIR; pnpm install"
162
+ exit 1
163
+ }
164
+
165
+ # Step 3: verify the bundle got registered (the sign mounting took effect)
166
+ $pkgJson = Get-Content -Raw (Join-Path $PROFILE_DIR 'package.json') | ConvertFrom-Json
167
+ $bundles = $pkgJson.dsh.profile.bundles
168
+ if ($bundles -notcontains $PKG) {
169
+ Warn "$PKG missing from dsh.profile.bundles -- bundle registration failed."
170
+ exit 1
171
+ }
172
+ Say "Bundle registered: dsh.profile.bundles contains $PKG (mounts on next start)"
173
+
174
+ # Step 4: idempotently remove an old manual mount entry (double-mount guard)
175
+ if (Test-Path $PATCH_YML) {
176
+ $mountScript = @'
177
+ const fs = require("fs");
178
+ const p = process.argv[2];
179
+ const lines = fs.readFileSync(p, "utf8").split("\n");
180
+ const out = [];
181
+ let i = 0;
182
+ let removed = false;
183
+ while (i < lines.length) {
184
+ const line = lines[i];
185
+ if (/^[ \t]*- insert:\s*$/.test(line)) {
186
+ const block = [line];
187
+ let j = i + 1;
188
+ while (j < lines.length && lines[j].trim() !== "" && !/^-\s/.test(lines[j])) {
189
+ block.push(lines[j]);
190
+ j++;
191
+ }
192
+ if (block.some((l) => /id:\s*ui-gitworkbench\b/.test(l) || /name:\s*'\@young1lin\/dsh-ui-gitworkbench'/.test(l))) {
193
+ while (out.length && /^[ \t]*#/.test(out[out.length - 1])) out.pop();
194
+ i = j;
195
+ removed = true;
196
+ continue;
197
+ }
198
+ }
199
+ out.push(line);
200
+ i++;
201
+ }
202
+ if (!removed) {
203
+ console.log("none");
204
+ } else {
205
+ const t = out.join("\n").replace(/\n{3,}/g, "\n\n");
206
+ fs.writeFileSync(p, t);
207
+ console.log("removed");
208
+ }
209
+ '@
210
+ $mountJs = Join-Path $env:TEMP ("dshgw-mount-" + [guid]::NewGuid().ToString("N") + ".js")
211
+ Set-Content -LiteralPath $mountJs -Value $mountScript -Encoding UTF8
212
+ $mountOut = node $mountJs "$PATCH_YML" 2>&1
213
+ $mountCode = $LASTEXITCODE
214
+ Remove-Item -LiteralPath $mountJs -Force -ErrorAction SilentlyContinue
215
+ $mountResult = (($mountOut | Out-String)).Trim()
216
+ if ($mountCode -ne 0) { Die "Failed to update $PATCH_YML (node exit $mountCode): $mountResult" }
217
+ if ($mountResult -eq 'removed') {
218
+ Say "Removed the old manual gitworkbench mount entry from $PATCH_YML (bundle channel takes over)"
219
+ } else {
220
+ Say 'No stale manual mount entry, skipped'
221
+ }
222
+ }
223
+
224
+ Say "Install complete: $PKG@$SPEC"
225
+
226
+ # Step 5: restart guidance
227
+ if ($Restart) {
228
+ if (Get-Command pm2 -ErrorAction SilentlyContinue) {
229
+ Say 'Restarting dsh-web (pm2)...'
230
+ pm2 restart dsh-web
231
+ if ($LASTEXITCODE -ne 0) { Warn 'pm2 restart failed; restart DSH manually' }
232
+ } else {
233
+ Warn 'pm2 not found; restart DSH manually (e.g. pm2 restart dsh-web, or dsh web)'
234
+ }
235
+ } else {
236
+ Say 'Next: restart DSH and hard-refresh (Ctrl+Shift+R / Cmd+Shift+R).'
237
+ if (Get-Command pm2 -ErrorAction SilentlyContinue) {
238
+ Say 'Available here: pm2 restart dsh-web (briefly drops the current page session)'
239
+ }
240
+ }