dsh-jira-tasks 1.0.7 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.en.md CHANGED
@@ -6,6 +6,8 @@
6
6
 
7
7
  Shows the current JIRA project's **open / reopened** issues **assigned to the current user** below the DSH composer input. The JIRA base URL and token are configured in **Settings → Plugins → JIRA** (`JIRA_BASE_URL` / `JIRA_API_TOKEN` act as fallback); the project key and JQL are **configured per workspace** and persisted.
8
8
 
9
+ > **DSH version targeted: 0.1.7 (verified on `0.1.7-rc.2`).** The client slots `conversation.input.dock` / `plugins.item`, the settings service `ctx.configForms` (namespace = profile entry id `jira-tasks`), and the `ctx.effect` / `configForms.whileServed` disposal contracts all follow the 0.1.7 interfaces; the 0.1.6-era `settingsScope` / `settings.register` / `settings.plugin.item` APIs are gone and no longer used.
10
+
9
11
  ## Features
10
12
 
11
13
  - 📋 Panel shown below the composer in both new and active sessions (aligned with the input width in new sessions)
@@ -33,6 +35,10 @@ dsh plugin --profile web add github:liu3734/jira-tasks-dsh-plugin
33
35
 
34
36
  **Restart DSH** to activate.
35
37
 
38
+ > **Nothing shows up? Check `dsh.profile.bundles` first.** DSH mounts this package as a profile layer only when `"dsh-jira-tasks"` is listed in `dsh.profile.bundles` of `~/.dsh/profiles/web/package.json`; being in `dependencies` alone is not enough — the startup log then prints `patch: entry "jira-tasks" not found` and the plugin silently never loads. `dsh plugin --profile web add` normally appends that row, but it will not re-append when the package is already a dependency; add `"dsh-jira-tasks"` to `dsh.profile.bundles` by hand.
39
+
40
+ > Using GitHub Packages instead: configure `@liu3734:registry=https://npm.pkg.github.com/` plus a read token in the profile's `.npmrc`, then run `dsh plugin --profile web add @liu3734/dsh-jira-tasks`.
41
+
36
42
  <details>
37
43
  <summary>Manual install (without npm)</summary>
38
44
 
