@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.
- package/AGENTS.md +70 -0
- package/LICENSE +21 -0
- package/README.md +395 -0
- package/README_EN.md +74 -0
- package/cordis.patch.yml +7 -0
- package/lib/atomic-json.js +48 -0
- package/lib/client.js +15297 -0
- package/lib/commit-cache.js +68 -0
- package/lib/git-log.js +79 -0
- package/lib/git-ops.js +409 -0
- package/lib/index.js +1143 -0
- package/lib/style-store.js +123 -0
- package/lib/worktree.js +112 -0
- package/package.json +86 -0
- package/scripts/install.ps1 +240 -0
- package/scripts/install.sh +231 -0
- package/src/atomic-json.ts +55 -0
- package/src/client/GitWorkbenchPanel.module.css +1512 -0
- package/src/client/GitWorkbenchPanel.tsx +3446 -0
- package/src/client/commit-graph.ts +140 -0
- package/src/client/diff-model.ts +193 -0
- package/src/client/highlight.ts +257 -0
- package/src/client/index.ts +198 -0
- package/src/client/locales.ts +270 -0
- package/src/client/op-feedback.ts +65 -0
- package/src/client/stage-tree.ts +178 -0
- package/src/client/themes.ts +181 -0
- package/src/client/worktree-view.ts +193 -0
- package/src/commit-cache.ts +69 -0
- package/src/git-log.ts +92 -0
- package/src/git-ops.ts +490 -0
- package/src/index.ts +1172 -0
- package/src/style-store.ts +144 -0
- package/src/types/dsh-client-shim.d.ts +100 -0
- package/src/types/dsh-shim.d.ts +77 -0
- package/src/worktree.ts +142 -0
|
@@ -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
|
+
}
|
package/lib/worktree.js
ADDED
|
@@ -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
|
+
}
|