dsh-graft-plugin 0.3.4 → 0.3.5
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 +14 -5
- package/README.zh-CN.md +14 -5
- package/cordis.patch.yml +4 -0
- package/package.json +5 -2
- package/scripts/postinstall.mjs +84 -109
- package/scripts/test-ensure.mjs +143 -0
- package/src/auto-sync.js +14 -3
- package/src/ensure-graft.js +290 -0
- package/src/graft-tools.js +17 -87
- package/src/index.js +32 -0
package/README.md
CHANGED
|
@@ -42,13 +42,20 @@ cd ~/.dsh/profiles/web && node -e "const p=require('./package.json');
|
|
|
42
42
|
p.dsh.profile.bundles.includes('dsh-graft-plugin'))"
|
|
43
43
|
```
|
|
44
44
|
|
|
45
|
-
Every tool here shells out to the `graft` CLI, so it needs `@nanonets/graft`
|
|
45
|
+
Every tool here shells out to the `graft` CLI, so it needs `@nanonets/graft`. **From 0.3.5 the plugin installs it for you** the first time it is needed — the chip, the Build button, `graft viz` and the tools all trigger the same one-at-a-time install:
|
|
46
|
+
|
|
47
|
+
1. `npm install -g @nanonets/graft@latest`, run with a real node (never the Electron app binary) and npm's own `npm-cli.js`, no shell.
|
|
48
|
+
2. If the global prefix refuses (elevation, read-only prefix) or the install lands off PATH, it retries into a prefix the plugin owns: `<DSH_HOME>/graft-cli` (default `~/.dsh/graft-cli`). No elevation needed, survives plugin upgrades, one directory to delete for a clean uninstall. A graft you installed yourself always wins over it.
|
|
49
|
+
|
|
50
|
+
While that runs the chip says so; press Build again once it finishes. A failed attempt is not retried on every poll — the Build button forces a retry.
|
|
51
|
+
|
|
52
|
+
The `postinstall` runs the same installer for package managers that execute lifecycle scripts. pnpm 10+ blocks those by default (and ignores them for tarball installs even when approved), which is exactly why the install also lives in the host half: it runs regardless of package-manager policy. To do it by hand instead:
|
|
46
53
|
|
|
47
54
|
```sh
|
|
48
55
|
npm install -g @nanonets/graft
|
|
49
56
|
```
|
|
50
57
|
|
|
51
|
-
|
|
58
|
+
Opt out with `autoInstall: false` on the plugin's row, or `DSH_GRAFT_NO_AUTOINSTALL=1` per machine; pin what gets installed with `graftVersion` (default `latest`).
|
|
52
59
|
|
|
53
60
|
Then index a repo once:
|
|
54
61
|
|
|
@@ -76,7 +83,7 @@ The dialog switches to the **Installed** confirmation, and the plugin appears un
|
|
|
76
83
|
|
|
77
84
|

|
|
78
85
|
|
|
79
|
-
> The dialog notes that installed plugins **do not update automatically yet** — to upgrade, uninstall and install again. The graft CLI
|
|
86
|
+
> The dialog notes that installed plugins **do not update automatically yet** — to upgrade, uninstall and install again. The graft CLI is installed automatically on first use from 0.3.5.
|
|
80
87
|
|
|
81
88
|
### The sidebar tab
|
|
82
89
|
|
|
@@ -165,6 +172,8 @@ All optional, all on the plugin's row in your profile's `cordis.patch.yml`:
|
|
|
165
172
|
command: false # drop /graft
|
|
166
173
|
promptSection: false # stop telling the model to use graft
|
|
167
174
|
autoBuild: false # stop rebuilding after edits (the card's Build button still works)
|
|
175
|
+
autoInstall: false # never install graft automatically; report the npm command instead
|
|
176
|
+
graftVersion: '0.16.0' # pin what the auto-install requests (default 'latest')
|
|
168
177
|
```
|
|
169
178
|
|
|
170
179
|
## Requirements
|
|
@@ -178,7 +187,7 @@ All optional, all on the plugin's row in your profile's `cordis.patch.yml`:
|
|
|
178
187
|
| `0.2.0-rc.2` | needs plugin `0.2.1`+ — earlier releases fail to activate (see Troubleshooting) |
|
|
179
188
|
|
|
180
189
|
- Node 22+
|
|
181
|
-
- the `graft` CLI on
|
|
190
|
+
- the `graft` CLI — installed automatically on first use (see Install), or by hand with `npm install -g @nanonets/graft`. A bun global install (`bun add -g @nanonets/graft`) is detected too, via `$BUN_INSTALL` or `~/.bun`, as is the plugin's own `<DSH_HOME>/graft-cli` prefix.
|
|
182
191
|
|
|
183
192
|
The client bundle requires only `react`, which is why it survives the dsh `0.1.5-rc.2` client-package removals that broke several neighbouring plugins.
|
|
184
193
|
|
|
@@ -211,7 +220,7 @@ registering "dsh-graft-plugin" via __ModuleLoader__.load
|
|
|
211
220
|
|
|
212
221
|
**On dsh 0.2 the plugin fails to load with `strict codec has no create() factory`, and dsh offers to remove it.** dsh 0.2 requires every strict remote codec to carry a `create()` schema factory; plugin releases before `0.2.1` only had the 0.1 `schema` key. Update to `0.2.1` or later, which carries both and so loads on 0.1 and 0.2.
|
|
213
222
|
|
|
214
|
-
**`/graft` says the graft CLI is not on PATH.**
|
|
223
|
+
**`/graft` says the graft CLI is not on PATH.** On 0.3.5+ the plugin installs it on first use; if that failed (offline machine, refused prefix) the message carries npm's own output and the hand command: `npm install -g @nanonets/graft`. On older versions the postinstall was blocked or skipped (see Install).
|
|
215
224
|
|
|
216
225
|
## Licence
|
|
217
226
|
|
package/README.zh-CN.md
CHANGED
|
@@ -42,13 +42,20 @@ cd ~/.dsh/profiles/web && node -e "const p=require('./package.json');
|
|
|
42
42
|
p.dsh.profile.bundles.includes('dsh-graft-plugin'))"
|
|
43
43
|
```
|
|
44
44
|
|
|
45
|
-
这里的每个工具都要调用 `graft` CLI
|
|
45
|
+
这里的每个工具都要调用 `graft` CLI,所以需要 `@nanonets/graft`。**从 0.3.5 起插件会替你装好**:chip、Build 按钮、`graft viz` 和各个工具在第一次需要时触发同一次安装(同一时刻只跑一个):
|
|
46
|
+
|
|
47
|
+
1. `npm install -g @nanonets/graft@latest`,用真正的 node(绝不是 Electron 应用二进制)和 npm 自己的 `npm-cli.js` 执行,不经过 shell。
|
|
48
|
+
2. 若全局前缀拒绝(提权、只读前缀)或装到了 PATH 之外,则重试到插件自有的前缀:`<DSH_HOME>/graft-cli`(默认 `~/.dsh/graft-cli`)。不需要提权,插件升级后仍在,卸载时删一个目录即可。你自己装的 graft 永远优先于它。
|
|
49
|
+
|
|
50
|
+
安装期间 chip 会说明;装完后再按一次 Build。失败的尝试不会在每次轮询时重试——Build 按钮会强制重试。
|
|
51
|
+
|
|
52
|
+
`postinstall` 对愿意运行生命周期脚本的包管理器执行同一个安装器。pnpm 10+ 默认拦截这些脚本(tarball 安装即使批准也照样忽略),这正是安装逻辑同时放在 host 半边的原因:它不受包管理器策略影响。想手动装也可以:
|
|
46
53
|
|
|
47
54
|
```sh
|
|
48
55
|
npm install -g @nanonets/graft
|
|
49
56
|
```
|
|
50
57
|
|
|
51
|
-
`
|
|
58
|
+
在插件行上设 `autoInstall: false`、或按机器设 `DSH_GRAFT_NO_AUTOINSTALL=1` 可关闭自动安装;用 `graftVersion` 固定安装版本(默认 `latest`)。
|
|
52
59
|
|
|
53
60
|
然后给仓库建一次索引:
|
|
54
61
|
|
|
@@ -76,7 +83,7 @@ graft build
|
|
|
76
83
|
|
|
77
84
|

