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 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` on PATH:
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
- A `postinstall` does this for you **when your package manager runs lifecycle scripts** — under pnpm 10+ they are blocked by default, so treat the command above as the required step there, not a fallback. If your global npm prefix needs elevation, the postinstall says so and prints the one command to run by hand.
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
  ![The Installed confirmation with the install details](assets/dsh-desktop-app-install-2.png)
78
85
 
79
- > The dialog notes that installed plugins **do not update automatically yet** — to upgrade, uninstall and install again. The graft CLI requirement (`npm install -g @nanonets/graft`) is the same.
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 PATH (`npm install -g @nanonets/graft`). A bun global install (`bun add -g @nanonets/graft`) is detected too, via `$BUN_INSTALL` or `~/.bun`.
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.** The postinstall was blocked or skipped (see Install): `npm install -g @nanonets/graft`.
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,所以 PATH 上必须有 `@nanonets/graft`:
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
- `postinstall` 会在**你的包管理器愿意运行生命周期脚本时**替你装好——pnpm 10+ 默认拦截它们,所以在 pnpm 下请把上面这条命令当作必需步骤,而不是后备。若全局 npm 前缀需要提权,postinstall 会说明并打印那条需要手动执行的命令。
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
  ![Installed 确认页与安装详情](assets/dsh-desktop-app-install-2.png)
78
85
 
79
- > 对话框也提示:已安装的插件**目前不会自动更新**——升级请先卸载再重装。graft CLI 的要求(`npm install -g @nanonets/graft`)不变。
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
- - PATH 上的 `graft` CLI(`npm install -g @nanonets/graft`)。bun 的全局安装(`bun add -g @nanonets/graft`)也能被识别,经由 `$BUN_INSTALL` 或 `~/.bun`。
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 上。** postinstall 被拦截或被跳过了(见安装):`npm install -g @nanonets/graft`。
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.4",
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
  },
@@ -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 NEVER FAILS THE INSTALL
13
- *
14
- * A global npm install is exactly the kind of thing that is refused: no write
15
- * permission on the prefix, an offline machine, a locked-down CI image, or
16
- * `--ignore-scripts` (which skips this file entirely and is a perfectly
17
- * reasonable thing for a careful user to do). None of those mean the PLUGIN
18
- * failed to install, so none of them exit non-zero. They print the one command
19
- * to run by hand and get out of the way.
20
- *
21
- * Set DSH_GRAFT_SKIP_POSTINSTALL=1 to skip it outright.
22
- */
23
-
24
- import { spawn } from 'node:child_process'
25
- import { delimiter, join } from 'node:path'
26
- import { existsSync } from 'node:fs'
27
-
28
- /** The package behind the `graft` command. The GitHub home is trailhq/Graft. */
29
- const PACKAGE = '@nanonets/graft'
30
-
31
- const BOLD = ''
32
- const DIM = ''
33
- const OFF = ''
34
-
35
- const say = (line = '') => process.stdout.write(`${line}\n`)
36
-
37
- /** Is `graft` already reachable? Checked before installing, so a re-install is a no-op. */
38
- function graftOnPath(env = process.env, exists = existsSync) {
39
- const path = String(env.PATH ?? env.Path ?? '')
40
- for (const dir of path.split(delimiter)) {
41
- if (dir === '') continue
42
- for (const name of ['graft.cmd', 'graft.exe', 'graft']) {
43
- if (exists(join(dir, name))) return true
44
- }
45
- }
46
- return false
47
- }
48
-
49
- /** Run a command to completion. Resolves with the exit code; never rejects. */
50
- function run(command, args) {
51
- return new Promise((resolve) => {
52
- let child
53
- try {
54
- // `shell: true` on Windows, because npm is a `.cmd` shim that cannot be
55
- // exec'd directly. No argument here contains a space, so the unquoted
56
- // argv join that makes `shell: true` dangerous cannot bite.
57
- child = spawn(command, args, { stdio: 'inherit', shell: process.platform === 'win32' })
58
- } catch {
59
- resolve(null)
60
- return
61
- }
62
- child.on('error', () => resolve(null))
63
- child.on('close', (code) => resolve(code))
64
- })
65
- }
66
-
67
- async function main() {
68
- if (process.env.DSH_GRAFT_SKIP_POSTINSTALL === '1') return
69
-
70
- say()
71
- say(`${BOLD}dsh-graft-plugin${OFF}`)
72
-
73
- if (graftOnPath()) {
74
- say(` ${DIM}graft is already on PATH - nothing to install.${OFF}`)
75
- } else {
76
- say(` installing the graft CLI (${PACKAGE}) globally...`)
77
- const code = await run('npm', ['install', '-g', PACKAGE])
78
- if (code === 0) {
79
- say(` ${BOLD}done.${OFF} graft is on PATH.`)
80
- } else {
81
- say()
82
- say(` ${BOLD}graft was not installed${OFF} (npm exited ${code === null ? 'without running' : code}).`)
83
- say(' This plugin needs it. Install it by hand:')
84
- say()
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
- if (loaded === undefined) {
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(deps.cliPath)
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 child = spawnFn(nodePath, [g.syncRun, root], {
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
+ }
@@ -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 { existsSync } from 'node:fs'
44
- import { delimiter, join } from 'node:path'
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 ?? (cachedCommand ??= resolveGraftCommand())
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) => {