@trim21/personal-pi-extensions 0.1.467 → 0.1.470

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
@@ -365,10 +365,11 @@ read/edit/write 工具内置 LSP 诊断(写文件后等待并报告 ERROR 级
365
365
  "bin": "gopls",
366
366
  "args": [],
367
367
  "cwd": "{root}", // 支持 {root} / {cwd} 模板
368
+ "env": { "VIRTUAL_ENV": "{root}/.venv", "GITHUB_TOKEN": { "sh": ["gh", "auth", "token"] } }, // 追加到子进程环境变量;string 值支持 {root} / {cwd} 模板与 ${VAR} 引用,{sh} 启动时执行命令取 stdout(失败则服务器启动失败)
368
369
  "languageIdByExtension": { ".go": "go" },
369
370
  "startupTimeoutMs": 45000,
370
371
  "diagnosticsWaitMs": 1500,
371
- "initializationOptions": {}, // → initialize 请求
372
+ "initializationOptions": { "pythonPath": "${VIRTUAL_ENV:-/opt/venv}/bin/python" }, // → initialize 请求;字符串值支持 ${VAR} / ${VAR:-default} 插值
372
373
  "settings": {}, // → didChangeConfiguration / workspace/configuration 请求
373
374
  },
374
375
  },
@@ -389,7 +390,31 @@ read/edit/write 工具内置 LSP 诊断(写文件后等待并报告 ERROR 级
389
390
  - `bin`:可执行文件——绝对路径、相对调用 cwd 的路径,或名字(先在项目内 `node_modules/.bin`、`.venv/bin`、`venv/bin` 找,再走 PATH)
390
391
  - `languageIdByExtension`:扩展名 → LSP languageId(didOpen 用);缺省回退内置映射表
391
392
  - `startupTimeoutMs` / `diagnosticsWaitMs`:per-server 超时,覆盖全局配置与默认值
392
- - `initializationOptions` 与 `settings` 按 LSP 语义分离:前者进 initialize 请求,后者进 didChangeConfiguration / workspace/configuration 请求
393
+ - `env`:追加到 LSP 子进程的环境变量(在 `process.env` 之上合并)。string 值支持 `{root}` / `{cwd}` 模板与 `${VAR}` 环境变量引用;`{ "sh": [...] }` 在服务器启动时执行命令(argv 直接执行、不经 shell,需要 shell 特性时自行包 `["bash", "-c", "..."]`),stdout trim 后作为值,命令失败(非零退出或输出为空)时该服务器启动失败并报错
394
+ - `initializationOptions` 与 `settings` 按 LSP 语义分离:前者进 initialize 请求,后者进 didChangeConfiguration / workspace/configuration 请求;`initializationOptions` 的字符串值(含嵌套对象/数组)在启动时做 `${VAR}` 插值,`${VAR:-default}` 在变量未定义或为空时用 default,未定义且无 default 替换为空字符串;插值时可引用 `env` 里配置的变量
395
+
396
+ `env` 的 `{sh}` 命令与 `initializationOptions` 插值可以组合使用,例如用 `gh auth token` 给服务器的 initialize 请求提供 session token:
397
+
398
+ ```jsonc
399
+ {
400
+ "servers": {
401
+ "github-lsp": {
402
+ "bin": "github-lsp",
403
+ "args": ["--stdio"],
404
+ "env": {
405
+ // 启动时执行 gh auth token,stdout(trim 后)成为环境变量 GITHUB_TOKEN
406
+ "GITHUB_TOKEN": { "sh": ["gh", "auth", "token"] },
407
+ },
408
+ "initializationOptions": {
409
+ // 插值引用上面命令的输出,随 initialize 请求发给服务器
410
+ "sessionToken": "${GITHUB_TOKEN}",
411
+ },
412
+ },
413
+ },
414
+ }
415
+ ```
416
+
417
+ 命令失败(`gh` 未登录 / 不在 PATH)时该服务器启动失败并报错。
393
418
 
394
419
  没有内置默认服务器:`servers` 的 key 就是服务器 id,全部来自你的配置,未定义 `servers` 时不启动任何语言服务器。executable 的发现逻辑(如 tsserver 路径、venv 里的 python)不内置,需要时用 `bin` / `args` / `settings` 自行表达。
395
420
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trim21/personal-pi-extensions",
3
- "version": "0.1.467",
3
+ "version": "0.1.470",
4
4
  "type": "module",
5
5
  "description": "Custom pi coding-agent extensions: bwrap sandbox, workspace guard, opencode edit, and more",
6
6
  "keywords": [
@@ -12,8 +12,10 @@
12
12
  * 特殊探测逻辑(tsserver 路径、venv python 等)。
13
13
  */
14
14
 
15
+ import { execFile as nodeExecFile } from "node:child_process";
15
16
  import { existsSync } from "node:fs";
16
17
  import { isAbsolute, join, relative, sep } from "node:path";
18
+ import { promisify } from "node:util";
17
19
 
18
20
  import { minimatch } from "minimatch";
19
21
  import { type Static, Type } from "typebox";
@@ -39,13 +41,24 @@ export const serverConfigSchema = Type.Object({
39
41
  args: Type.Optional(Type.Array(Type.String())),
40
42
  /** 启动工作目录,支持 {root} / {cwd} 模板;缺省 {root}。 */
41
43
  cwd: Type.Optional(Type.String()),
44
+ /**
45
+ * 追加到子进程的环境变量。string 值支持 {root} / {cwd} 模板与 ${VAR} 引用;
46
+ * {sh: [...]} 在启动时执行命令(argv 直接执行、不经 shell),stdout trim 后作为值,
47
+ * 失败(非零退出 / 空输出)时服务器启动失败并报错。
48
+ */
49
+ env: Type.Optional(
50
+ Type.Record(
51
+ Type.String(),
52
+ Type.Union([Type.String(), Type.Object({ sh: Type.Array(Type.String()) })]),
53
+ ),
54
+ ),
42
55
  /** 文件扩展名(含点)→ LSP languageId,didOpen 用;缺省回退内置映射表。 */
43
56
  languageIdByExtension: Type.Optional(Type.Record(Type.String(), Type.String())),
44
57
  /** initialize 握手超时(ms);缺省用全局配置 / client 默认。 */
45
58
  startupTimeoutMs: Type.Optional(Type.Number({ minimum: 1 })),
46
59
  /** 写文件后等待诊断的时长(ms);缺省用全局配置 / client 默认。 */
47
60
  diagnosticsWaitMs: Type.Optional(Type.Number({ minimum: 1 })),
48
- /** initialize 请求的 initializationOptions。 */
61
+ /** initialize 请求的 initializationOptions;字符串值支持 ${VAR} / ${VAR:-default} 插值。 */
49
62
  initializationOptions: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
50
63
  /** didChangeConfiguration / workspace/configuration 请求的 settings。 */
51
64
  settings: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
@@ -77,6 +90,74 @@ function resolveTemplate(template: string, root: string, cwd: string): string {
77
90
  return template.split("{root}").join(root).split("{cwd}").join(cwd);
78
91
  }
79
92
 
93
+ /** ${VAR} / ${VAR:-default} 插值;shell 语义:未定义或空时用 default(缺省空字符串)。 */
94
+ const envVarPattern = /\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}/g;
95
+
96
+ function interpolateEnvVars(value: string, env: NodeJS.ProcessEnv): string {
97
+ return value.replaceAll(envVarPattern, (_match, name: string, fallback?: string) => {
98
+ const resolved = env[name];
99
+ return resolved !== undefined && resolved !== "" ? resolved : (fallback ?? "");
100
+ });
101
+ }
102
+
103
+ /** 深遍历配置值,对所有字符串做环境变量插值。 */
104
+ function interpolateEnvDeep(value: unknown, env: NodeJS.ProcessEnv): unknown {
105
+ if (typeof value === "string") return interpolateEnvVars(value, env);
106
+ if (Array.isArray(value)) return value.map((item) => interpolateEnvDeep(item, env));
107
+ if (value !== null && typeof value === "object") {
108
+ return Object.fromEntries(
109
+ Object.entries(value).map(([key, item]) => [key, interpolateEnvDeep(item, env)]),
110
+ );
111
+ }
112
+ return value;
113
+ }
114
+
115
+ /** 解析 per-server env:string 值做 {root} / {cwd} 模板 + 环境变量引用插值,{sh} 执行命令取 stdout。 */
116
+ async function resolveEnv(
117
+ config: Record<string, string | { sh: string[] }> | undefined,
118
+ root: string,
119
+ cwd: string,
120
+ ): Promise<NodeJS.ProcessEnv | undefined> {
121
+ const entries = Object.entries(config ?? {});
122
+ if (entries.length === 0) return undefined;
123
+ const resolved = await Promise.all(
124
+ entries.map(async ([key, value]): Promise<[string, string]> => {
125
+ if (typeof value === "string") {
126
+ return [key, interpolateEnvVars(resolveTemplate(value, root, cwd), process.env)];
127
+ }
128
+ return [key, await runEnvCommand(value.sh, cwd)];
129
+ }),
130
+ );
131
+ return Object.fromEntries(resolved);
132
+ }
133
+
134
+ const execFile = promisify(nodeExecFile);
135
+
136
+ /**
137
+ * argv 直接执行(不经 shell,避免注入面;需要 shell 特性时配置里自行包
138
+ * ["bash", "-c", "..."]),stdout trim 后作为 env 值。
139
+ * 失败(spawn 错误 / 非零退出 / 空输出)时抛错,由 lsp.ts 捕获后 notify 给用户。
140
+ */
141
+ async function runEnvCommand(argv: string[], cwd: string): Promise<string> {
142
+ let stdout: string;
143
+ try {
144
+ ({ stdout } = await execFile(argv[0], argv.slice(1), { cwd }));
145
+ } catch (error) {
146
+ const err = error as NodeJS.ErrnoException & { stderr?: string };
147
+ const reason = typeof err.code === "number" ? `exit code ${err.code}` : err.message;
148
+ const stderr = err.stderr?.trim();
149
+ throw new Error(
150
+ `env command failed (${argv.join(" ")}): ${reason}${stderr ? `: ${stderr}` : ""}`,
151
+ { cause: error },
152
+ );
153
+ }
154
+ const output = stdout.trim();
155
+ if (!output) {
156
+ throw new Error(`env command (${argv.join(" ")}) produced empty output`);
157
+ }
158
+ return output;
159
+ }
160
+
80
161
  /** 解析可执行文件:绝对/相对路径直接用;名字走项目工作区(node_modules/.bin 等)→ PATH。 */
81
162
  async function resolveBinary(bin: string, root: string, cwd: string): Promise<string | undefined> {
82
163
  if (isAbsolute(bin)) return existsSync(bin) ? bin : undefined;
@@ -144,11 +225,17 @@ export class ConfigAdapter implements LspServerAdapter {
144
225
  if (!bin) return undefined;
145
226
  const resolved = await resolveBinary(bin, root, cwd);
146
227
  if (!resolved) return undefined;
228
+ const spawnCwd = resolveTemplate(this.config.cwd ?? "{root}", root, cwd);
229
+ const env = await resolveEnv(this.config.env, root, cwd);
147
230
  return {
148
231
  process: spawnProcess(resolved, this.config.args ?? [], {
149
- cwd: resolveTemplate(this.config.cwd ?? "{root}", root, cwd),
232
+ cwd: spawnCwd,
233
+ env,
150
234
  }),
151
- initialization: this.config.initializationOptions,
235
+ initialization: interpolateEnvDeep(this.config.initializationOptions, {
236
+ ...process.env,
237
+ ...env,
238
+ }) as Record<string, unknown> | undefined,
152
239
  settings: this.config.settings,
153
240
  languageIds: this.config.languageIdByExtension,
154
241
  };