@@ -58,12 +64,18 @@ In a DSH session, use the Cordis tools: `cordis_define` (`kind: new`, `idPrefix:
58
64
 
59
65
  Open **Settings → Plugins → Plugin configuration → JIRA** and fill in:
60
66
 
61
- - **JIRA base URL**: e.g. `http://jira.example.com/` (stored in the user settings document and read back by the form)
62
- - **Access token / PAT**: written to the credential store (`$DSH_HOME/.credentials.yaml`); the browser only ever sees "configured", never the token itself
67
+ - **JIRA base URL**: e.g. `http://jira.example.com/` (written to this plugin entry's `Config.baseUrl` — the `jira-tasks` row in the profile's `cordis.patch.yml` — and read back by the form; a host-refused write now surfaces as an error instead of failing silently)
68
+ - **Access token / PAT**: written to the credential store (`$DSH_HOME/.credentials.yaml`) under the plugin-owned ref `JIRA_TASKS_TOKEN`; the browser only ever sees "configured", never the token itself
63
69
 
64
- Leaving the token blank on save keeps the existing one; clearing the address on save removes the override and falls back to the environment. Auth is auto-detected: tokens containing `:` use Basic, otherwise Bearer (JIRA PAT).
70
+ Leaving the token blank on save keeps the existing one; clearing the address on save removes the override and falls back to the credential store / environment. Auth is auto-detected: tokens containing `:` use Basic, otherwise Bearer (JIRA PAT).
65
71
 
66
- > **A token supplied by the environment cannot be overwritten from this card.** If the environment that launched DSH already defines `JIRA_API_TOKEN` (a Windows *user-level* variable counts), DSH treats that reference as read-only: the card disables the token field and explains why. The panel already uses that variable, so **no save is needed**; to manage the token from settings instead, remove the variable first (Windows: System Properties → Environment Variables, or PowerShell `[Environment]::SetEnvironmentVariable('JIRA_API_TOKEN', $null, 'User')`) and restart DSH.
72
+ > **The settings-page token takes precedence over the environment.** When the environment that launched DSH already defines `JIRA_API_TOKEN` (a Windows *user-level* variable counts), the card is still editable: it stores the token in its own ref `JIRA_TASKS_TOKEN`, which DSH accepts (it only refuses to write a ref the launching environment shadows), and the Host resolves tokens in this order:
73
+ >
74
+ > ```
75
+ > JIRA_TASKS_TOKEN (settings card) > JIRA_API_TOKEN > JIRA_TOKEN
76
+ > ```
77
+ >
78
+ > Click **Clear settings token** in the card to fall back to the environment variable again.
67
79
 
68
80
  #### Connection test
69
81
 
@@ -73,7 +85,7 @@ The card's footer carries a status light and a **Test connection** button:
73
85
  - **Green** = address and token work (the current user is shown); **red** = unusable (JIRA's reason, e.g. 401, is shown); **grey** = address or token not configured
74
86
  - **Test connection** probes what is currently in the fields, saved or not, so you can check before saving
75
87
 
76
- Environment variables / credentials still work as a **fallback** (used when the settings card is empty), hot-reloaded without a restart:
88
+ Environment variables / credentials still work as a **fallback** (used when the settings card leaves `baseUrl` empty), hot-reloaded without a restart. Address precedence is `Config.baseUrl` > `JIRA_BASE_URL` > `JIRA_URL`, and `JIRA_BASE_URL` may live in the launching environment or in `.credentials.yaml` — note that a stale record (e.g. an old domain left in the credential file) becomes effective again as soon as the settings page clears the address:
77
89
 
78
90
  ```yaml
79
91
  JIRA_BASE_URL: "http://jira.example.com/"
@@ -81,7 +93,7 @@ JIRA_API_TOKEN: "<PAT or user:token>"
81
93
  ```
82
94
 
83
95
  - Base URL aliases: `JIRA_BASE_URL` / `JIRA_URL`
84
- - Token aliases: `JIRA_API_TOKEN` / `JIRA_TOKEN`
96
+ - Token resolution order: `JIRA_TASKS_TOKEN` (written by the settings card) → `JIRA_API_TOKEN` → `JIRA_TOKEN`; the first match wins
85
97
 
86
98
  ### 2. Project key and JQL (per workspace)
87
99
 
@@ -117,24 +129,26 @@ dsh plugin --profile web remove dsh-jira-tasks
117
129
  </details>
118
130
 
119
131
  <details>
120
- <summary>Saving the token fails with "is supplied read-only by the launching environment"</summary>
132
+ <summary>Can the settings card override the token from the environment?</summary>
121
133
 
122
- `JIRA_API_TOKEN` (or `JIRA_TOKEN`) is supplied by the environment that launched DSH, and DSH treats that reference as read-only: a write would be shadowed by the variable, so it is refused. The panel already uses that variable and queries fine, so **there is nothing to save**.
134
+ **Yes.** The card stores the token under the plugin-owned ref `JIRA_TASKS_TOKEN` instead of writing the environment's `JIRA_API_TOKEN`. DSH only refuses to write a ref the launching environment *shadows*, so its own ref is always writable: even with `JIRA_API_TOKEN` exported by the shell or the OS, the field accepts input, the save succeeds, and the Host prefers it:
123
135
 
124
- To manage the token from the settings card instead, remove the variable and restart DSH:
125
-
126
- - Windows (PowerShell): `[Environment]::SetEnvironmentVariable('JIRA_API_TOKEN', $null, 'User')`, then reopen the terminal
127
- - Windows (GUI): System Properties → Advanced → Environment Variables, delete the user variable
128
- - macOS / Linux: remove the export from `~/.zshrc` / `~/.bashrc` (or equivalent) and reopen the terminal
136
+ ```
137
+ JIRA_TASKS_TOKEN (settings card) > JIRA_API_TOKEN > JIRA_TOKEN
138
+ ```
129
139
 
130
- Since v1.0.7 the card detects a read-only token, disables the field, and shows this guidance inline instead of failing on save.
140
+ - The card names the effective source: with a saved token it says "overrides environment variable JIRA_API_TOKEN"; without one it says "currently using environment variable JIRA_API_TOKEN — fill in and save to override"
141
+ - Saving re-probes the connection; **Clear settings token (fall back to environment)** removes the override
142
+ - The one remaining case that reports `is supplied read-only by the launching environment` is someone exporting `JIRA_TASKS_TOKEN` itself — that ref really is read-only then; remove it (Windows: System Properties → Environment Variables, or PowerShell `[Environment]::SetEnvironmentVariable('JIRA_TASKS_TOKEN', $null, 'User')`) and restart DSH
131
143
  </details>
132
144
 
133
145
  <details>
134
146
  <summary>Panel does not appear</summary>
135
147
 
136
148
  - Make sure it is installed and DSH was **restarted**; in new sessions the panel sits below the input
137
- - Check the DSH startup log for profile plugin load errors
149
+ - Check that `dsh.profile.bundles` in `~/.dsh/profiles/web/package.json` lists `"dsh-jira-tasks"` (the most common cause of a silent no-show — see the Install note)
150
+ - Check the DSH startup log: `patch: entry "jira-tasks" not found` means the profile layer was never composed; `webserver: duplicate exact route` means a route was registered twice (the plugin releases its routes through `ctx.effect`, so this points at a second copy)
151
+ - A browser console error `client-modules: could not load "dsh-jira-tasks"` means `/plugins/dsh-jira-tasks/client.js` was not served — confirm `exports["./client"]` resolves to the built `lib/client.js`
138
152
  </details>
139
153
 
140
154
  ## Architecture & Implementation Details
@@ -143,21 +157,21 @@ Since v1.0.7 the card detects a read-only token, disables the field, and shows t
143
157
  <summary>Expand</summary>
144
158
 
145
159
  ```
146
- ┌─────────── Browser (Client) ───────────┐ ┌──────────── Host ──────────────┐
147
- │ conversation.composer.dock (active) │ │ webServer route /jira/api/search │
148
- │ conversation.input.dock (new, order:99) │ │ ↓ │
149
- │ ↓ on mount/refresh fetch POST │ │ settings.get("jira-tasks") │
150
- │ render: list / error / unconfigured │ │ credentials.resolve(JIRA_*) │
151
- │ localStorage per-workspace key/JQL │ │ subprocess.spawn(curl …) │
152
- │ settings.plugin.item (Settings card) │ │ ↓ stdout JSON │
153
- └────────────────────────────────────────────┘ │ parse issues → {ok,issues} │
160
+ ┌────────────── Browser (Client) ──────────────┐ ┌─────────────── Host ───────────────┐
161
+ │ conversation.input.dock (both states) │ │ webServer route /jira/api/search │
162
+ │ CSS order:99 -> below the input, full width │ │ entry Config.baseUrl (volatile) │
163
+ │ on mount/refresh: fetch POST │ │ credentials.resolve(TOKEN_REFS) │
164
+ │ render: list / error / unconfigured │ │ subprocess.spawn(curl ...) │
165
+ │ localStorage: per-workspace key/JQL │ │ parse stdout JSON │
166
+ │ plugins.item (Settings card) │ │ return {ok, issues} │
167
+ └──────────────────────────────────────────────┘ └────────────────────────────────────┘
154
168
  └────────────────────────────────┘
155
169
  ```
156
170
 
157
- - **Host**: registers the `jira-tasks` settings namespace (`baseUrl`, readable) and a `webServer` route `POST /jira/api/search`; the address comes from the settings document, the token from the `credentials` service (Settings card / env / `$DSH_HOME/.credentials.yaml`, hot-reloaded); queries run through `subprocess` spawning `curl` directly, with the auth header passed via stdin (`--config -`) so the token never appears in argv.
158
- - **Client**: a standard `window.__ModuleLoader__.load({ id, factory })` web bundle; registers `conversation.composer.dock` (active sessions) and `conversation.input.dock` (new sessions, flex `order: 99` below the input, aligned width), plus `settings.plugin.item` (`key: "jira-tasks"`) for the Settings card — the address is written through `settingsScope` and the token through `remote.credentials`.
171
+ - **Host**: declares the entry's own `Config` (`baseUrl`, `volatile` — since DSH 0.1.5 a settings page is built from it; `settings.configure({ auto: false }, ctx.fiber)` turns the auto-generated page off and hands its disposer back to `ctx.effect`) plus `webServer` routes `POST /jira/api/search` and `POST /jira/api/test`, each wrapped in its own `ctx.effect` (a reload otherwise hits "duplicate route" and fails activation; non-POST requests get 405); the token comes from the `credentials` service resolved in the order `JIRA_TASKS_TOKEN` (written by the Settings card) → `JIRA_API_TOKEN` → `JIRA_TOKEN` (`$DSH_HOME/.credentials.yaml` / environment, hot-reloaded), so a saved card token overrides the environment; queries run through `subprocess` spawning `curl` directly, with the auth header passed via stdin (`--config -`) so the token never appears in argv.
172
+ - **Client**: a standard `window.__ModuleLoader__.load({ id, factory })` web bundle requiring only `"react"`; registers **only `conversation.input.dock`** (`order: 10`) — that seat renders in both new and active sessions and is a full-width row of `composerStack` (`flex-direction: column`), where the panel's own CSS `order: 99` places it below the input card at the card's width. `conversation.composer.dock` is deliberately NOT used: in 0.1.7 it renders into InputBar's `.dock` **row**, side by side with the context meter, so a full-width panel there gets its right-hand content covered by the meter. Also registers the plugin configuration card on `plugins.item` (`id: "jira-tasks"`) — the address form comes from `ctx.configForms.get("jira-tasks")` and the token is written through `remote.credentials` to `JIRA_TASKS_TOKEN`; `configForms.whileServed` keeps it hidden when the host serves no such namespace. The card's `summary` view is rendered by a dispatcher that calls **no hooks**, while the form lives in a separate `JiraSettingsPage`, so flipping `view` on one mounted instance never changes its hook count.
159
173
  - **Why not the `shell` service**: `shell` wraps commands with `sandbox-exec`, which is broken on some macOS versions (`sandbox_apply: Operation not permitted`); `subprocess` is the raw process seam without this issue.
160
- - **New-session display**: the DSH shell does not render `composer.dock` during the hero (blank session) phase, so the plugin also registers `input.dock` and de-duplicates by "session has messages".
174
+ - **One registration covers both states**: `conversation.input.dock` renders whenever a session and its input exist, so new and active sessions need no separate registrations (the older `composer.dock` + blank-session de-duplication was both unnecessary under 0.1.7 and the cause of the meter-row collision).
161
175
 
162
176
  **Differences from the dynamic version**
163
177
 
package/README.md CHANGED
@@ -6,6 +6,8 @@
6
6
 
7
7
  在 DSH 会话**输入框下方**展示当前 JIRA 项目**指派给当前用户**的「开启 / 重新开启」任务列表。JIRA 地址与令牌在**设置 → 插件 → JIRA** 中配置(`JIRA_BASE_URL` / `JIRA_API_TOKEN` 作为回退);项目 Key 与 JQL **按工作区配置**并持久化。
8
8
 
9
+ > **适配的 DSH 版本:0.1.7(`0.1.7-rc.2` 实测)。** 客户端槽位 `conversation.input.dock` / `plugins.item`、设置服务 `ctx.configForms`(命名空间 = profile 条目 id `jira-tasks`)、`ctx.effect` / `configForms.whileServed` 的注销契约均按 0.1.7 的接口实现;0.1.6 及更早版本的 `settingsScope` / `settings.register` / `settings.plugin.item` 已不再使用。
10
+
9
11
  ## 功能
10
12
 
11
13
  - 📋 新会话与活跃会话的输入框下方均展示任务面板(新会话时与输入框等宽)
@@ -42,6 +44,8 @@ dsh plugin --profile web add github:liu3734/jira-tasks-dsh-plugin
42
44
 
43
45
  **重启 DSH** 后生效。
44
46
 
47
+ > **装完没反应?先查 `dsh.profile.bundles`。** DSH 只有当 `~/.dsh/profiles/web/package.json` 的 `dsh.profile.bundles` 里列了 `"dsh-jira-tasks"` 时才把本包当作 profile 层挂载;只出现在 `dependencies` 里不够——此时启动日志会打 `patch: entry "jira-tasks" not found`,插件静默不加载。`dsh plugin --profile web add` 一般会补上这一行,但该包已在 `dependencies` 中时重装不会重新补,手动往 `dsh.profile.bundles` 追加 `"dsh-jira-tasks"` 即可。
48
+
45
49
  > 若改用 GitHub Packages 源:先在 profile 的 `.npmrc` 配置 `@liu3734:registry=https://npm.pkg.github.com/` 及读取令牌,再执行 `dsh plugin --profile web add @liu3734/dsh-jira-tasks`。
46
50
 
47
51
  <details>
@@ -69,12 +73,18 @@ dsh plugin --profile web add github:liu3734/jira-tasks-dsh-plugin
69
73
 
70
74
  打开 **设置 → 插件 → 插件配置 → JIRA**,填写:
71
75
 
72
- - **JIRA 地址**:如 `http://jira.example.com/`(存入用户设置文档,可在界面回读)
73
- - **访问令牌 / PAT**:写入凭据存储(`$DSH_HOME/.credentials.yaml`),前端只显示"已配置",不回传令牌本身
76
+ - **JIRA 地址**:如 `http://jira.example.com/`(写入本插件 profile 条目 `jira-tasks` 的 `Config.baseUrl`,即 profile 的 `cordis.patch.yml`,可在界面回读;保存被宿主拒绝时会直接报错,不会静默失败)
77
+ - **访问令牌 / PAT**:写入凭据存储(`$DSH_HOME/.credentials.yaml`,引用名是插件自有的 `JIRA_TASKS_TOKEN`),前端只显示"已配置",不回传令牌本身
74
78
 
75
- 留空并保存会保持已有令牌不变;地址留空并保存则清除设置项,回退到环境变量。认证自动识别:令牌含 `:` 用 Basic,否则用 Bearer(JIRA PAT)。
79
+ 留空并保存会保持已有令牌不变;地址留空并保存则清除设置项,回退到凭据存储 / 环境变量。认证自动识别:令牌含 `:` 用 Basic,否则用 Bearer(JIRA PAT)。
76
80
 
77
- > **令牌已由环境变量提供时,设置页无法改写它**:若启动 DSH 的环境里已设置 `JIRA_API_TOKEN`(Windows 用户级环境变量也算),DSH 会将该引用视为只读,卡片会禁用令牌输入并说明原因。此时面板已直接使用该环境变量,**无需保存**;要改由设置页管理,请先移除环境变量(Windows:系统属性 → 环境变量,或 PowerShell `[Environment]::SetEnvironmentVariable('JIRA_API_TOKEN', $null, 'User')`)后重启 DSH。
81
+ > **设置页令牌优先于环境变量**:启动 DSH 的环境里已有 `JIRA_API_TOKEN`(Windows 用户级环境变量也算)时,卡片照常可以输入并保存——保存的是插件自有引用 `JIRA_TASKS_TOKEN`,DSH 不会拒绝(它只拒绝写入会被环境遮蔽的同名引用),Host 解析时也把它排在环境变量之前:
82
+ >
83
+ > ```
84
+ > JIRA_TASKS_TOKEN(设置页) > JIRA_API_TOKEN > JIRA_TOKEN
85
+ > ```
86
+ >
87
+ > 想改回用环境变量,点卡片里的「清除设置页令牌」即可。
78
88
 
79
89
  #### 连接测试
80
90
 
@@ -84,7 +94,7 @@ dsh plugin --profile web add github:liu3734/jira-tasks-dsh-plugin
84
94
  - **绿** = 地址与令牌可用(并显示当前登录用户);**红** = 不可用(显示 JIRA 返回的原因,如 401 认证失败);**灰** = 地址或令牌未配置
85
95
  - 点 **测试连接** 会用**当前输入框里的内容**(未保存也可)立即测试,方便改完再存
86
96
 
87
- 以下环境变量 / 凭据仍作为**回退**(设置页未配置时生效,兼容旧部署),热加载无需重启:
97
+ 以下环境变量 / 凭据仍作为**回退**(设置页未配置 `baseUrl` 时生效,兼容旧部署),热加载无需重启。注意地址的优先级是 `Config.baseUrl` > `JIRA_BASE_URL` > `JIRA_URL`,而 `JIRA_BASE_URL` 既可以从启动环境读,也可以像下面这样存在 `.credentials.yaml` 里——旧域名的记录留在凭据文件里时,设置页一旦清空地址就会回退到它:
88
98
 
89
99
  ```yaml
90
100
  JIRA_BASE_URL: "http://jira.example.com/"
@@ -92,7 +102,7 @@ JIRA_API_TOKEN: "<PAT 或 user:token>"
92
102
  ```
93
103
 
94
104
  - 地址别名:`JIRA_BASE_URL` / `JIRA_URL`
95
- - 令牌别名:`JIRA_API_TOKEN` / `JIRA_TOKEN`
105
+ - 令牌解析顺序:`JIRA_TASKS_TOKEN`(设置页写入)→ `JIRA_API_TOKEN` → `JIRA_TOKEN`,前者优先
96
106
 
97
107
  ### 2. 项目 Key 与 JQL(按工作区)
98
108
 
@@ -131,24 +141,26 @@ dsh plugin --profile web remove dsh-jira-tasks
131
141
  </details>
132
142
 
133
143
  <details>
134
- <summary>保存令牌报 “is supplied read-only by the launching environment”</summary>
135
-
136
- `JIRA_API_TOKEN`(或 `JIRA_TOKEN`)已由启动 DSH 的环境提供,DSH 判定该引用只读:写入会被环境变量遮蔽,因此拒绝保存。此时面板已经直接使用该环境变量,能正常查询,**无需在设置页保存**。
144
+ <summary>环境变量里的令牌,能不能在设置页覆盖?</summary>
137
145
 
138
- 如需改由设置页管理令牌,先移除环境变量再重启 DSH:
146
+ **能。** 卡片把令牌保存到插件自有引用 `JIRA_TASKS_TOKEN`,而不是直接写环境变量用的 `JIRA_API_TOKEN`。DSH 只拒绝写入「会被启动环境遮蔽的同名引用」,插件自有引用不受影响,所以哪怕 shell / 系统里已导出 `JIRA_API_TOKEN`,设置页也能正常输入并保存,并且 Host 解析时优先用它:
139
147
 
140
- - Windows(PowerShell):`[Environment]::SetEnvironmentVariable('JIRA_API_TOKEN', $null, 'User')`,然后重开终端
141
- - Windows(图形界面):系统属性 → 高级 → 环境变量,删除对应用户变量
142
- - macOS / Linux:从 `~/.zshrc` / `~/.bashrc` 等启动脚本中移除后重开终端
148
+ ```
149
+ JIRA_TASKS_TOKEN(设置页) > JIRA_API_TOKEN > JIRA_TOKEN
150
+ ```
143
151
 
144
- v1.0.7 起卡片会检测只读令牌并直接禁用输入框、在卡片内给出上述提示,不再等到保存才报英文错误。
152
+ - 卡片会显示当前生效来源:已保存设置页令牌时提示「优先于环境变量 JIRA_API_TOKEN」;未保存时提示「当前使用环境变量 JIRA_API_TOKEN,填写并保存即可覆盖」
153
+ - 保存后自动重测连接;点「清除设置页令牌(回退到环境变量)」可删除覆盖值
154
+ - 唯一仍会报 `is supplied read-only by the launching environment` 的情况:有人把 `JIRA_TASKS_TOKEN` 本身也导出到了启动环境——那种情况下该引用确实只读,需先移除它(Windows:系统属性 → 环境变量,或 PowerShell `[Environment]::SetEnvironmentVariable('JIRA_TASKS_TOKEN', $null, 'User')`)并重启 DSH
145
155
  </details>
146
156
 
147
157
  <details>
148
158
  <summary>面板不显示</summary>
149
159
 
150
160
  - 确认已安装并**重启 DSH**;新会话面板位于输入框下方
151
- - 检查 DSH 启动日志中 profile 插件是否加载成功
161
+ - 检查 `~/.dsh/profiles/web/package.json` 的 `dsh.profile.bundles` 是否包含 `"dsh-jira-tasks"`(这是最常见的漏装原因,见「安装」一节的提示)
162
+ - 检查 DSH 启动日志:出现 `patch: entry "jira-tasks" not found` 即为 profile 层未挂载;出现 `webserver: duplicate exact route` 说明同一路由被注册了两次(插件自身已用 `ctx.effect` 释放旧路由,若仍有说明有第二份副本)
163
+ - 浏览器控制台若报 `client-modules: could not load "dsh-jira-tasks"`,说明 `/plugins/dsh-jira-tasks/client.js` 没取到——确认 `package.json` 的 `exports["./client"]` 指向已构建的 `lib/client.js`
152
164
  </details>
153
165
 
154
166
  ## 架构与实现细节
@@ -158,20 +170,19 @@ v1.0.7 起卡片会检测只读令牌并直接禁用输入框、在卡片内给
158
170
 
159
171
  ```
160
172
  ┌────────────────────────────────────────┐ ┌────────────────────────────────────┐
161
- │ conversation.composer.dock(活跃会话) │ │ webServer 路由 /jira/api/search │
162
- │ conversation.input.dock(新会话) │ │ ↓ │
163
- │ 面板 hero 布局(flex order:99) │ │ settings.get("jira-tasks").baseUrl │
164
- │ ↓ 挂载 / 刷新时 fetch POST │ │ credentials.resolve(JIRA_API_TOKEN) │
173
+ │ conversation.input.dock(两种会话) │ │ webServer 路由 /jira/api/search │
174
+ │ CSS order:99 → 输入框下方、整宽 │ │ 条目 Config.baseUrl(volatile 引用) │
175
+ │ ↓ 挂载 / 刷新时 fetch POST │ │ credentials.resolve(TOKEN_REFS) │
165
176
  │ 渲染:任务列表 / 错误 / 未配置 │ │ subprocess.spawn(curl …) │
166
177
  │ localStorage 按工作区存取项目 Key/JQL │ │ ↓ stdout JSON │
167
- │ settings.plugin.item(设置页卡片) │ │ 解析 issues → 返回 {ok,issues} │
178
+ │ plugins.item(设置页卡片) │ │ 解析 issues → 返回 {ok,issues} │
168
179
  └────────────────────────────────────────┘ └────────────────────────────────────┘
169
180
  ```
170
181
 
171
- - **Host**:注册 `settings` 命名空间 `jira-tasks`(`baseUrl`,可读)与 `webServer` 路由 `POST /jira/api/search`;地址优先读设置文档,令牌经 `credentials` 服务解析(设置页写入 / 环境变量 / `$DSH_HOME/.credentials.yaml`,热加载);查询用 `subprocess` 直接 `spawn curl`,认证头经 stdin(`--config -`)传入,令牌不进入命令行参数。
172
- - **Client**:`window.__ModuleLoader__.load({ id, factory })` 标准 web bundle;注册 `conversation.composer.dock`(活跃会话,注册 `order: 5`)与 `conversation.input.dock`(新会话,注册 `order: 10`)。新会话面板走 hero 布局:面板元素自身 `flex order: 99` 排在输入框之后下方,并以 `--dsh-composer-side-clearance` / `--dsh-composer-card-max-width` 与输入卡等宽。另注册 `settings.plugin.item`(`key: "jira-tasks"`)作为设置页卡片:地址经 `settingsScope` 写入命名空间,令牌经 `remote.credentials` 写入凭据存储。
182
+ - **Host**:声明条目自身的 `Config`(`baseUrl`,`volatile`,DSH 0.1.5 起设置页的表单直接来自它;`settings.configure({ auto: false }, ctx.fiber)` 关掉自动生成页,并把返回的注销函数交回 `ctx.effect`)与 `webServer` 路由 `POST /jira/api/search`、`POST /jira/api/test`(两条路由各自包在 `ctx.effect` 里,卸载/重载时先释放,否则重挂载会撞上「重复路由」直接抛错;非 POST 返回 405);令牌经 `credentials` 服务按 `JIRA_TASKS_TOKEN`(设置页写入)→ `JIRA_API_TOKEN` → `JIRA_TOKEN` 的顺序解析(`$DSH_HOME/.credentials.yaml` / 环境变量,热加载),因此设置页保存的令牌能覆盖环境变量;查询用 `subprocess` 直接 `spawn curl`,认证头经 stdin(`--config -`)传入,令牌不进入命令行参数。
183
+ - **Client**:`window.__ModuleLoader__.load({ id, factory })` 标准 web bundle,仅 `require("react")`;**只注册 `conversation.input.dock` 一处**(`order: 10`)。该槽位在新会话与活跃会话下都会渲染,属于 `composerStack`(`flex-direction: column`)的整宽纵向行;面板元素自身 `flex order: 99` 排到输入卡之后,即输入框下方,并以 `--dsh-composer-side-clearance` / `--dsh-composer-card-max-width` 与输入卡等宽。之所以不用 `conversation.composer.dock`:0.1.7 里那一槽位渲染进 InputBar 的 `.dock`**横向** flex 行,与上下文占用环并排,整宽面板会被占用环压住右侧内容。插件配置卡片注册到 `plugins.item`(`id: "jira-tasks"`),表单来自 `ctx.configForms.get("jira-tasks")`(地址)与 `remote.credentials`(令牌写入 `JIRA_TASKS_TOKEN`),并用 `configForms.whileServed` 保证宿主未提供该命名空间时不显示;`plugins.item` 的摘要位由一个**不调用任何 hook** 的分发组件负责,表单在独立的 `JiraSettingsPage` 里,避免同一实例在 `view` 切换时改变 hook 数量。
173
184
  - **为什么不用 `shell` 服务**:`shell` 会套 `sandbox-exec`,部分 macOS 上不可用(`sandbox_apply: Operation not permitted`);`subprocess` 是原始进程缝,无此问题。
174
- - **新会话显示**:DSH 壳在 hero(空白会话)阶段不渲染 `composer.dock`,故额外注册 `input.dock`,并用「会话是否已有消息」去重(新版 DSH 依据 `SessionSnapshot.blank`),避免双份面板。
185
+ - **一处注册覆盖两种会话**:`conversation.input.dock` 只要有 session + input 就会渲染,新会话与活跃会话无需分别注册(旧版曾用 `composer.dock` + 空白判定去重,0.1.7 下既不必要、又会与占用环抢同一行)。
175
186
 
176
187
  **与动态插件版的差异**
177
188
 
package/lib/client.js CHANGED
@@ -8,6 +8,7 @@ window.__ModuleLoader__.load({
8
8
  var h = React.createElement;
9
9
 
10
10
  var CSS = ".jt-root{box-sizing:border-box;width:100%;padding:2px 0 6px;flex:none;display:flex;flex-direction:column}"
11
+ + "/* composerStack 是 flex-direction:column:order:99 把面板排到输入卡之后,即输入框下方。 */"
11
12
  + ".jt-hero{order:99;padding:0 var(--dsh-composer-side-clearance, 16px)}"
12
13
  + ".jt-panel{box-sizing:border-box;width:100%;max-width:var(--dsh-composer-card-max-width, 780px);margin:0 auto;border:1px solid var(--dsw-alias-border-l1, rgba(127,127,127,.28));background:var(--dsw-alias-bg-layer-1, rgba(127,127,127,.07));border-radius:10px;overflow:hidden}"
13
14
  + ".jt-header{display:flex;align-items:center;gap:8px;min-height:30px;padding:2px 6px 2px 4px}"
@@ -95,15 +96,6 @@ window.__ModuleLoader__.load({
95
96
  persistAll(all);
96
97
  }
97
98
 
98
- // 新版 DSH:SessionSnapshot.blank 是“会话日志为空”的规范标记;旧版 DSH:消息位于 chat.timeline / chat.legacy.nodes。
99
- function hasMessages(session) {
100
- if (!session) return false;
101
- if (typeof session.blank === "boolean") return !session.blank;
102
- if (session.chat && session.chat.timeline && session.chat.timeline.length > 0) return true;
103
- if (session.chat && session.chat.legacy && session.chat.legacy.nodes && session.chat.legacy.nodes.length > 0) return true;
104
- return false;
105
- }
106
-
107
99
  function query(projectKey, jql) {
108
100
  return fetch("/jira/api/search", {
109
101
  method: "POST",
@@ -122,7 +114,6 @@ window.__ModuleLoader__.load({
122
114
  }
123
115
 
124
116
  function JiraTasksDock(props) {
125
- var blankOnly = !!(props && props.blankOnly === true);
126
117
  var useWorkspaces = props && typeof props.useWorkspaces === "function" ? props.useWorkspaces : null;
127
118
  var sessionId = props && props.sessionId;
128
119
  var wsState = useWorkspaces ? useWorkspaces(function (s) { return s; }) : null;
@@ -173,8 +164,9 @@ window.__ModuleLoader__.load({
173
164
  return function () { cancelled = true; };
174
165
  }, [config, tick]);
175
166
 
176
- if (blankOnly && hasMessages(props.session)) return null;
177
-
167
+ // 面板只注册 conversation.input.dock 一处(见 apply 里的说明):
168
+ // 该槽位在“新会话”和“活跃会话”下都会渲染,且是整行纵向排布的元素,
169
+ // 因此这里没有“仅空白会话显示”的分支,也不需要去重。
178
170
  function openEditor() {
179
171
  setDraftKey(config.projectKey);
180
172
  setDraftJql(config.jql || defaultJql(config.projectKey));
@@ -187,10 +179,8 @@ window.__ModuleLoader__.load({
187
179
  setEditing(false);
188
180
  }
189
181
 
190
- var heroLayout = blankOnly;
191
-
192
182
  if (editing) {
193
- return h("div", { className: heroLayout ? "jt-root jt-hero" : "jt-root" },
183
+ return h("div", { className: "jt-root jt-hero" },
194
184
  h("div", { className: "jt-panel" },
195
185
  h("div", { className: "jt-header" }, h("span", { className: "jt-title" }, "JIRA 任务设置")),
196
186
  h("div", { className: "jt-body" },
@@ -260,7 +250,7 @@ window.__ModuleLoader__.load({
260
250
  body = h("div", { className: "jt-hint" }, "未设置 JIRA 项目 Key,", h("button", { className: "jt-linklike", onClick: openEditor }, "点击配置"));
261
251
  }
262
252
 
263
- return h("div", { className: heroLayout ? "jt-root jt-hero" : "jt-root" },
253
+ return h("div", { className: "jt-root jt-hero" },
264
254
  h("div", { className: "jt-panel" },
265
255
  h("div", { className: "jt-header" },
266
256
  h("button", {
@@ -285,8 +275,13 @@ window.__ModuleLoader__.load({
285
275
  // ---- 设置页卡片:设置 → 插件 → JIRA ----
286
276
  // 地址存 settings 命名空间(可读);令牌写 credentials(永不随响应回传)。
287
277
  var SETTINGS_NS = "jira-tasks";
288
- var TOKEN_REF = "JIRA_API_TOKEN";
289
- var TOKEN_REFS = ["JIRA_API_TOKEN", "JIRA_TOKEN"];
278
+ // 令牌写入插件自有引用,而不是直接写 JIRA_API_TOKEN:启动环境里导出的
279
+ // JIRA_API_TOKEN 会让那个引用只读(DSH 拒绝写入会被环境遮蔽的引用),而
280
+ // 插件自有引用不受影响,因此设置页保存的值可以覆盖环境变量——优先级由
281
+ // Host 的解析顺序保证(JIRA_TASKS_TOKEN → JIRA_API_TOKEN → JIRA_TOKEN)。
282
+ var TOKEN_REF = "JIRA_TASKS_TOKEN";
283
+ var ENV_TOKEN_REFS = ["JIRA_API_TOKEN", "JIRA_TOKEN"];
284
+ var TOKEN_REFS = [TOKEN_REF].concat(ENV_TOKEN_REFS);
290
285
 
291
286
  // credentials-local 拒绝写入“由启动环境提供”的引用时,原始信息是英文的;这里取出引用名并给出可操作的中文说明。
292
287
  function shadowedTokenRef(message) {
@@ -295,9 +290,9 @@ window.__ModuleLoader__.load({
295
290
  }
296
291
  function tokenReadOnlyHint(ref) {
297
292
  var name = ref || TOKEN_REF;
298
- return "令牌由启动 DSH 的环境变量 " + name + " 提供(只读),面板已直接使用,无需在此保存。"
299
- + "如需改由设置页管理,请先移除该环境变量并重启 DSH;Windows 可在 PowerShell 执行 "
300
- + "[Environment]::SetEnvironmentVariable('" + name + "', $null, 'User') 后重开终端。";
293
+ return "环境变量 " + name + " 由启动 DSH 的终端提供且只读,无法写入该引用。"
294
+ + "请先移除它(Windows:系统属性 → 环境变量,或 PowerShell "
295
+ + "[Environment]::SetEnvironmentVariable('" + name + "', $null, 'User'))并重启 DSH。";
301
296
  }
302
297
 
303
298
  function useScopeSnapshot(scope) {
@@ -316,23 +311,37 @@ window.__ModuleLoader__.load({
316
311
  function readTokenInfo() {
317
312
  return credentials.describe(TOKEN_REFS).then(function (res) {
318
313
  if (!res || !res.ok || !res.value) return { configured: false, writable: true };
319
- for (var i = 0; i < TOKEN_REFS.length; i++) {
320
- var ref = TOKEN_REFS[i];
321
- var info = res.value[ref];
322
- // 命中的第一个引用即生效来源;writable 为 false 表示只读(由启动环境提供),写不进去。
314
+ var value = res.value;
315
+ var override = value[TOKEN_REF];
316
+ var overridden = !!(override && override.configured);
317
+ var fallbackRef = "", fallbackSource = "";
318
+ for (var i = 0; i < ENV_TOKEN_REFS.length; i++) {
319
+ var info = value[ENV_TOKEN_REFS[i]];
323
320
  if (info && info.configured) {
324
- return { configured: true, writable: info.writable !== false, ref: ref };
321
+ fallbackRef = ENV_TOKEN_REFS[i];
322
+ fallbackSource = info.source || "";
323
+ break;
325
324
  }
326
325
  }
327
- var primary = res.value[TOKEN_REF];
328
- return { configured: false, writable: primary ? primary.writable !== false : true, ref: TOKEN_REF };
326
+ return {
327
+ configured: overridden || fallbackRef !== "",
328
+ overridden: overridden,
329
+ // 只有插件自有引用可能被环境遮蔽;JIRA_API_TOKEN 只读不影响这里的写入。
330
+ writable: !override || override.writable !== false,
331
+ // 仅当回退值确实来自启动环境时才称它为“环境变量”(旧版本可能把它存在凭据文件里)。
332
+ envRef: fallbackSource === "env" ? fallbackRef : ""
333
+ };
329
334
  }).catch(function () {
330
335
  // 状态读取失败不阻断保存,交由写入路径给出真实错误。
331
336
  return { configured: false, writable: true };
332
337
  });
333
338
  }
334
339
 
335
- return function JiraSettingsCard() {
340
+ // plugins.item 的两个渲染位:列表里的卡片摘要(view="summary")与点开后的
341
+ // 详情面板(view="page")。分发组件自身不调用任何 hook,钩子全部留在
342
+ // JiraSettingsPage 里,这样同一个实例的 hook 数量与 view 取值无关——
343
+ // 这是 React 的硬性要求,早期版本把摘要提前 return 写在 hook 之前会踩这条规则。
344
+ function JiraSettingsPage() {
336
345
  var snapshot = useScopeSnapshot(scope);
337
346
  var tokenInfoState = React.useState(null);
338
347
  var tokenInfo = tokenInfoState[0], setTokenInfo = tokenInfoState[1];
@@ -362,31 +371,37 @@ window.__ModuleLoader__.load({
362
371
 
363
372
  var storedBase = snapshot.value && typeof snapshot.value.baseUrl === "string" ? snapshot.value.baseUrl : "";
364
373
  var baseValue = baseEdit === undefined ? storedBase : baseEdit;
365
- var dirty = (baseEdit !== undefined && baseEdit !== storedBase) || (tokenDraft || "").length > 0;
374
+ var baseDirty = baseEdit !== undefined && baseEdit !== storedBase;
375
+ var tokenDraftText = (tokenDraft || "").trim();
376
+ var tokenDirty = tokenDraftText.length > 0;
377
+ var dirty = baseDirty || tokenDirty;
366
378
  var writable = snapshot.writable !== false;
367
- // 令牌可能来自启动环境(只读):此时禁用输入并说明原因,而不是等到保存才报 DSH 的英文错误。
379
+ // 插件自有引用通常可写;只有 JIRA_TASKS_TOKEN 本身也被导出到启动环境时才会只读。
368
380
  var tokenWritable = !tokenInfo || tokenInfo.writable !== false;
369
- var tokenReadOnly = !!(tokenInfo && tokenInfo.configured && tokenInfo.writable === false);
381
+ var tokenOverridden = !!(tokenInfo && tokenInfo.overridden);
382
+ var envTokenRef = tokenInfo && tokenInfo.envRef ? tokenInfo.envRef : "";
370
383
 
371
384
  // 命名空间未挂载时不显示,避免留下一张无法操作的卡片。
372
385
  if (snapshot.status === "unavailable") return null;
373
386
 
374
387
  function save() {
375
388
  if (busy || !dirty) return;
389
+ if (tokenDirty && !tokenWritable) {
390
+ setFailed(tokenReadOnlyHint(TOKEN_REF));
391
+ return;
392
+ }
376
393
  setBusy(true);
377
394
  setFailed("");
378
395
  var tasks = [];
379
- if (baseEdit !== undefined && baseEdit !== storedBase) {
380
- tasks.push(baseEdit === "" ? scope.unset("baseUrl") : scope.set("baseUrl", baseEdit));
381
- }
382
- var token = (tokenDraft || "").trim();
383
- if (token && !tokenWritable) {
384
- setBusy(false);
385
- setFailed(tokenReadOnlyHint(tokenInfo && tokenInfo.ref));
386
- return;
396
+ if (baseDirty) {
397
+ // ConfigForm.set/unset 解析为 boolean:false = 宿主拒绝写入或连接处于
398
+ // memory 模式。以前忽略返回值会让面板“看起来保存了”,实际没落盘。
399
+ tasks.push((baseEdit === "" ? scope.unset("baseUrl") : scope.set("baseUrl", baseEdit)).then(function (accepted) {
400
+ if (accepted === false) throw new Error("JIRA 地址保存被宿主拒绝(该命名空间只读,或连接处于 memory 模式)");
401
+ }));
387
402
  }
388
- if (token) {
389
- tasks.push(credentials.set(TOKEN_REF, token).then(function (res) {
403
+ if (tokenDirty) {
404
+ tasks.push(credentials.set(TOKEN_REF, tokenDraftText).then(function (res) {
390
405
  if (res && res.ok === false) {
391
406
  var err = res.error;
392
407
  throw new Error((err && (err.message || err.code)) || "令牌保存失败");
@@ -400,6 +415,31 @@ window.__ModuleLoader__.load({
400
415
  setBaseEdit(undefined);
401
416
  setTokenDraft("");
402
417
  setBusy(false);
418
+ // baseUrl 变更会经 snapshot.revision 触发自动重测;仅保存令牌时手动补一次。
419
+ if (!baseDirty) runProbe({});
420
+ }).catch(function (err) {
421
+ setBusy(false);
422
+ var shadowed = shadowedTokenRef(err && err.message);
423
+ setFailed(shadowed ? tokenReadOnlyHint(shadowed) : String((err && err.message) || err));
424
+ });
425
+ }
426
+
427
+ // 清除设置页保存的令牌,回退到环境变量 / 凭据文件中的旧值。
428
+ function clearOverride() {
429
+ if (busy) return;
430
+ setBusy(true);
431
+ setFailed("");
432
+ credentials.unset(TOKEN_REF).then(function (res) {
433
+ if (res && res.ok === false) {
434
+ var err = res.error;
435
+ throw new Error((err && (err.message || err.code)) || "清除令牌失败");
436
+ }
437
+ return readTokenInfo();
438
+ }).then(function (info) {
439
+ setTokenInfo(info);
440
+ setTokenDraft("");
441
+ setBusy(false);
442
+ runProbe({});
403
443
  }).catch(function (err) {
404
444
  setBusy(false);
405
445
  var shadowed = shadowedTokenRef(err && err.message);
@@ -430,9 +470,11 @@ window.__ModuleLoader__.load({
430
470
 
431
471
  var tokenHint = tokenInfo === null
432
472
  ? "正在读取令牌状态…"
433
- : (tokenReadOnly
434
- ? tokenReadOnlyHint(tokenInfo.ref)
435
- : (tokenInfo.configured ? "令牌已配置;留空并保存可保持不变。" : "令牌未配置。含 “:” 时用 Basic,否则用 Bearer。"));
473
+ : (tokenOverridden
474
+ ? "设置页令牌已保存" + (envTokenRef ? ",优先于环境变量 " + envTokenRef : "") + ";留空并保存可保持不变。"
475
+ : (envTokenRef
476
+ ? "当前使用环境变量 " + envTokenRef + ";在下方填写并保存即可覆盖它。"
477
+ : (tokenInfo.configured ? "令牌已配置;留空并保存可保持不变。" : "令牌未配置。含 “:” 时用 Basic,否则用 Bearer。")));
436
478
  var dotClass = probe.phase === "ok" ? "jt-dot jt-dot-ok"
437
479
  : probe.phase === "fail" ? "jt-dot jt-dot-fail"
438
480
  : probe.phase === "testing" ? "jt-dot jt-dot-testing" : "jt-dot";
@@ -461,14 +503,18 @@ window.__ModuleLoader__.load({
461
503
  className: "jt-input",
462
504
  type: "password",
463
505
  value: tokenDraft,
464
- placeholder: tokenReadOnly ? "由环境变量提供(只读)" : (tokenInfo && tokenInfo.configured ? "已配置,留空保持不变" : "粘贴令牌"),
506
+ placeholder: tokenOverridden ? "已保存,留空保持不变" : (envTokenRef ? "填写以覆盖环境变量" : (tokenInfo && tokenInfo.configured ? "已配置,留空保持不变" : "粘贴令牌")),
465
507
  autoComplete: "off",
466
- disabled: !writable || busy || !tokenWritable,
508
+ disabled: busy || !tokenWritable,
467
509
  onChange: function (e) { setTokenDraft(e.target.value); },
468
510
  onKeyDown: function (e) { if (e.key === "Enter") save(); }
469
511
  })
470
512
  ),
471
513
  h("div", { className: "jt-hint", style: { marginBottom: "8px" } }, tokenHint),
514
+ tokenOverridden && tokenWritable
515
+ ? h("div", { className: "jt-hint", style: { margin: "-4px 0 8px" } },
516
+ h("button", { className: "jt-linklike", disabled: busy, onClick: clearOverride }, "清除设置页令牌(回退到环境变量)"))
517
+ : null,
472
518
  h("div", { className: "jt-probe", role: "status" },
473
519
  h("span", { className: dotClass, "aria-hidden": "true" }),
474
520
  h("span", null, probe.message || "未测试")
@@ -483,50 +529,61 @@ window.__ModuleLoader__.load({
483
529
  onClick: function () { runProbe({ baseUrl: baseValue, token: (tokenDraft || "").trim() }); }
484
530
  }, probe.phase === "testing" ? "测试中…" : "测试连接"),
485
531
  h("button", { className: "jt-btn", disabled: !dirty || busy, onClick: discard }, "放弃"),
486
- h("button", { className: "jt-btn jt-btn-primary", disabled: !dirty || busy || !writable || ((tokenDraft || "").trim().length > 0 && !tokenWritable), onClick: save }, busy ? "保存中…" : "保存")
532
+ h("button", { className: "jt-btn jt-btn-primary", disabled: !dirty || busy || (baseDirty && !writable) || (tokenDirty && !tokenWritable), onClick: save }, busy ? "保存中…" : "保存")
487
533
  )
488
534
  );
535
+ }
536
+
537
+ // 槽位组件:摘要位只回一行文字,详情位挂载真正的表单。
538
+ return function JiraSettingsCard(props) {
539
+ if (props && props.view === "summary") return "JIRA 地址、令牌与连接状态";
540
+ return h(JiraSettingsPage, null);
489
541
  };
490
542
  }
491
543
 
492
544
  function apply(ctx) {
493
545
  // ctx.effect(cb) 会立即执行 cb 并把其返回值作为卸载时的清理函数,
494
546
  // 所以样式注入必须放在回调内、返回移除函数,否则标签刚插入就被删除。
495
- if (ctx && typeof ctx.effect === "function") {
496
- ctx.effect(function () {
497
- var styleTag = document.createElement("style");
498
- styleTag.textContent = CSS;
499
- document.head.appendChild(styleTag);
500
- return function () { try { styleTag.remove(); } catch (e) {} };
501
- });
502
- } else {
547
+ ctx.effect(function () {
503
548
  var styleTag = document.createElement("style");
504
549
  styleTag.textContent = CSS;
505
550
  document.head.appendChild(styleTag);
506
- }
551
+ return function () { try { styleTag.remove(); } catch (e) {} };
552
+ }, "jira-tasks: styles");
507
553
  var slots = ctx.get("slots");
508
554
  if (!slots) return;
509
- slots.inject("conversation.composer.dock", function () {
510
- return slots.register({ name: "conversation.composer.dock", id: "jira-open-tasks", order: 5 }, JiraTasksDock);
511
- });
555
+ // 只注册 conversation.input.dock 一处。
556
+ //
557
+ // 为什么不用 conversation.composer.dock:该槽位在 DSH 0.1.7 里渲染进
558
+ // InputBar 的 .dock 行——一个 justify-content:center 的**横向** flex 行,
559
+ // 与右侧的上下文占用环(ContextMeter)并排。整宽任务面板放进那一行,会和
560
+ // 占用环抢同一条 flex line,面板右侧内容被环压住。
561
+ //
562
+ // conversation.input.dock 属于 composerStack(flex-direction:column),
563
+ // 在新会话与活跃会话下都会渲染(只要有 session + input),是整宽纵向行;
564
+ // 面板自身用 CSS order:99 排到输入卡之后,即“输入框下方”。
512
565
  slots.inject("conversation.input.dock", function () {
513
- return slots.register({ name: "conversation.input.dock", id: "jira-open-tasks-hero", order: 10 }, function (props) {
514
- return h(JiraTasksDock, Object.assign({}, props, { blankOnly: true }));
515
- });
566
+ return slots.register({ name: "conversation.input.dock", id: "jira-open-tasks", order: 10 }, JiraTasksDock);
516
567
  });
517
568
 
518
- // 设置页卡片:宿主注册了 jira-tasks 命名空间时,由插件配置 Tab 分发到本卡片。
519
- var settingsScope = ctx.get("settingsScope");
569
+ // 设置页卡片。DSH 0.1.5+ :设置表单的服务是 configForms,命名空间就是本插件
570
+ // 的 profile 条目 id(jira-tasks);卡片注册到“插件”页的 plugins.item 槽,
571
+ // 并且只在宿主确实提供了该命名空间时才出现(whileServed 的 disposer 交回 effect)。
572
+ var configForms = ctx.get("configForms");
520
573
  var remote = ctx.get("remote");
521
- if (settingsScope && remote && remote.credentials) {
522
- var card = createJiraSettingsCard(settingsScope.bind({ namespace: SETTINGS_NS }), remote.credentials);
523
- slots.inject("settings.plugin.item", function () {
524
- return slots.register({ name: "settings.plugin.item", key: SETTINGS_NS }, card);
525
- });
574
+ if (configForms && remote && remote.credentials) {
575
+ var card = createJiraSettingsCard(configForms.get(SETTINGS_NS), remote.credentials);
576
+ ctx.effect(function () {
577
+ return configForms.whileServed([SETTINGS_NS], function () {
578
+ return slots.inject("plugins.item", function () {
579
+ return slots.register({ name: "plugins.item", id: SETTINGS_NS, order: 41, label: "JIRA" }, card);
580
+ });
581
+ });
582
+ }, "jira-tasks: plugins page card");
526
583
  }
527
584
  }
528
585
 
529
- exports.inject = ["slots", "settingsScope", "remote", "remote.credentials"];
586
+ exports.inject = ["slots", "configForms", "remote", "remote.credentials"];
530
587
  exports.apply = apply;
531
588
  return module.exports;
532
589
  }
package/lib/index.js CHANGED
@@ -1,10 +1,11 @@
1
1
  /**
2
2
  * dsh-jira-tasks — HOST half (persistent profile plugin).
3
- * Serves POST /jira/api/search: reads the JIRA base URL from the `jira-tasks`
4
- * settings namespace and the token from the credentials service (falling back
5
- * to JIRA_BASE_URL / JIRA_API_TOKEN for existing setups), queries JIRA
6
- * /rest/api/2/search through subprocess+curl (auth header via stdin
7
- * --config -), returns normalized issue JSON.
3
+ * Serves POST /jira/api/search: reads the JIRA base URL from this entry's own
4
+ * Config (the `jira-tasks` profile entry, editable on the Plugins settings
5
+ * page) and the token from the credentials service — the settings-page
6
+ * JIRA_TASKS_TOKEN first, so it overrides an exported JIRA_API_TOKEN /
7
+ * JIRA_TOKEN — then queries JIRA /rest/api/2/search through subprocess+curl
8
+ * (auth header via stdin --config -), returning normalized issue JSON.
8
9
  */
9
10
  import z from '@deepseek-ai/schemastery';
10
11
 
@@ -12,17 +13,59 @@ import z from '@deepseek-ai/schemastery';
12
13
  const SETTINGS_NS = 'jira-tasks';
13
14
 
14
15
  /**
15
- * Durable connection schema. `baseUrl` is a plain readable field; the token is
16
- * deliberately NOT stored here — it stays in the credentials store (write-only
17
- * from the client) so no secret ever lands in settings.yaml.
16
+ * Diagnostics name for this plugin instance. Deliberately the same string as
17
+ * {@link SETTINGS_NS} (= the profile entry id, `jira-tasks`): that id — not this
18
+ * name and not the package name `dsh-jira-tasks` — is the settings namespace the
19
+ * client card edits, so deriving one from the other removes a whole class of
20
+ * confusion when reading loader logs.
18
21
  */
19
- const JiraSettingsSchema = z.object({
20
- baseUrl: z.string().default('').description('JIRA 地址,例如 http://jira.example.com/')
22
+ const name = SETTINGS_NS;
23
+
24
+ /**
25
+ * Token references in precedence order. `JIRA_TASKS_TOKEN` is the plugin-owned
26
+ * ref the settings card writes to: unlike `JIRA_API_TOKEN`, the credentials
27
+ * provider can always store it, because the launching environment does not
28
+ * supply it. Listing it first is what makes a saved token override an exported
29
+ * `JIRA_API_TOKEN` / `JIRA_TOKEN`.
30
+ */
31
+ const TOKEN_REFS = ['JIRA_TASKS_TOKEN', 'JIRA_API_TOKEN', 'JIRA_TOKEN'];
32
+
33
+ /**
34
+ * Durable connection schema. Since DSH 0.1.5 the settings service no longer
35
+ * takes a `register(ns, schema)` call: a plugin's OWN Config IS the form its
36
+ * settings page is built from, keyed by the profile entry id (`jira-tasks`).
37
+ * `baseUrl` is volatile, so a saved change reaches the running plugin without
38
+ * remounting it. The token is deliberately NOT here — it stays in the
39
+ * credentials store (write-only from the client), so no secret ever lands in
40
+ * the profile configuration document.
41
+ */
42
+ const Config = z.object({
43
+ baseUrl: z.string().default('').volatile().description('JIRA 地址,例如 http://jira.example.com/')
21
44
  });
22
45
 
23
- export default {
24
- inject: ['subprocess', 'credentials', 'sandboxPolicy', 'webServer'],
25
- apply(ctx) {
46
+ const inject = ['subprocess', 'credentials', 'sandboxPolicy', 'webServer'];
47
+
48
+ /**
49
+ * Answer a non-POST request with 405. `webServer.register` dispatches on the
50
+ * pathname only, so without this guard a stray GET would run a real JIRA
51
+ * query. Returns true when the caller may handle the request.
52
+ * @param req - the HTTP request.
53
+ * @param res - the response the guard writes to on refusal.
54
+ * @returns true for POST, false after answering 405.
55
+ */
56
+ function methodIsPost(req, res) {
57
+ if (req.method === 'POST') return true;
58
+ res.writeHead(405, { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-store', Allow: 'POST' });
59
+ res.end('method not allowed');
60
+ return false;
61
+ }
62
+
63
+ /**
64
+ * Mount the host half: the /jira/api/* routes and the settings-page policy.
65
+ * @param ctx - plugin context.
66
+ * @param config - this entry's live Config (profile entry `jira-tasks`).
67
+ */
68
+ function apply(ctx, config) {
26
69
  const sendJson = (res, obj) => {
27
70
  res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
28
71
  res.end(JSON.stringify(obj));
@@ -55,19 +98,32 @@ export default {
55
98
  return undefined;
56
99
  }
57
100
 
58
- // Register the GUI-editable section when the optional settings service is
59
- // composed; without it the plugin keeps working from the composition/env.
101
+ // DSH 0.1.5+ :设置页的表单直接来自本条目自己的 Config,settings 服务不再有
102
+ // register(ns, schema)。这里把本实例的“自动生成页”关掉,改由客户端注册的
103
+ // 自定义卡片(地址 + 令牌 + 连通性测试)接管。
104
+ // 两个易错点:1) configure 的第二个参数必须是**本插件实例**的 fiber——默认值是
105
+ // settings 服务自己的 fiber,而 describe() 是按 entry.fiber 查策略的,传错不生效;
106
+ // 2) 返回值是注销函数,必须交回 effect,否则卸载后策略会一直挂在死 fiber 上。
60
107
  ctx.inject(['settings'], (settingsCtx) => {
61
- settingsCtx.settings.register(SETTINGS_NS, JiraSettingsSchema);
108
+ settingsCtx.effect(() => {
109
+ try {
110
+ return settingsCtx.settings.configure({ auto: false }, ctx.fiber);
111
+ } catch (e) {
112
+ // 同一实例重复配置等情况下不阻断插件加载,但要留下可诊断的日志。
113
+ if (ctx.logger && typeof ctx.logger.warn === 'function') {
114
+ ctx.logger.warn('jira-tasks: settings page policy not applied: %s', (e && e.message) || e);
115
+ }
116
+ return undefined;
117
+ }
118
+ }, 'jira-tasks: settings page policy');
62
119
  });
63
120
 
64
- /** Base URL from the settings document, or '' when unset/unavailable. */
121
+ /** 本条目 Config 里的 JIRA 地址(volatile 引用),未配置时返回 ''。 */
65
122
  function settingsBaseUrl() {
66
123
  try {
67
- const settings = ctx.get('settings');
68
- if (!settings) return '';
69
- const section = settings.get(SETTINGS_NS);
70
- return section && typeof section.baseUrl === 'string' ? section.baseUrl.trim() : '';
124
+ const ref = config && config.baseUrl;
125
+ const value = ref && typeof ref.get === 'function' ? ref.get() : ref;
126
+ return typeof value === 'string' ? value.trim() : '';
71
127
  } catch (e) {
72
128
  return '';
73
129
  }
@@ -166,7 +222,7 @@ export default {
166
222
  try {
167
223
  const baseUrl = settingsBaseUrl() || await resolveFirst(['JIRA_BASE_URL', 'JIRA_URL']);
168
224
  if (!baseUrl) return { ok: false, error: '未配置 JIRA 地址(设置 → 插件 → JIRA,或环境变量 JIRA_BASE_URL)' };
169
- const token = await resolveFirst(['JIRA_API_TOKEN', 'JIRA_TOKEN']);
225
+ const token = await resolveFirst(TOKEN_REFS);
170
226
  if (!token) return { ok: false, error: '未配置 JIRA 令牌(设置 → 插件 → JIRA,或环境变量 JIRA_API_TOKEN)' };
171
227
 
172
228
  const jql = buildJql(args && args.jql ? String(args.jql) : '', projectKey);
@@ -213,7 +269,7 @@ export default {
213
269
  const draftToken = args && typeof args.token === 'string' ? args.token.trim() : '';
214
270
  const baseUrl = draftBase || settingsBaseUrl() || await resolveFirst(['JIRA_BASE_URL', 'JIRA_URL']);
215
271
  if (!baseUrl) return { ok: false, code: 'unconfigured', error: '未配置 JIRA 地址' };
216
- const token = draftToken || await resolveFirst(['JIRA_API_TOKEN', 'JIRA_TOKEN']);
272
+ const token = draftToken || await resolveFirst(TOKEN_REFS);
217
273
  if (!token) return { ok: false, code: 'unconfigured', error: '未配置 JIRA 令牌' };
218
274
 
219
275
  const raw = await probeJira(baseUrl, token);
@@ -244,28 +300,33 @@ export default {
244
300
  }
245
301
 
246
302
  if (webServer && typeof webServer.register === 'function') {
247
- webServer.register({
303
+ // 每条路由都必须挂在自己的 effect 上:register 返回注销函数,卸载/重载时先释放,
304
+ // 否则重挂载会因为“同一 (kind, path) 只允许一条路由”直接抛错,整个插件激活失败。
305
+ ctx.effect(() => webServer.register({
248
306
  kind: 'exact',
249
307
  path: '/jira/api/search',
250
308
  handler: async (req, res) => {
309
+ if (!methodIsPost(req, res)) return;
251
310
  try {
252
311
  sendJson(res, await handleSearch(await readBody(req)));
253
312
  } catch (e) {
254
313
  sendJson(res, { ok: false, error: String((e && e.message) || e) });
255
314
  }
256
315
  }
257
- });
258
- webServer.register({
316
+ }), 'jira-tasks: /jira/api/search');
317
+ ctx.effect(() => webServer.register({
259
318
  kind: 'exact',
260
319
  path: '/jira/api/test',
261
320
  handler: async (req, res) => {
321
+ if (!methodIsPost(req, res)) return;
262
322
  try {
263
323
  sendJson(res, await handleTest(await readBody(req)));
264
324
  } catch (e) {
265
325
  sendJson(res, { ok: false, code: 'error', error: String((e && e.message) || e) });
266
326
  }
267
327
  }
268
- });
328
+ }), 'jira-tasks: /jira/api/test');
269
329
  }
270
- }
271
- };
330
+ }
331
+
332
+ export { Config, apply, inject, name };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-jira-tasks",
3
- "version": "1.0.7",
4
- "description": "JIRA open tasks panel for DeepSeek Harness (DSH): shows the current user's open/reopened issues below the composer, per-workspace project key, persistent profile bundle.",
3
+ "version": "1.1.0",
4
+ "description": "JIRA open tasks panel for DeepSeek Harness (DSH) 0.1.7: shows the current user's open/reopened issues below the composer, per-workspace project key, persistent profile bundle.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "exports": {
@@ -40,7 +40,10 @@
40
40
  "platform": "web",
41
41
  "inject": [
42
42
  "@deepseek-ai/dsh-client-ui-conversation",
43
+ "@deepseek-ai/dsh-client-ui-session",
44
+ "@deepseek-ai/dsh-client-ui-workspace",
43
45
  "@deepseek-ai/dsh-client-ui-settings",
46
+ "@deepseek-ai/dsh-client-ui-plugin-manager",
44
47
  "@deepseek-ai/dsh-api-remotes"
45
48
  ]
46
49
  }