|
|
78
85
|
|
|
79
|
-
> 对话框也提示:已安装的插件**目前不会自动更新**——升级请先卸载再重装。graft CLI
|
|
86
|
+
> 对话框也提示:已安装的插件**目前不会自动更新**——升级请先卸载再重装。graft CLI 从 0.3.5 起在首次使用时自动安装。
|
|
80
87
|
|
|
81
88
|
### 侧边栏标签页
|
|
82
89
|
|
|
@@ -165,6 +172,8 @@ dsh plugin --profile web add dsh-better-sidebar
|
|
|
165
172
|
command: false # 去掉 /graft
|
|
166
173
|
promptSection: false # 不再告诉模型使用 graft
|
|
167
174
|
autoBuild: false # 不再在编辑后重建(卡片上的 Build 按钮仍然可用)
|
|
175
|
+
autoInstall: false # 不自动安装 graft,改为提示 npm 命令
|
|
176
|
+
graftVersion: '0.16.0' # 固定自动安装的版本(默认 'latest')
|
|
168
177
|
```
|
|
169
178
|
|
|
170
179
|
## 系统要求
|
|
@@ -178,7 +187,7 @@ dsh plugin --profile web add dsh-better-sidebar
|
|
|
178
187
|
| `0.2.0-rc.2` | 需要插件 `0.2.1`+——更早的发行版无法激活(见故障排查) |
|
|
179
188
|
|
|
180
189
|
- Node 22+
|
|
181
|
-
-
|
|
190
|
+
- `graft` CLI——首次使用时自动安装(见安装),或手动 `npm install -g @nanonets/graft`。bun 的全局安装(`bun add -g @nanonets/graft`)也能被识别,经由 `$BUN_INSTALL` 或 `~/.bun`;插件自有的 `<DSH_HOME>/graft-cli` 前缀同样被识别。
|
|
182
191
|
|
|
183
192
|
客户端 bundle 只依赖 `react`——这就是它能在 dsh `0.1.5-rc.2` 移除一批客户端包、弄坏了好几个相邻插件的情况下存活的原因。
|
|
184
193
|
|
|
@@ -211,7 +220,7 @@ registering "dsh-graft-plugin" via __ModuleLoader__.load
|
|
|
211
220
|
|
|
212
221
|
**在 dsh 0.2 上插件加载失败,报 `strict codec has no create() factory`,dsh 还提议移除它。** dsh 0.2 要求每个 strict remote codec 都带一个 `create()` schema 工厂;`0.2.1` 之前的插件发行版只有 0.1 的 `schema` 键。升级到 `0.2.1` 或更新版本——它两种都带,0.1 和 0.2 上都能加载。
|
|
213
222
|
|
|
214
|
-
**`/graft` 说 graft CLI 不在 PATH 上。**
|
|
223
|
+
**`/graft` 说 graft CLI 不在 PATH 上。** 0.3.5+ 会在首次使用时自动安装;若失败(离线机器、前缀被拒),消息里带 npm 自己的输出和手动命令:`npm install -g @nanonets/graft`。旧版本则是 postinstall 被拦截或被跳过(见安装)。
|
|
215
224
|
|
|
216
225
|
## 许可证
|
|
217
226
|
|
package/cordis.patch.yml
CHANGED
|
@@ -19,6 +19,10 @@
|
|
|
19
19
|
# command: false drop the /graft command
|
|
20
20
|
# promptSection: false stop telling the model to prefer graft
|
|
21
21
|
# autoBuild: false stop rebuilding the index after edits
|
|
22
|
+
# autoInstall: false never install graft automatically; report the one
|
|
23
|
+
# `npm install -g @nanonets/graft` command instead
|
|
24
|
+
# (DSH_GRAFT_NO_AUTOINSTALL=1 does the same per machine)
|
|
25
|
+
# graftVersion: '0.16.0' pin what the auto-install requests; default 'latest'
|
|
22
26
|
- insert:
|
|
23
27
|
- id: graft-status
|
|
24
28
|
name: 'dsh-graft-plugin'
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-graft-plugin",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.5",
|
|
4
4
|
"description": "Shows which graft index a dsh session is serving and whether it still matches the code, and gives the model graft_ask, graft_grep, graft_callers, graft_skeleton and graft_map as native tools.",
|
|
5
5
|
"icon": "assets/icon.png",
|
|
6
6
|
"type": "module",
|
|
@@ -11,12 +11,14 @@
|
|
|
11
11
|
"./cordis.patch.yml": "./cordis.patch.yml",
|
|
12
12
|
"./package.json": "./package.json",
|
|
13
13
|
"./graft-tools": "./src/graft-tools.js",
|
|
14
|
+
"./ensure-graft": "./src/ensure-graft.js",
|
|
14
15
|
"./auto-sync": "./src/auto-sync.js",
|
|
15
16
|
"./command": "./src/command.js"
|
|
16
17
|
},
|
|
17
18
|
"files": [
|
|
18
19
|
"src/index.js",
|
|
19
20
|
"src/graft-tools.js",
|
|
21
|
+
"src/ensure-graft.js",
|
|
20
22
|
"src/auto-sync.js",
|
|
21
23
|
"src/command.js",
|
|
22
24
|
"src/client.js",
|
|
@@ -25,6 +27,7 @@
|
|
|
25
27
|
"scripts/test-harness.mjs",
|
|
26
28
|
"scripts/test-tools.mjs",
|
|
27
29
|
"scripts/test-autosync.mjs",
|
|
30
|
+
"scripts/test-ensure.mjs",
|
|
28
31
|
"scripts/test-command.mjs",
|
|
29
32
|
"scripts/test-guidance.mjs",
|
|
30
33
|
"README.md",
|
|
@@ -33,7 +36,7 @@
|
|
|
33
36
|
"assets/icon.png"
|
|
34
37
|
],
|
|
35
38
|
"scripts": {
|
|
36
|
-
"test": "node scripts/test-harness.mjs && node scripts/test-autosync.mjs && node scripts/test-command.mjs && node scripts/test-guidance.mjs",
|
|
39
|
+
"test": "node scripts/test-harness.mjs && node scripts/test-autosync.mjs && node scripts/test-ensure.mjs && node scripts/test-command.mjs && node scripts/test-guidance.mjs",
|
|
37
40
|
"test:live": "node scripts/test-tools.mjs",
|
|
38
41
|
"postinstall": "node scripts/postinstall.mjs"
|
|
39
42
|
},
|
package/scripts/postinstall.mjs
CHANGED
|
@@ -1,109 +1,84 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Put the graft CLI on the machine, and say what is still left to do.
|
|
3
|
-
*
|
|
4
|
-
* WHY A POSTINSTALL AT ALL
|
|
5
|
-
*
|
|
6
|
-
* This plugin is a front end. Every tool it registers shells out to `graft`,
|
|
7
|
-
* and the chip reads an index `graft build` writes — so a correct install of
|
|
8
|
-
* the plugin with no `graft` on PATH is a plugin that can do nothing, and says
|
|
9
|
-
* so only at the first tool call. Installing it here turns that into one
|
|
10
|
-
* install step instead of two.
|
|
11
|
-
*
|
|
12
|
-
* WHY IT
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
})
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
say()
|
|
71
|
-
say(
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
say(` npm install -g ${PACKAGE}`)
|
|
86
|
-
say()
|
|
87
|
-
say(' On a machine where the global prefix needs elevation, run that in an')
|
|
88
|
-
say(' administrator shell, or point npm at a writable prefix first.')
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
say()
|
|
93
|
-
say(` ${BOLD}Next:${OFF}`)
|
|
94
|
-
say(' 1. Index a repo: cd <your repo> && graft build')
|
|
95
|
-
say(' 2. The sidebar tab for `graft viz` needs dsh-better-sidebar mounted.')
|
|
96
|
-
say(' It ships as a dependency of this plugin, so it is already downloaded,')
|
|
97
|
-
say(' but dsh only mounts what the profile lists:')
|
|
98
|
-
say()
|
|
99
|
-
say(' dsh plugin --profile web add dsh-better-sidebar')
|
|
100
|
-
say()
|
|
101
|
-
say(' Skip it if you do not want the tab - the chip, the tools, the')
|
|
102
|
-
say(' /graft command and the auto-rebuild all work without it.')
|
|
103
|
-
say()
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
main().catch(() => {
|
|
107
|
-
// A postinstall that throws fails the install of a plugin that is, itself,
|
|
108
|
-
// perfectly fine. Never do that.
|
|
109
|
-
})
|
|
1
|
+
/**
|
|
2
|
+
* Put the graft CLI on the machine, and say what is still left to do.
|
|
3
|
+
*
|
|
4
|
+
* WHY A POSTINSTALL AT ALL
|
|
5
|
+
*
|
|
6
|
+
* This plugin is a front end. Every tool it registers shells out to `graft`,
|
|
7
|
+
* and the chip reads an index `graft build` writes — so a correct install of
|
|
8
|
+
* the plugin with no `graft` on PATH is a plugin that can do nothing, and says
|
|
9
|
+
* so only at the first tool call. Installing it here turns that into one
|
|
10
|
+
* install step instead of two, for the package managers that run this file.
|
|
11
|
+
*
|
|
12
|
+
* WHY IT IS NOT THE ONLY INSTALL PATH
|
|
13
|
+
*
|
|
14
|
+
* pnpm — which is what dsh profiles and the marketplace use — refuses
|
|
15
|
+
* lifecycle scripts of dependencies unless the profile approves them, and
|
|
16
|
+
* ignores them for `file:` tarball deps even WITH the approval. Measured on
|
|
17
|
+
* pnpm 11.28.5: ERR_PNPM_IGNORED_BUILDS, plugin installed, graft absent. So the
|
|
18
|
+
* host half runs the same ensureGraft() on first need; this file is the
|
|
19
|
+
* install-time convenience for plain npm, not the guarantee.
|
|
20
|
+
*
|
|
21
|
+
* WHY IT NEVER FAILS THE INSTALL
|
|
22
|
+
*
|
|
23
|
+
* A global npm install is exactly the kind of thing that is refused: no write
|
|
24
|
+
* permission on the prefix, an offline machine, a locked-down CI image, or
|
|
25
|
+
* `--ignore-scripts` (which skips this file entirely and is a perfectly
|
|
26
|
+
* reasonable thing for a careful user to do). None of those mean the PLUGIN
|
|
27
|
+
* failed to install, so none of them exit non-zero. ensureGraft already falls
|
|
28
|
+
* back to a user-writable prefix; whatever is left prints the one command to
|
|
29
|
+
* run by hand and gets out of the way.
|
|
30
|
+
*
|
|
31
|
+
* Set DSH_GRAFT_SKIP_POSTINSTALL=1 to skip it outright.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { ensureGraft, GRAFT_PACKAGE } from '../src/ensure-graft.js'
|
|
35
|
+
|
|
36
|
+
const BOLD = '\x1b[1m'
|
|
37
|
+
const DIM = '\x1b[2m'
|
|
38
|
+
const OFF = '\x1b[0m'
|
|
39
|
+
|
|
40
|
+
const say = (line = '') => process.stdout.write(`${line}\n`)
|
|
41
|
+
|
|
42
|
+
async function main() {
|
|
43
|
+
if (process.env.DSH_GRAFT_SKIP_POSTINSTALL === '1') return
|
|
44
|
+
|
|
45
|
+
say()
|
|
46
|
+
say(`${BOLD}dsh-graft-plugin${OFF}`)
|
|
47
|
+
|
|
48
|
+
const result = await ensureGraft({
|
|
49
|
+
spec: process.env.DSH_GRAFT_VERSION || undefined,
|
|
50
|
+
log: (message) => say(` ${message}`),
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
if (result.ok && result.already) {
|
|
54
|
+
say(` ${DIM}graft is already installed - nothing to do.${OFF}`)
|
|
55
|
+
} else if (result.ok) {
|
|
56
|
+
say(` ${BOLD}done.${OFF} graft is installed (${result.installed}).`)
|
|
57
|
+
} else {
|
|
58
|
+
say()
|
|
59
|
+
say(` ${BOLD}graft was not installed.${OFF} This plugin needs it.`)
|
|
60
|
+
for (const line of String(result.reason ?? '').split('\n')) say(` ${line}`)
|
|
61
|
+
say()
|
|
62
|
+
say(' On a machine where the global prefix needs elevation, run that in an')
|
|
63
|
+
say(' administrator shell, or point npm at a writable prefix first.')
|
|
64
|
+
say(` (dsh itself will retry automatically on first use: ${GRAFT_PACKAGE}.)`)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
say()
|
|
68
|
+
say(` ${BOLD}Next:${OFF}`)
|
|
69
|
+
say(' 1. Index a repo: cd <your repo> && graft build')
|
|
70
|
+
say(' 2. The sidebar tab for `graft viz` needs dsh-better-sidebar mounted.')
|
|
71
|
+
say(' It ships as a dependency of this plugin, so it is already downloaded,')
|
|
72
|
+
say(' but dsh only mounts what the profile lists:')
|
|
73
|
+
say()
|
|
74
|
+
say(' dsh plugin --profile web add dsh-better-sidebar')
|
|
75
|
+
say()
|
|
76
|
+
say(' Skip it if you do not want the tab - the chip, the tools, the')
|
|
77
|
+
say(' /graft command and the auto-rebuild all work without it.')
|
|
78
|
+
say()
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
main().catch(() => {
|
|
82
|
+
// A postinstall that throws fails the install of a plugin that is, itself,
|
|
83
|
+
// perfectly fine. Never do that.
|
|
84
|
+
})
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ensure-graft: the install that postinstall cannot guarantee.
|
|
3
|
+
*
|
|
4
|
+
* Every case here runs against a fake filesystem and a fake spawn, so the
|
|
5
|
+
* suite never touches the network or the machine's real npm prefix. The two
|
|
6
|
+
* real-install smoke tests live outside the suite (they need a contained
|
|
7
|
+
* npm_config_prefix) and are run by hand before a release.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { delimiter, join } from 'node:path'
|
|
11
|
+
import {
|
|
12
|
+
GRAFT_PACKAGE,
|
|
13
|
+
cachedGraftCommand,
|
|
14
|
+
ensureGraft,
|
|
15
|
+
managedPrefix,
|
|
16
|
+
npmInvocation,
|
|
17
|
+
resetEnsureGraftState,
|
|
18
|
+
resetGraftCommandCache,
|
|
19
|
+
resolveGraftCommand,
|
|
20
|
+
} from '../src/ensure-graft.js'
|
|
21
|
+
import { createAutoSync } from '../src/auto-sync.js'
|
|
22
|
+
|
|
23
|
+
let failures = 0
|
|
24
|
+
const check = (l, ok, extra = '') => { console.log((ok ? 'PASS ' : 'FAIL ') + l + (extra ? ' -> ' + extra : '')); if (!ok) failures += 1 }
|
|
25
|
+
|
|
26
|
+
const NODE_EXE = process.platform === 'win32' ? 'node.exe' : 'node'
|
|
27
|
+
const cliIn = (dir) => join(dir, 'node_modules', '@nanonets', 'graft', 'dist', 'cli.js')
|
|
28
|
+
const nodeIn = (dir) => join(dir, NODE_EXE)
|
|
29
|
+
const npmIn = (dir) => join(dir, 'node_modules', 'npm', 'bin', 'npm-cli.js')
|
|
30
|
+
const HOME = { DSH_HOME: join('C:', 'dsh-home') }
|
|
31
|
+
|
|
32
|
+
/** A fake machine: which files exist, and what each spawned npm run does. */
|
|
33
|
+
function world({ pathDirs = [], nodeDirs = [], managed = false, plan = () => ({ code: 0 }) } = {}) {
|
|
34
|
+
const set = new Set()
|
|
35
|
+
for (const d of pathDirs) set.add(cliIn(d))
|
|
36
|
+
for (const d of nodeDirs) { set.add(nodeIn(d)); set.add(npmIn(d)) }
|
|
37
|
+
if (managed) set.add(cliIn(managedPrefix(HOME)))
|
|
38
|
+
const spawns = []
|
|
39
|
+
const env = { ...HOME, PATH: pathDirs.join(delimiter) }
|
|
40
|
+
const exists = (p) => set.has(p)
|
|
41
|
+
const spawn = (command, args) => {
|
|
42
|
+
const step = plan(command, args, spawns.length)
|
|
43
|
+
spawns.push({ command, args })
|
|
44
|
+
for (const p of step.add ?? []) set.add(p)
|
|
45
|
+
return {
|
|
46
|
+
stdout: { on() {} },
|
|
47
|
+
stderr: { on() {} },
|
|
48
|
+
kill() {},
|
|
49
|
+
on(ev, cb) { if (ev === 'close') setImmediate(() => cb(step.code)) },
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return { env, exists, spawn, spawns, set }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const deps = (w, over = {}) => ({ env: w.env, exists: w.exists, spawn: w.spawn, execPath: join('C:', 'app', 'DeepSeek Harness.exe'), electron: true, ...over })
|
|
56
|
+
|
|
57
|
+
console.log('--- nothing to do when graft is already there ---')
|
|
58
|
+
resetEnsureGraftState()
|
|
59
|
+
let w = world({ pathDirs: [join('C:', 'npm')] })
|
|
60
|
+
let r = await ensureGraft(deps(w))
|
|
61
|
+
check('an existing install is reported, not reinstalled', r.ok === true && r.already === true && w.spawns.length === 0, JSON.stringify(r))
|
|
62
|
+
|
|
63
|
+
console.log('--- opt-outs keep the old report-the-command behaviour ---')
|
|
64
|
+
for (const over of [{ autoInstall: false }, {}]) {
|
|
65
|
+
resetEnsureGraftState()
|
|
66
|
+
w = world()
|
|
67
|
+
if (over.autoInstall === undefined) w.env.DSH_GRAFT_NO_AUTOINSTALL = '1'
|
|
68
|
+
r = await ensureGraft(deps(w, over))
|
|
69
|
+
check('no install, and the manual command is in the reason', r.ok === false && w.spawns.length === 0 && r.reason.includes(`npm install -g ${GRAFT_PACKAGE}`), JSON.stringify(r).slice(0, 120))
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
console.log('--- global install, with a real node and npm-cli.js (no shell) ---')
|
|
73
|
+
resetEnsureGraftState()
|
|
74
|
+
const nodeDir = join('C:', 'node')
|
|
75
|
+
w = world({ nodeDirs: [nodeDir], plan: (cmd, args) => (args.includes('-g') ? { code: 0, add: [cliIn(nodeDir)] } : { code: 1 }) })
|
|
76
|
+
w.env.PATH = nodeDir
|
|
77
|
+
r = await ensureGraft(deps(w))
|
|
78
|
+
check('global success is reported as global', r.ok === true && r.installed === 'global', JSON.stringify(r))
|
|
79
|
+
check('spawned node + npm-cli.js, not the npm shim', w.spawns[0].command === nodeIn(nodeDir) && w.spawns[0].args[0] === npmIn(nodeDir), JSON.stringify(w.spawns[0]))
|
|
80
|
+
check('asked for the package at the default spec', w.spawns[0].args.includes('-g') && w.spawns[0].args.includes(`${GRAFT_PACKAGE}@latest`), JSON.stringify(w.spawns[0].args))
|
|
81
|
+
check('one npm run, no fallback needed', w.spawns.length === 1, String(w.spawns.length))
|
|
82
|
+
|
|
83
|
+
console.log('--- a refused global prefix falls back to the managed prefix ---')
|
|
84
|
+
resetEnsureGraftState()
|
|
85
|
+
w = world({ nodeDirs: [nodeDir], plan: (cmd, args) => (args.includes('-g') ? { code: 1 } : { code: 0, add: [cliIn(managedPrefix(HOME))] }) })
|
|
86
|
+
r = await ensureGraft(deps(w))
|
|
87
|
+
check('the fallback reports managed', r.ok === true && r.installed === 'managed', JSON.stringify(r))
|
|
88
|
+
check('and installed with --prefix under the dsh home', w.spawns[1].args.includes('--prefix') && w.spawns[1].args.includes(managedPrefix(HOME)), JSON.stringify(w.spawns[1].args))
|
|
89
|
+
check('the resolver now finds the managed cli', resolveGraftCommand(w.env, w.exists).prefix[0] === cliIn(managedPrefix(HOME)))
|
|
90
|
+
|
|
91
|
+
console.log('--- total failure: cooldown, then force ---')
|
|
92
|
+
resetEnsureGraftState()
|
|
93
|
+
w = world({ nodeDirs: [nodeDir], plan: () => ({ code: 1 }) })
|
|
94
|
+
r = await ensureGraft(deps(w))
|
|
95
|
+
check('failure carries npm output and the manual command', r.ok === false && r.reason.includes('by hand'), JSON.stringify(r).slice(0, 100))
|
|
96
|
+
const after = w.spawns.length
|
|
97
|
+
r = await ensureGraft(deps(w))
|
|
98
|
+
check('a second poll inside the cooldown does not hammer npm', r.ok === false && w.spawns.length === after, String(w.spawns.length))
|
|
99
|
+
r = await ensureGraft(deps(w, { force: true }))
|
|
100
|
+
check('a pressed Build (force) retries anyway', w.spawns.length > after, String(w.spawns.length))
|
|
101
|
+
|
|
102
|
+
console.log('--- one install at a time ---')
|
|
103
|
+
resetEnsureGraftState()
|
|
104
|
+
w = world({ nodeDirs: [nodeDir], plan: () => ({ code: 1 }) })
|
|
105
|
+
const p1 = ensureGraft(deps(w))
|
|
106
|
+
const p2 = ensureGraft(deps(w))
|
|
107
|
+
check('concurrent callers share one attempt', p1 === p2)
|
|
108
|
+
await p1
|
|
109
|
+
check('and npm ran once per target, not once per caller', w.spawns.length === 2, String(w.spawns.length))
|
|
110
|
+
|
|
111
|
+
console.log('--- resolution order and interpreter choice ---')
|
|
112
|
+
resetEnsureGraftState()
|
|
113
|
+
const own = join('C:', 'own')
|
|
114
|
+
w = world({ pathDirs: [own], nodeDirs: [nodeDir], managed: true })
|
|
115
|
+
check('the user\'s own PATH install wins over the managed one', resolveGraftCommand(w.env, w.exists).prefix[0] === cliIn(own))
|
|
116
|
+
w = world({ nodeDirs: [nodeDir], managed: true })
|
|
117
|
+
w.env.PATH = nodeDir
|
|
118
|
+
const managedHow = resolveGraftCommand(w.env, w.exists, join('C:', 'app', 'x.exe'), true)
|
|
119
|
+
check('a managed install on an Electron host runs with a real node', managedHow.command === nodeIn(nodeDir) && managedHow.shell === false, managedHow.command)
|
|
120
|
+
const npmHow = npmInvocation(w.env, w.exists, join('C:', 'app', 'x.exe'), true)
|
|
121
|
+
check('npm likewise avoids the Electron binary', npmHow.command === nodeIn(nodeDir) && npmHow.prefix[0] === npmIn(nodeDir), npmHow.command)
|
|
122
|
+
check('the command cache is droppable after an install', (resetGraftCommandCache(), typeof cachedGraftCommand === 'function'))
|
|
123
|
+
|
|
124
|
+
console.log('--- auto-sync picks up a graft installed mid-session ---')
|
|
125
|
+
let now = 1_000_000
|
|
126
|
+
let spawned = []
|
|
127
|
+
const state = { stats: { dirty: true }, readStats: () => state.stats, patchStats: (_d, p) => Object.assign(state.stats, p), acquireLock: () => true }
|
|
128
|
+
let available = null
|
|
129
|
+
const sync = createAutoSync({
|
|
130
|
+
get graft() { return available },
|
|
131
|
+
resolveCli: () => (available ? 'C:/late/cli.js' : undefined),
|
|
132
|
+
resolveCommand: () => ({ command: 'C:/late/node.exe' }),
|
|
133
|
+
now: () => now,
|
|
134
|
+
spawn: (...a) => { spawned.push(a); return { on() {}, unref() {} } },
|
|
135
|
+
})
|
|
136
|
+
check('unavailable before the install', (await sync.syncIfDirty('C:/x')) === 'graft-unavailable')
|
|
137
|
+
available = { state, syncRun: 'C:/late/sync-run.js' }
|
|
138
|
+
now += 60_000
|
|
139
|
+
check('and rebuilds after it, without a restart', (await sync.syncIfDirty('C:/x')) === 'started')
|
|
140
|
+
check('with the live-resolved interpreter', spawned.at(-1)?.[0] === 'C:/late/node.exe', String(spawned.at(-1)?.[0]))
|
|
141
|
+
|
|
142
|
+
console.log(failures === 0 ? '\nALL CHECKS PASSED' : `\n${failures} CHECK(S) FAILED`)
|
|
143
|
+
process.exit(failures === 0 ? 0 : 1)
|
package/src/auto-sync.js
CHANGED
|
@@ -93,6 +93,10 @@ async function loadGraftState(cliPath) {
|
|
|
93
93
|
/**
|
|
94
94
|
* @param deps.cliPath - graft's `dist/cli.js`, from which its `claude/` dir is found.
|
|
95
95
|
* @param deps.nodePath - the interpreter to run it with; defaults to execPath.
|
|
96
|
+
* @param deps.resolveCli - optional live re-resolve of graft's cli.js, so a
|
|
97
|
+
* graft installed mid-session is picked up without a restart.
|
|
98
|
+
* @param deps.resolveCommand - optional live `{ command }` for the sync-run
|
|
99
|
+
* spawn; wins over nodePath when present.
|
|
96
100
|
* @param deps.minIntervalMs - floor between rebuilds of the same repo.
|
|
97
101
|
*/
|
|
98
102
|
export function createAutoSync(deps = {}) {
|
|
@@ -150,9 +154,15 @@ export function createAutoSync(deps = {}) {
|
|
|
150
154
|
|
|
151
155
|
let loaded
|
|
152
156
|
const graft = async () => {
|
|
153
|
-
|
|
157
|
+
// A null result is NOT cached: graft can be installed mid-session by
|
|
158
|
+
// ensureGraft (the first Build on a fresh machine), and a permanently
|
|
159
|
+
// cached "unavailable" would keep auto-sync dead for the rest of that
|
|
160
|
+
// session. Re-resolving is cheap — loadGraftState returns before any
|
|
161
|
+
// import when there is no cli to load.
|
|
162
|
+
if (loaded === undefined || loaded === null) {
|
|
163
|
+
const cliPath = typeof deps.resolveCli === 'function' ? deps.resolveCli() : deps.cliPath
|
|
154
164
|
try {
|
|
155
|
-
loaded = deps.graft !== undefined ? await deps.graft : await loadGraftState(
|
|
165
|
+
loaded = deps.graft !== undefined ? await deps.graft : await loadGraftState(cliPath)
|
|
156
166
|
} catch {
|
|
157
167
|
loaded = null // any loader failure counts as "graft unavailable"
|
|
158
168
|
}
|
|
@@ -286,7 +296,8 @@ export function createAutoSync(deps = {}) {
|
|
|
286
296
|
* and `sync-run` clears `syncing` and releases the lock on its own way
|
|
287
297
|
* out either way.
|
|
288
298
|
*/
|
|
289
|
-
const
|
|
299
|
+
const how = typeof deps.resolveCommand === 'function' ? deps.resolveCommand() : { command: nodePath }
|
|
300
|
+
const child = spawnFn(how.command, [g.syncRun, root], {
|
|
290
301
|
stdio: 'ignore',
|
|
291
302
|
windowsHide: true,
|
|
292
303
|
})
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Find graft, and install it when it is not there.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS IS NOT ONLY A POSTINSTALL
|
|
5
|
+
*
|
|
6
|
+
* scripts/postinstall.mjs has always run `npm install -g @nanonets/graft`, and
|
|
7
|
+
* for a marketplace user it never runs at all. Measured with the pnpm dsh
|
|
8
|
+
* ships (11.28.5), installing this plugin's tarball into a fresh profile:
|
|
9
|
+
*
|
|
10
|
+
* [ERR_PNPM_IGNORED_BUILDS] Ignored build scripts: dsh-graft-plugin@file:…
|
|
11
|
+
*
|
|
12
|
+
* pnpm refuses lifecycle scripts of dependencies unless the profile approves
|
|
13
|
+
* them, and even WITH the approval entry (`allowBuilds: dsh-graft-plugin: true`)
|
|
14
|
+
* a `file:` tarball dependency — exactly how the marketplace installs — is
|
|
15
|
+
* still ignored. So the plugin lands, graft does not, and the first Build says
|
|
16
|
+
* "no graft". A global prefix that needs elevation is the second way the
|
|
17
|
+
* postinstall loses, on machines where it does run.
|
|
18
|
+
*
|
|
19
|
+
* The one trigger that always runs is this plugin's own code inside the dsh
|
|
20
|
+
* host. So the install lives here: on first need, once at a time, with a
|
|
21
|
+
* user-writable fallback for the machines where `-g` is refused. postinstall
|
|
22
|
+
* calls the same function, so npm users keep the install-time convenience and
|
|
23
|
+
* gain the same fallback.
|
|
24
|
+
*
|
|
25
|
+
* NO dsh IMPORTS IN THIS FILE, DELIBERATELY
|
|
26
|
+
*
|
|
27
|
+
* postinstall runs under plain npm/pnpm node, where `@deepseek-ai/dsh-tools`
|
|
28
|
+
* does not exist. Everything here is node builtins, so both callers can use it.
|
|
29
|
+
*
|
|
30
|
+
* @module graft-status/ensure-graft
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { spawn } from 'node:child_process'
|
|
34
|
+
import { existsSync, mkdirSync } from 'node:fs'
|
|
35
|
+
import { delimiter, dirname, join } from 'node:path'
|
|
36
|
+
import { homedir } from 'node:os'
|
|
37
|
+
|
|
38
|
+
/** The package behind the `graft` command. */
|
|
39
|
+
export const GRAFT_PACKAGE = '@nanonets/graft'
|
|
40
|
+
|
|
41
|
+
/** What a fresh install asks for. Overridable per profile (`graftVersion`). */
|
|
42
|
+
export const DEFAULT_SPEC = 'latest'
|
|
43
|
+
|
|
44
|
+
/** An install downloads and builds tree-sitter grammars; give it room. */
|
|
45
|
+
const INSTALL_TIMEOUT_MS = 240_000
|
|
46
|
+
|
|
47
|
+
/** After a failed attempt, do not hammer npm on every chip poll. */
|
|
48
|
+
const FAIL_COOLDOWN_MS = 60_000
|
|
49
|
+
|
|
50
|
+
/** Cap on npm output kept for the failure message. */
|
|
51
|
+
const MAX_LOG = 2_000
|
|
52
|
+
|
|
53
|
+
const NODE_EXE = process.platform === 'win32' ? 'node.exe' : 'node'
|
|
54
|
+
|
|
55
|
+
const noop = () => {}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Where the fallback install lives: a prefix this plugin owns, under the dsh
|
|
59
|
+
* home. Never needs elevation, survives plugin upgrades, and is one directory
|
|
60
|
+
* to delete for a clean uninstall. A graft the user installed themselves always
|
|
61
|
+
* wins over it — see the scan order in resolveGraftCommand.
|
|
62
|
+
*/
|
|
63
|
+
export function managedPrefix(env = process.env) {
|
|
64
|
+
const home = env.DSH_HOME ?? join(env.HOME ?? env.USERPROFILE ?? homedir(), '.dsh')
|
|
65
|
+
return join(home, 'graft-cli')
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Which binary to run a graft/npm script with.
|
|
70
|
+
*
|
|
71
|
+
* The npm shim's own rule is the first choice — `%dp0%\node.exe`, the node that
|
|
72
|
+
* was installed beside the shims — so this agrees with what typing `graft` in a
|
|
73
|
+
* terminal would do. Only an Electron host needs the PATH search after it,
|
|
74
|
+
* because `graft` and its node need not share a prefix. A Node host keeps using
|
|
75
|
+
* its own execPath, unchanged.
|
|
76
|
+
*
|
|
77
|
+
* WHY NOT JUST process.execPath
|
|
78
|
+
*
|
|
79
|
+
* execPath is Node only when the host is Node. The dsh DESKTOP app is Electron
|
|
80
|
+
* (`DeepSeek Harness.exe`) running with ELECTRON_RUN_AS_NODE=1, so execPath is
|
|
81
|
+
* that app binary: the script still starts, and the breakage happens one layer
|
|
82
|
+
* in, inside graft's own argument parsing. The child keeps
|
|
83
|
+
* `process.versions.electron`, and commander 15 picks its `electron` argv
|
|
84
|
+
* convention from exactly that (`if (process.versions?.electron) from =
|
|
85
|
+
* 'electron'`). That branch then keys on `process.defaultApp`, which is
|
|
86
|
+
* undefined when the JS arrived as a script path rather than an app directory,
|
|
87
|
+
* and slices argv from 1 instead of 2 — so the cli.js path lands where the
|
|
88
|
+
* subcommand should be:
|
|
89
|
+
*
|
|
90
|
+
* error: unknown command '<...>\@nanonets\graft\dist\cli.js'
|
|
91
|
+
*
|
|
92
|
+
* which is what the Build button and all five query tools returned in the
|
|
93
|
+
* desktop app before 0.3.4. A real node avoids the Electron binary altogether.
|
|
94
|
+
*/
|
|
95
|
+
function interpreterFor(installDir, dirs, exists, execPath, electron) {
|
|
96
|
+
const beside = join(installDir, NODE_EXE)
|
|
97
|
+
if (exists(beside)) return beside
|
|
98
|
+
if (!electron) return execPath
|
|
99
|
+
for (const dir of dirs) {
|
|
100
|
+
const candidate = join(dir, NODE_EXE)
|
|
101
|
+
if (exists(candidate)) return candidate
|
|
102
|
+
}
|
|
103
|
+
// Nothing better to hand: execPath at least runs the script, and graft's own
|
|
104
|
+
// stderr says what happened — the same answer as before this existed.
|
|
105
|
+
return execPath
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const pathDirs = (env) => String(env.PATH ?? env.Path ?? '').split(delimiter).filter((dir) => dir !== '')
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* How to invoke graft without a shell.
|
|
112
|
+
*
|
|
113
|
+
* `graft` on Windows is a `.cmd` shim, so a naive spawn cannot exec it and the
|
|
114
|
+
* obvious fix — `shell: true` — is a trap: Node joins argv into one command
|
|
115
|
+
* line WITHOUT quoting, so the first argument containing a space is silently
|
|
116
|
+
* split. That broke every call at once, because both the queries
|
|
117
|
+
* (`"punch animation"`) and the repo path (`B:\...\Street Yeet`) contain
|
|
118
|
+
* spaces.
|
|
119
|
+
*
|
|
120
|
+
* The shim's own body is `node <dir>/node_modules/@nanonets/graft/dist/cli.js`,
|
|
121
|
+
* so finding that file and running it with a real node needs no shell at all,
|
|
122
|
+
* and argv is passed through exactly as written.
|
|
123
|
+
*
|
|
124
|
+
* Scan order: the user's own PATH install, then a bun global install (only the
|
|
125
|
+
* shim is on PATH there), then this plugin's managed prefix. The bare-name
|
|
126
|
+
* fallback keeps a differently-installed graft working; it is only reached
|
|
127
|
+
* when no entry point can be found.
|
|
128
|
+
*/
|
|
129
|
+
export function resolveGraftCommand(
|
|
130
|
+
env = process.env,
|
|
131
|
+
exists = existsSync,
|
|
132
|
+
execPath = process.execPath,
|
|
133
|
+
electron = process.versions?.electron !== undefined,
|
|
134
|
+
) {
|
|
135
|
+
const dirs = pathDirs(env)
|
|
136
|
+
for (const dir of dirs) {
|
|
137
|
+
const cli = join(dir, 'node_modules', '@nanonets', 'graft', 'dist', 'cli.js')
|
|
138
|
+
if (exists(cli)) return { command: interpreterFor(dir, dirs, exists, execPath, electron), prefix: [cli], shell: false }
|
|
139
|
+
}
|
|
140
|
+
const bunRoot = env.BUN_INSTALL ?? join(env.HOME ?? env.USERPROFILE ?? '.', '.bun')
|
|
141
|
+
if (typeof bunRoot === 'string' && bunRoot !== '') {
|
|
142
|
+
const bunInstall = join(bunRoot, 'install', 'global')
|
|
143
|
+
const bunCli = join(bunInstall, 'node_modules', '@nanonets', 'graft', 'dist', 'cli.js')
|
|
144
|
+
if (exists(bunCli)) return { command: interpreterFor(bunInstall, dirs, exists, execPath, electron), prefix: [bunCli], shell: false }
|
|
145
|
+
}
|
|
146
|
+
const managed = managedPrefix(env)
|
|
147
|
+
const managedCli = join(managed, 'node_modules', '@nanonets', 'graft', 'dist', 'cli.js')
|
|
148
|
+
if (exists(managedCli)) return { command: interpreterFor(managed, dirs, exists, execPath, electron), prefix: [managedCli], shell: false }
|
|
149
|
+
return { command: 'graft', prefix: [], shell: process.platform === 'win32' }
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* The resolved command, cached for the process — and DROPPED after a successful
|
|
154
|
+
* install, because the cache would otherwise keep answering "not installed"
|
|
155
|
+
* for the rest of the session that installed it.
|
|
156
|
+
*/
|
|
157
|
+
let cachedCommand
|
|
158
|
+
export const cachedGraftCommand = () => (cachedCommand ??= resolveGraftCommand())
|
|
159
|
+
export const resetGraftCommandCache = () => { cachedCommand = undefined }
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* How to invoke npm without a shell: the npm-cli.js beside the node we would
|
|
163
|
+
* run graft with. Falls back to the `npm` shim (shell on Windows) when that
|
|
164
|
+
* file is not there — a node from nvm or a standalone build still ships npm
|
|
165
|
+
* next to itself in every layout we have seen.
|
|
166
|
+
*/
|
|
167
|
+
export function npmInvocation(env = process.env, exists = existsSync, execPath = process.execPath, electron = process.versions?.electron !== undefined) {
|
|
168
|
+
const dirs = pathDirs(env)
|
|
169
|
+
let node = execPath
|
|
170
|
+
if (electron) {
|
|
171
|
+
for (const dir of dirs) {
|
|
172
|
+
const candidate = join(dir, NODE_EXE)
|
|
173
|
+
if (exists(candidate)) { node = candidate; break }
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
const npmCli = join(dirname(node), 'node_modules', 'npm', 'bin', 'npm-cli.js')
|
|
177
|
+
if (exists(npmCli)) return { command: node, prefix: [npmCli], shell: false }
|
|
178
|
+
return { command: 'npm', prefix: [], shell: process.platform === 'win32' }
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Run to completion, capturing both streams. Never rejects; code null = never ran. */
|
|
182
|
+
function runCapture(how, args, deps) {
|
|
183
|
+
const spawnFn = deps.spawn ?? spawn
|
|
184
|
+
const timeoutMs = deps.timeoutMs ?? INSTALL_TIMEOUT_MS
|
|
185
|
+
return new Promise((resolve) => {
|
|
186
|
+
let child
|
|
187
|
+
try {
|
|
188
|
+
child = spawnFn(how.command, [...how.prefix, ...args], {
|
|
189
|
+
shell: how.shell,
|
|
190
|
+
windowsHide: true,
|
|
191
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
192
|
+
})
|
|
193
|
+
} catch {
|
|
194
|
+
resolve({ code: null, text: '' })
|
|
195
|
+
return
|
|
196
|
+
}
|
|
197
|
+
let out = ''
|
|
198
|
+
let settled = false
|
|
199
|
+
const finish = (code) => {
|
|
200
|
+
if (settled) return
|
|
201
|
+
settled = true
|
|
202
|
+
clearTimeout(timer)
|
|
203
|
+
resolve({ code, text: out.length > MAX_LOG ? out.slice(-MAX_LOG) : out })
|
|
204
|
+
}
|
|
205
|
+
const timer = setTimeout(() => { child.kill(); finish(null) }, timeoutMs)
|
|
206
|
+
child.stdout?.on('data', (c) => { out += c.toString() })
|
|
207
|
+
child.stderr?.on('data', (c) => { out += c.toString() })
|
|
208
|
+
child.on('error', () => finish(null))
|
|
209
|
+
child.on('close', (code) => finish(code))
|
|
210
|
+
})
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const manualHint = (spec) =>
|
|
214
|
+
'graft is not installed, and automatic installation is turned off.\n' +
|
|
215
|
+
`Install it once: npm install -g ${GRAFT_PACKAGE}@${spec}\n` +
|
|
216
|
+
'Then press Build again.'
|
|
217
|
+
|
|
218
|
+
/** Single-flight + cooldown state. Resettable so tests start clean. */
|
|
219
|
+
let inflight
|
|
220
|
+
let lastFailAt = -Infinity
|
|
221
|
+
let lastReason = ''
|
|
222
|
+
export function resetEnsureGraftState() {
|
|
223
|
+
inflight = undefined
|
|
224
|
+
lastFailAt = -Infinity
|
|
225
|
+
lastReason = ''
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Make graft reachable, installing it if needed.
|
|
230
|
+
*
|
|
231
|
+
* Returns { ok, already? , installed?: 'global'|'managed', reason? }. Never
|
|
232
|
+
* throws: every caller is a chip poll, a button or a fire-and-forget hook, and
|
|
233
|
+
* a rejection in any of those is fatal to dsh rather than informative.
|
|
234
|
+
*
|
|
235
|
+
* @param deps.autoInstall - false (or DSH_GRAFT_NO_AUTOINSTALL=1) keeps the old
|
|
236
|
+
* behaviour: report the one command to run by hand.
|
|
237
|
+
* @param deps.spec - version spec to install; defaults to DEFAULT_SPEC.
|
|
238
|
+
* @param deps.force - skip the failure cooldown (the Build button).
|
|
239
|
+
*/
|
|
240
|
+
export function ensureGraft(deps = {}) {
|
|
241
|
+
const env = deps.env ?? process.env
|
|
242
|
+
const exists = deps.exists ?? existsSync
|
|
243
|
+
const execPath = deps.execPath ?? process.execPath
|
|
244
|
+
const electron = deps.electron ?? process.versions?.electron !== undefined
|
|
245
|
+
const now = deps.now ?? (() => Date.now())
|
|
246
|
+
const log = deps.log ?? noop
|
|
247
|
+
const spec = typeof deps.spec === 'string' && deps.spec !== '' ? deps.spec : DEFAULT_SPEC
|
|
248
|
+
const autoInstall = deps.autoInstall !== false && env.DSH_GRAFT_NO_AUTOINSTALL !== '1'
|
|
249
|
+
|
|
250
|
+
if (resolveGraftCommand(env, exists, execPath, electron).prefix[0] !== undefined) {
|
|
251
|
+
return Promise.resolve({ ok: true, already: true })
|
|
252
|
+
}
|
|
253
|
+
if (!autoInstall) return Promise.resolve({ ok: false, reason: manualHint(spec) })
|
|
254
|
+
if (inflight !== undefined) return inflight
|
|
255
|
+
if (!deps.force && now() - lastFailAt < FAIL_COOLDOWN_MS) {
|
|
256
|
+
return Promise.resolve({ ok: false, reason: lastReason })
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const attempt = (async () => {
|
|
260
|
+
const npm = npmInvocation(env, exists, execPath, electron)
|
|
261
|
+
const target = `${GRAFT_PACKAGE}@${spec}`
|
|
262
|
+
log(`graft is not installed — installing ${target} globally…`)
|
|
263
|
+
const global = await runCapture(npm, ['install', '-g', target], deps)
|
|
264
|
+
if (global.code === 0 && resolveGraftCommand(env, exists, execPath, electron).prefix[0] !== undefined) {
|
|
265
|
+
resetGraftCommandCache()
|
|
266
|
+
log('graft installed globally.')
|
|
267
|
+
return { ok: true, installed: 'global' }
|
|
268
|
+
}
|
|
269
|
+
// `-g` refused (elevation, read-only prefix) or landed off PATH. A prefix
|
|
270
|
+
// under the dsh home needs neither, so the install still succeeds there.
|
|
271
|
+
log(`global install did not land (npm exit ${global.code}) — using a user-writable prefix…`)
|
|
272
|
+
const prefix = managedPrefix(env)
|
|
273
|
+
try { mkdirSync(prefix, { recursive: true }) } catch { /* runCapture reports the real error */ }
|
|
274
|
+
const local = await runCapture(npm, ['install', '--prefix', prefix, target], deps)
|
|
275
|
+
if (local.code === 0 && resolveGraftCommand(env, exists, execPath, electron).prefix[0] !== undefined) {
|
|
276
|
+
resetGraftCommandCache()
|
|
277
|
+
log(`graft installed into ${prefix}.`)
|
|
278
|
+
return { ok: true, installed: 'managed', root: prefix }
|
|
279
|
+
}
|
|
280
|
+
lastFailAt = now()
|
|
281
|
+
lastReason =
|
|
282
|
+
'graft could not be installed automatically.\n\n' +
|
|
283
|
+
`${global.text}\n${local.text}\n\n`.trim() +
|
|
284
|
+
`\n\nInstall it by hand: npm install -g ${target}`
|
|
285
|
+
return { ok: false, reason: lastReason }
|
|
286
|
+
})()
|
|
287
|
+
|
|
288
|
+
inflight = attempt.finally(() => { inflight = undefined })
|
|
289
|
+
return inflight
|
|
290
|
+
}
|
package/src/graft-tools.js
CHANGED
|
@@ -40,8 +40,12 @@
|
|
|
40
40
|
|
|
41
41
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
42
42
|
import { spawn } from 'node:child_process'
|
|
43
|
-
import {
|
|
44
|
-
|
|
43
|
+
import { cachedGraftCommand, ensureGraft } from './ensure-graft.js'
|
|
44
|
+
|
|
45
|
+
// Resolution and installation live in ensure-graft.js, which postinstall can
|
|
46
|
+
// also import (it runs under plain npm node, where dsh-tools does not exist).
|
|
47
|
+
// Re-exported here because every existing caller imports it from this module.
|
|
48
|
+
export { resolveGraftCommand } from './ensure-graft.js'
|
|
45
49
|
|
|
46
50
|
/**
|
|
47
51
|
* Ceiling for one graft call.
|
|
@@ -57,90 +61,6 @@ const TIMEOUT_MS = 60_000
|
|
|
57
61
|
/** Cap on returned text, so one broad query cannot swamp the context. */
|
|
58
62
|
const MAX_CHARS = 60_000
|
|
59
63
|
|
|
60
|
-
/**
|
|
61
|
-
* How to invoke graft without a shell.
|
|
62
|
-
*
|
|
63
|
-
* `graft` on Windows is a `.cmd` shim, so a naive spawn cannot exec it and the
|
|
64
|
-
* obvious fix — `shell: true` — is a trap: Node joins argv into one command
|
|
65
|
-
* line WITHOUT quoting, so the first argument containing a space is silently
|
|
66
|
-
* split. That broke every call here at once, because both the queries
|
|
67
|
-
* (`"punch animation"`) and the repo path (`B:\...\Street Yeet`) contain
|
|
68
|
-
* spaces.
|
|
69
|
-
*
|
|
70
|
-
* The shim's own body is `node <dir>/node_modules/@nanonets/graft/dist/cli.js`,
|
|
71
|
-
* so finding that file on PATH and running it with the current Node needs no
|
|
72
|
-
* shell at all, and argv is passed through exactly as written.
|
|
73
|
-
*
|
|
74
|
-
* The bare-name fallback keeps a differently-installed graft working; it is
|
|
75
|
-
* only reached when the entry point cannot be found.
|
|
76
|
-
*
|
|
77
|
-
* WHY THE INTERPRETER IS NOT ALWAYS `process.execPath`
|
|
78
|
-
*
|
|
79
|
-
* execPath is Node only when the host is Node. The dsh DESKTOP app is Electron
|
|
80
|
-
* (`DeepSeek Harness.exe`) running with ELECTRON_RUN_AS_NODE=1, so execPath is
|
|
81
|
-
* that app binary: the script still starts, and the breakage happens one layer
|
|
82
|
-
* in, inside graft's own argument parsing. The child keeps
|
|
83
|
-
* `process.versions.electron`, and commander 15 picks its `electron` argv
|
|
84
|
-
* convention from exactly that (`if (process.versions?.electron) from =
|
|
85
|
-
* 'electron'`). That branch then keys on `process.defaultApp`, which is
|
|
86
|
-
* undefined when the JS arrived as a script path rather than an app directory,
|
|
87
|
-
* and slices argv from 1 instead of 2 — so the cli.js path lands where the
|
|
88
|
-
* subcommand should be:
|
|
89
|
-
*
|
|
90
|
-
* error: unknown command '<...>\@nanonets\graft\dist\cli.js'
|
|
91
|
-
*
|
|
92
|
-
* which is what the Build button and all five query tools used to return in the
|
|
93
|
-
* desktop app. A real node avoids the Electron binary altogether.
|
|
94
|
-
*/
|
|
95
|
-
const NODE_EXE = process.platform === 'win32' ? 'node.exe' : 'node'
|
|
96
|
-
|
|
97
|
-
/**
|
|
98
|
-
* Which binary to run graft's cli.js with.
|
|
99
|
-
*
|
|
100
|
-
* The npm shim's own rule is the first choice — `%dp0%\node.exe`, the node that
|
|
101
|
-
* was installed beside the shims — so this agrees with what typing `graft` in a
|
|
102
|
-
* terminal would do. Only an Electron host needs the PATH search after it,
|
|
103
|
-
* because `graft` and its node need not share a prefix. A Node host keeps using
|
|
104
|
-
* its own execPath, unchanged from before.
|
|
105
|
-
*/
|
|
106
|
-
function interpreterFor(installDir, dirs, exists, execPath, electron) {
|
|
107
|
-
const beside = join(installDir, NODE_EXE)
|
|
108
|
-
if (exists(beside)) return beside
|
|
109
|
-
if (!electron) return execPath
|
|
110
|
-
for (const dir of dirs) {
|
|
111
|
-
const candidate = join(dir, NODE_EXE)
|
|
112
|
-
if (exists(candidate)) return candidate
|
|
113
|
-
}
|
|
114
|
-
// Nothing better to hand: execPath at least runs the script, and graft's own
|
|
115
|
-
// stderr says what happened — the same answer as before this existed.
|
|
116
|
-
return execPath
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
export function resolveGraftCommand(
|
|
120
|
-
env = process.env,
|
|
121
|
-
exists = existsSync,
|
|
122
|
-
execPath = process.execPath,
|
|
123
|
-
electron = process.versions?.electron !== undefined,
|
|
124
|
-
) {
|
|
125
|
-
const dirs = String(env.PATH ?? env.Path ?? '').split(delimiter).filter((dir) => dir !== '')
|
|
126
|
-
for (const dir of dirs) {
|
|
127
|
-
const cli = join(dir, 'node_modules', '@nanonets', 'graft', 'dist', 'cli.js')
|
|
128
|
-
if (exists(cli)) return { command: interpreterFor(dir, dirs, exists, execPath, electron), prefix: [cli], shell: false }
|
|
129
|
-
}
|
|
130
|
-
// A bun global install puts only the shim on PATH; the package lives under
|
|
131
|
-
// $BUN_INSTALL/install/global. Missing it left prefix empty, which auto-sync
|
|
132
|
-
// used to turn into a fatal dirname(undefined).
|
|
133
|
-
const bunRoot = env.BUN_INSTALL ?? join(env.HOME ?? env.USERPROFILE ?? '.', '.bun')
|
|
134
|
-
if (typeof bunRoot === 'string' && bunRoot !== '') {
|
|
135
|
-
const bunInstall = join(bunRoot, 'install', 'global')
|
|
136
|
-
const bunCli = join(bunInstall, 'node_modules', '@nanonets', 'graft', 'dist', 'cli.js')
|
|
137
|
-
if (exists(bunCli)) return { command: interpreterFor(bunInstall, dirs, exists, execPath, electron), prefix: [bunCli], shell: false }
|
|
138
|
-
}
|
|
139
|
-
return { command: 'graft', prefix: [], shell: process.platform === 'win32' }
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
let cachedCommand
|
|
143
|
-
|
|
144
64
|
/**
|
|
145
65
|
* Run one graft subcommand and return its stdout.
|
|
146
66
|
*
|
|
@@ -151,7 +71,7 @@ let cachedCommand
|
|
|
151
71
|
*/
|
|
152
72
|
export function runGraft(args, cwd, deps = {}) {
|
|
153
73
|
const spawnFn = deps.spawn ?? spawn
|
|
154
|
-
const how = deps.command ?? (
|
|
74
|
+
const how = deps.command ?? cachedGraftCommand()
|
|
155
75
|
return new Promise((resolve) => {
|
|
156
76
|
let child
|
|
157
77
|
try {
|
|
@@ -236,6 +156,16 @@ function graftTool(spec, deps) {
|
|
|
236
156
|
},
|
|
237
157
|
execute: async (args, exec) => {
|
|
238
158
|
const root = repoFor(exec, deps.fallbackCwd)
|
|
159
|
+
// A machine with no graft at all used to answer "no index" here, which
|
|
160
|
+
// reads as "your repo is not indexed" when the truth is "the CLI is not
|
|
161
|
+
// installed". Install it first (once, in the background elsewhere) so
|
|
162
|
+
// the next call works; while that runs, say what is happening. Only when
|
|
163
|
+
// the caller wired ensure deps: the offline test suites construct these
|
|
164
|
+
// tools without them and must never reach the network.
|
|
165
|
+
if (deps.ensure !== undefined && cachedGraftCommand().prefix[0] === undefined) {
|
|
166
|
+
const ensured = await ensureGraft(deps.ensure)
|
|
167
|
+
if (!ensured.ok) return { text: ensured.reason }
|
|
168
|
+
}
|
|
239
169
|
const found = await deps.findRoot(root)
|
|
240
170
|
if (found === undefined) {
|
|
241
171
|
return {
|
package/src/index.js
CHANGED
|
@@ -49,6 +49,7 @@ import { dirname, join, resolve } from 'node:path'
|
|
|
49
49
|
import { createAutoSync } from './auto-sync.js'
|
|
50
50
|
import { createGraftCommand } from './command.js'
|
|
51
51
|
import { graftTools, resolveGraftCommand, runGraft } from './graft-tools.js'
|
|
52
|
+
import { ensureGraft } from './ensure-graft.js'
|
|
52
53
|
|
|
53
54
|
export const name = 'graft-status'
|
|
54
55
|
|
|
@@ -567,10 +568,28 @@ export function apply(ctx, config = {}) {
|
|
|
567
568
|
const autoSync = createAutoSync({
|
|
568
569
|
cliPath: graftCommand.prefix[0],
|
|
569
570
|
nodePath: graftCommand.command,
|
|
571
|
+
// graft may not exist yet at boot (fresh machine): re-resolve per use so
|
|
572
|
+
// the install ensureGraft performs mid-session is picked up without a
|
|
573
|
+
// restart, for both the state helpers and the sync-run interpreter.
|
|
574
|
+
resolveCli: () => resolveGraftCommand().prefix[0],
|
|
575
|
+
resolveCommand: () => resolveGraftCommand(),
|
|
570
576
|
minIntervalMs: config.autoBuildMinIntervalMs,
|
|
571
577
|
writeTools: config.autoBuildTools,
|
|
572
578
|
})
|
|
573
579
|
|
|
580
|
+
/**
|
|
581
|
+
* The auto-install, shared by the chip, the Build button, viz and the tools.
|
|
582
|
+
* `autoInstall: false` on the row (or DSH_GRAFT_NO_AUTOINSTALL=1) restores the
|
|
583
|
+
* old report-the-command behaviour; `graftVersion` pins what gets installed.
|
|
584
|
+
*/
|
|
585
|
+
const ensureDeps = {
|
|
586
|
+
autoInstall: config.autoInstall !== false,
|
|
587
|
+
spec: typeof config.graftVersion === 'string' && config.graftVersion !== '' ? config.graftVersion : undefined,
|
|
588
|
+
// ctx.get, not ctx.logger: property access on an undeclared service throws
|
|
589
|
+
// in cordis, and this plugin deliberately declares no injects.
|
|
590
|
+
log: (message) => { try { ctx.get?.('logger')?.info?.(`[graft] ${message}`) } catch { /* logging must never break the chip */ } },
|
|
591
|
+
}
|
|
592
|
+
|
|
574
593
|
const vizDeps = {
|
|
575
594
|
alive: vizAlive,
|
|
576
595
|
wait: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
@@ -600,6 +619,13 @@ export function apply(ctx, config = {}) {
|
|
|
600
619
|
liveOps = {
|
|
601
620
|
status: async (sessionId) => {
|
|
602
621
|
const from = cwdFor(sessionId)
|
|
622
|
+
// No graft at all is a different answer from "no index in this repo":
|
|
623
|
+
// kick the install off once (single-flight inside ensureGraft) and let
|
|
624
|
+
// the next poll read the real status.
|
|
625
|
+
if (resolveGraftCommand().prefix[0] === undefined) {
|
|
626
|
+
void ensureGraft(ensureDeps)
|
|
627
|
+
return { ok: false, reason: 'graft is not installed yet — installing it in the background. Press Build again in a moment.', from }
|
|
628
|
+
}
|
|
603
629
|
let root
|
|
604
630
|
try {
|
|
605
631
|
root = await findGraftRoot(from, io.exists, io.readText)
|
|
@@ -627,6 +653,8 @@ export function apply(ctx, config = {}) {
|
|
|
627
653
|
|
|
628
654
|
viz: async (sessionId) => {
|
|
629
655
|
const from = cwdFor(sessionId)
|
|
656
|
+
const ensured = await ensureGraft(ensureDeps)
|
|
657
|
+
if (!ensured.ok) return { ok: false, reason: ensured.reason, from }
|
|
630
658
|
const root = await findGraftRoot(from, io.exists, io.readText)
|
|
631
659
|
if (root === undefined) return { ok: false, reason: 'no graft index above ' + from, from }
|
|
632
660
|
|
|
@@ -664,6 +692,9 @@ export function apply(ctx, config = {}) {
|
|
|
664
692
|
|
|
665
693
|
build: async (sessionId) => {
|
|
666
694
|
const from = cwdFor(sessionId)
|
|
695
|
+
// A pressed Build is an explicit request: skip the failure cooldown.
|
|
696
|
+
const ensured = await ensureGraft({ ...ensureDeps, force: true })
|
|
697
|
+
if (!ensured.ok) return { ok: false, reason: ensured.reason, from }
|
|
667
698
|
let root
|
|
668
699
|
try {
|
|
669
700
|
root = await findGraftRoot(from, io.exists, io.readText)
|
|
@@ -709,6 +740,7 @@ export function apply(ctx, config = {}) {
|
|
|
709
740
|
if (config.tools !== false) {
|
|
710
741
|
const toolDeps = {
|
|
711
742
|
fallbackCwd: fallbackFrom,
|
|
743
|
+
ensure: ensureDeps,
|
|
712
744
|
findRoot: (start) => findGraftRoot(start, io.exists, io.readText),
|
|
713
745
|
noteUse: (root) => pulses.note(root),
|
|
714
746
|
recordSavings: (root, saved) => {
|