@dsh-workbench/shared 1.1.3 → 1.1.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.
Files changed (68) hide show
  1. package/dist/api/host-write.d.ts +4 -1
  2. package/dist/api/workbench-client.d.ts +2 -0
  3. package/dist/components/ApprovalPlan.css +7 -8
  4. package/dist/config/constants.d.ts +4 -0
  5. package/dist/config/constants.js +4 -0
  6. package/dist/contract/approval.d.ts +4 -0
  7. package/dist/contract/approval.js +4 -0
  8. package/dist/contract/command.d.ts +33 -0
  9. package/dist/contract/command.js +1 -1
  10. package/dist/contract/needs.d.ts +17 -0
  11. package/dist/contract/needs.js +41 -1
  12. package/dist/contract/protocol/bundle-install.d.ts +4 -0
  13. package/dist/contract/protocol/market-index.d.ts +14 -0
  14. package/dist/contract/protocol/market-index.js +1 -0
  15. package/dist/contract/unit.d.ts +10 -0
  16. package/dist/host/adapter-types.d.ts +2 -0
  17. package/dist/host/data-error.d.ts +2 -0
  18. package/dist/host/data-error.js +3 -0
  19. package/dist/host/http.js +1 -0
  20. package/dist/host/install-failure.d.ts +49 -0
  21. package/dist/host/install-failure.js +167 -0
  22. package/dist/host/installed-sources.d.ts +10 -0
  23. package/dist/host/installed-sources.js +64 -0
  24. package/dist/host/lib-install.d.ts +12 -1
  25. package/dist/host/lib-install.js +33 -1
  26. package/dist/host/paths.d.ts +7 -0
  27. package/dist/host/paths.js +17 -0
  28. package/dist/host/runtime-links.d.ts +7 -0
  29. package/dist/host/runtime-links.js +25 -0
  30. package/dist/host/tool-approval.d.ts +4 -2
  31. package/dist/host/tool-approval.js +4 -2
  32. package/dist/host.d.ts +3 -0
  33. package/dist/host.js +3 -0
  34. package/dist/i18n/en.js +46 -1
  35. package/dist/i18n/index.d.ts +47 -2
  36. package/dist/i18n/zh.d.ts +46 -1
  37. package/dist/i18n/zh.js +46 -1
  38. package/dist/index.d.ts +1 -0
  39. package/dist/index.js +1 -0
  40. package/package.json +1 -1
  41. package/src/api/host-write.ts +5 -1
  42. package/src/api/workbench-client.ts +2 -0
  43. package/src/components/ApprovalPlan.css +7 -8
  44. package/src/config/constants.ts +6 -0
  45. package/src/contract/__tests__/needs.test.ts +53 -0
  46. package/src/contract/approval.ts +4 -0
  47. package/src/contract/command.ts +22 -0
  48. package/src/contract/needs.ts +56 -1
  49. package/src/contract/protocol/bundle-install.ts +5 -0
  50. package/src/contract/protocol/market-index.ts +17 -0
  51. package/src/contract/unit.ts +11 -0
  52. package/src/host/adapter-types.ts +2 -0
  53. package/src/host/data-error.ts +3 -1
  54. package/src/host/http.ts +1 -0
  55. package/src/host/install-failure.test.ts +121 -0
  56. package/src/host/install-failure.ts +198 -0
  57. package/src/host/installed-sources.test.ts +80 -0
  58. package/src/host/installed-sources.ts +73 -0
  59. package/src/host/lib-install.ts +38 -2
  60. package/src/host/paths.test.ts +26 -0
  61. package/src/host/paths.ts +18 -0
  62. package/src/host/runtime-links.test.ts +31 -0
  63. package/src/host/runtime-links.ts +23 -0
  64. package/src/host/tool-approval.ts +11 -4
  65. package/src/host.ts +3 -0
  66. package/src/i18n/en.ts +47 -1
  67. package/src/i18n/zh.ts +47 -1
  68. package/src/index.ts +1 -0
@@ -0,0 +1,121 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ classifyInstallFailure,
4
+ installFailureOf,
5
+ installTargets,
6
+ installWriteProblem,
7
+ } from './install-failure.js';
8
+
9
+ const CURRENT = { uid: 502, gid: 20 };
10
+
11
+ describe('installTargets(从我们自己的安装命令里挑出写点)', () => {
12
+ it('挑出 --prefix 与原样带空格的路径', () => {
13
+ expect(installTargets(`npm i --prefix '/Users/a b/x' foo@1 --cache /tmp/c`)).toEqual([
14
+ '/Users/a b/x',
15
+ '/tmp/c',
16
+ ]);
17
+ });
18
+
19
+ it('没有 --prefix / --cache 就没有写点', () => {
20
+ expect(installTargets('npm ls --json --depth 0')).toEqual([]);
21
+ });
22
+ });
23
+
24
+ describe('classifyInstallFailure', () => {
25
+ it('EPERM / EACCES 都算权限', () => {
26
+ expect(classifyInstallFailure('npm error code EACCES')).toBe('permission');
27
+ expect(classifyInstallFailure('npm error syscall open errno EPERM')).toBe('permission');
28
+ });
29
+
30
+ it('ENOSPC 是盘满', () => {
31
+ expect(classifyInstallFailure('ENOSPC: no space left on device')).toBe('disk-full');
32
+ });
33
+
34
+ it('认不出来就说认不出来', () => {
35
+ expect(classifyInstallFailure('')).toBe('unknown');
36
+ });
37
+ });
38
+
39
+ describe('installFailureOf(从现场推到修法)', () => {
40
+ it('属主不是当前账号 → chown 回自己', () => {
41
+ const failure = installFailureOf({
42
+ command: 'npm i --prefix /x foo@1',
43
+ exitCode: 1,
44
+ output: 'EACCES: permission denied',
45
+ path: '/x',
46
+ owner: { uid: 0, gid: 0 },
47
+ mode: '755',
48
+ writable: false,
49
+ current: CURRENT,
50
+ });
51
+
52
+ expect(failure.kind).toBe('permission');
53
+ expect(failure.fix).toEqual({ kind: 'chown', path: '/x', uid: 502, gid: 20 });
54
+ });
55
+
56
+ it('属主是自己、只是没写位 → chmod,不给 sudo', () => {
57
+ const failure = installFailureOf({
58
+ command: 'npm i --prefix /x foo@1',
59
+ exitCode: 1,
60
+ output: 'EACCES: permission denied',
61
+ path: '/x',
62
+ owner: { uid: 502, gid: 20 },
63
+ mode: '555',
64
+ writable: false,
65
+ current: CURRENT,
66
+ });
67
+
68
+ expect(failure.fix).toEqual({ kind: 'chmod', path: '/x' });
69
+ });
70
+
71
+ it('属主是自己、明明可写却报 EPERM → 是被别的沙箱拦的,不给一条没用的 sudo', () => {
72
+ const failure = installFailureOf({
73
+ command: 'npm i --prefix /x foo@1',
74
+ exitCode: 1,
75
+ output: 'EPERM: operation not permitted',
76
+ path: '/x',
77
+ owner: { uid: 502, gid: 20 },
78
+ mode: '755',
79
+ writable: true,
80
+ current: CURRENT,
81
+ });
82
+
83
+ expect(failure.kind).toBe('sandbox');
84
+ expect(failure.fix).toEqual({ kind: 'handoff' });
85
+ });
86
+
87
+ it('盘满 → 指向精简的命令,而不是 chown', () => {
88
+ const failure = installFailureOf({
89
+ command: 'npm i --prefix /x foo@1',
90
+ exitCode: 1,
91
+ output: 'ENOSPC: no space left on device',
92
+ path: '/x',
93
+ current: CURRENT,
94
+ });
95
+
96
+ expect(failure.kind).toBe('disk-full');
97
+ expect(failure.fix).toEqual({ kind: 'space', path: '/x' });
98
+ });
99
+
100
+ it('认不出来 → 交接给 agent,不硬猜修法', () => {
101
+ const failure = installFailureOf({
102
+ command: 'npm i --prefix /x foo@1',
103
+ exitCode: 255,
104
+ output: 'something odd',
105
+ current: CURRENT,
106
+ });
107
+
108
+ expect(failure.kind).toBe('unknown');
109
+ expect(failure.fix).toEqual({ kind: 'handoff' });
110
+ });
111
+ });
112
+
113
+ describe('installWriteProblem(装之前先看一眼,省得白跑)', () => {
114
+ it('只盯安装动词:npm ls 这种读命令不拦', () => {
115
+ expect(installWriteProblem({ command: 'npm ls --prefix /x foo --json' })).toBeUndefined();
116
+ });
117
+
118
+ it('目录不存在 → 退到最近的祖先,不报假问题', () => {
119
+ expect(installWriteProblem({ command: `npm i --prefix ${process.cwd()}/definitely-missing-dir foo` })).toBeUndefined();
120
+ });
121
+ });
@@ -0,0 +1,198 @@
1
+ import { accessSync, constants, statSync } from 'node:fs';
2
+ import { dirname } from 'node:path';
3
+ import type { InstallFailure, InstallFailureKind } from '../contract/command.js';
4
+
5
+ const TARGET_FLAGS = ['--prefix', '--cache', '--dir', '--store-dir'];
6
+
7
+ const INSTALL_VERBS = /^\s*npm\s+(i|install|add)\b/;
8
+
9
+ const NPM_PATH = /^npm (?:error|ERR!) path (.+)$/m;
10
+
11
+ export interface DirFacts {
12
+ readonly mode: string;
13
+ readonly owner: { readonly uid: number; readonly gid: number };
14
+ readonly writable: boolean;
15
+ }
16
+
17
+ export function commandTokens(command: string): string[] {
18
+ const tokens: string[] = [];
19
+ const token = /'([^']*)'|"([^"]*)"|(\S+)/g;
20
+ for (let match = token.exec(command); match !== null; match = token.exec(command)) {
21
+ tokens.push(match[1] ?? match[2] ?? match[3] ?? '');
22
+ }
23
+ return tokens;
24
+ }
25
+
26
+ export function installTargets(command: string): string[] {
27
+ const tokens = commandTokens(command);
28
+ const targets: string[] = [];
29
+ for (let index = 0; index < tokens.length - 1; index += 1) {
30
+ if (TARGET_FLAGS.includes(tokens[index] ?? '')) targets.push(tokens[index + 1] ?? '');
31
+ }
32
+ return targets.filter((target) => target !== '');
33
+ }
34
+
35
+ export function failurePathIn(output: string): string | undefined {
36
+ const path = NPM_PATH.exec(output)?.[1]?.trim();
37
+ return path === undefined || path === '' ? undefined : path;
38
+ }
39
+
40
+ export function nearestDir(path: string): string | undefined {
41
+ let at = path;
42
+ for (;;) {
43
+ const here = (): boolean => {
44
+ try {
45
+ return statSync(at).isDirectory();
46
+ } catch {
47
+ return false;
48
+ }
49
+ };
50
+ if (here()) return at;
51
+ const up = dirname(at);
52
+ if (up === at) return undefined;
53
+ at = up;
54
+ }
55
+ }
56
+
57
+ export type WriteState = 'ok' | 'denied' | 'unknown';
58
+
59
+ export function writeStateOf(dir: string): WriteState {
60
+ try {
61
+ accessSync(dir, constants.W_OK);
62
+ return 'ok';
63
+ } catch (error) {
64
+ return (error as { code?: string }).code === 'EACCES' ? 'denied' : 'unknown';
65
+ }
66
+ }
67
+
68
+ export function dirFacts(dir: string): DirFacts | undefined {
69
+ try {
70
+ const stats = statSync(dir);
71
+ return {
72
+ mode: (stats.mode & 0o777).toString(8),
73
+ owner: { uid: stats.uid, gid: stats.gid },
74
+ writable: writeStateOf(dir) === 'ok',
75
+ };
76
+ } catch {
77
+ return undefined;
78
+ }
79
+ }
80
+
81
+ export function currentUser(): { readonly uid: number; readonly gid: number } {
82
+ return { uid: process.getuid?.() ?? -1, gid: process.getgid?.() ?? -1 };
83
+ }
84
+
85
+ export function classifyInstallFailure(output: string): InstallFailureKind {
86
+ const text = output.toLowerCase();
87
+ if (/enospc|no space left/.test(text)) return 'disk-full';
88
+ if (/eacces|eperm|permission denied|operation not permitted|access is denied/.test(text)) return 'permission';
89
+ return 'unknown';
90
+ }
91
+
92
+ export interface InstallFailureFacts {
93
+ readonly command: string;
94
+ readonly exitCode: number | null;
95
+ readonly output: string;
96
+ readonly kind?: InstallFailureKind | undefined;
97
+ readonly path?: string | undefined;
98
+ readonly mode?: string | undefined;
99
+ readonly owner?: { readonly uid: number; readonly gid: number } | undefined;
100
+ readonly writable?: boolean | undefined;
101
+ readonly current: { readonly uid: number; readonly gid: number };
102
+ }
103
+
104
+ export function installFailureOf(facts: InstallFailureFacts): InstallFailure {
105
+ const kind = facts.kind ?? classifyInstallFailure(facts.output);
106
+ const path = facts.path;
107
+ const base = {
108
+ command: facts.command,
109
+ exitCode: facts.exitCode,
110
+ output: facts.output,
111
+ ...(path === undefined ? {} : { path }),
112
+ ...(facts.mode === undefined ? {} : { mode: facts.mode }),
113
+ ...(facts.owner === undefined ? {} : { owner: facts.owner }),
114
+ current: facts.current,
115
+ };
116
+ if (kind === 'disk-full') {
117
+ return { ...base, kind, fix: path === undefined ? { kind: 'handoff' } : { kind: 'space', path } };
118
+ }
119
+ if (kind === 'permission' && path !== undefined) {
120
+ const owner = facts.owner;
121
+ if (owner !== undefined && owner.uid !== facts.current.uid && facts.current.uid >= 0) {
122
+ return { ...base, kind, fix: { kind: 'chown', path, uid: facts.current.uid, gid: facts.current.gid } };
123
+ }
124
+ if (facts.writable === false) {
125
+ return { ...base, kind, fix: { kind: 'chmod', path } };
126
+ }
127
+ if (facts.writable === true) {
128
+ return { ...base, kind: 'sandbox', fix: { kind: 'handoff' } };
129
+ }
130
+ }
131
+ return { ...base, kind, fix: { kind: 'handoff' } };
132
+ }
133
+
134
+ export function diagnoseInstallFailure(input: {
135
+ readonly command: string;
136
+ readonly exitCode: number | null;
137
+ readonly output: string;
138
+ readonly fallback?: string | undefined;
139
+ }): InstallFailure {
140
+ const current = currentUser();
141
+ const candidates = [
142
+ failurePathIn(input.output),
143
+ ...installTargets(input.command),
144
+ input.fallback,
145
+ ].filter((path): path is string => path !== undefined && path !== '');
146
+ for (const candidate of candidates) {
147
+ const dir = nearestDir(candidate);
148
+ if (dir === undefined) continue;
149
+ const facts = dirFacts(dir);
150
+ if (facts === undefined) continue;
151
+ return installFailureOf({
152
+ command: input.command,
153
+ exitCode: input.exitCode,
154
+ output: input.output,
155
+ path: dir,
156
+ mode: facts.mode,
157
+ owner: facts.owner,
158
+ writable: facts.writable,
159
+ current,
160
+ });
161
+ }
162
+ return installFailureOf({
163
+ command: input.command,
164
+ exitCode: input.exitCode,
165
+ output: input.output,
166
+ current,
167
+ });
168
+ }
169
+
170
+ export function installWriteProblem(input: {
171
+ readonly command: string;
172
+ readonly fallback?: string | undefined;
173
+ }): InstallFailure | undefined {
174
+ if (!INSTALL_VERBS.test(input.command)) return undefined;
175
+ const current = currentUser();
176
+ const candidates = [...installTargets(input.command), input.fallback].filter(
177
+ (path): path is string => path !== undefined && path !== '',
178
+ );
179
+ for (const candidate of candidates) {
180
+ const dir = nearestDir(candidate);
181
+ if (dir === undefined) continue;
182
+ if (writeStateOf(dir) !== 'denied') continue;
183
+ const facts = dirFacts(dir);
184
+ if (facts === undefined) continue;
185
+ return installFailureOf({
186
+ command: input.command,
187
+ exitCode: null,
188
+ output: '',
189
+ kind: 'permission',
190
+ path: dir,
191
+ mode: facts.mode,
192
+ owner: facts.owner,
193
+ writable: facts.writable,
194
+ current,
195
+ });
196
+ }
197
+ return undefined;
198
+ }
@@ -0,0 +1,80 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { afterEach, describe, expect, it } from 'vitest';
5
+ import {
6
+ forgetPackageSource,
7
+ installedSourcesFile,
8
+ packageSourceOf,
9
+ readInstalledSources,
10
+ rememberPackageSource,
11
+ } from './installed-sources.js';
12
+
13
+ const dirs: string[] = [];
14
+
15
+ function profile(): string {
16
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'wb-sources-'));
17
+ dirs.push(dir);
18
+ return dir;
19
+ }
20
+
21
+ afterEach(() => {
22
+ for (const dir of dirs.splice(0)) fs.rmSync(dir, { recursive: true, force: true });
23
+ });
24
+
25
+ describe('按包记安装来源(检查更新只认这一份)', () => {
26
+ it('写一条再读回来:版本与源都在,文件是临时名换过去的', () => {
27
+ const dir = profile();
28
+ rememberPackageSource(dir, '@a/mod', { version: '0.3.1', registry: 'https://r1.example/' });
29
+
30
+ expect(packageSourceOf(dir, '@a/mod')).toEqual({ version: '0.3.1', registry: 'https://r1.example/' });
31
+ expect(fs.existsSync(`${installedSourcesFile(dir)}.tmp`)).toBe(false);
32
+ });
33
+
34
+ it('★ 同一个 scope 的两个包可以各记各的源(.npmrc 做不到这件事)', () => {
35
+ const dir = profile();
36
+ rememberPackageSource(dir, '@x/a', { version: '1.0.0', registry: 'https://r1.example/' });
37
+ rememberPackageSource(dir, '@x/b', { version: '2.0.0', registry: 'https://r2.example/' });
38
+
39
+ expect(packageSourceOf(dir, '@x/a')?.registry).toBe('https://r1.example/');
40
+ expect(packageSourceOf(dir, '@x/b')?.registry).toBe('https://r2.example/');
41
+ expect(readInstalledSources(dir)).toHaveProperty('@x/a');
42
+ });
43
+
44
+ it('再装一次同一个包 → 覆盖成这次记录的版本与源', () => {
45
+ const dir = profile();
46
+ rememberPackageSource(dir, '@x/a', { version: '1.0.0', registry: 'https://r1.example/' });
47
+ rememberPackageSource(dir, '@x/a', { version: '1.1.0', registry: 'https://r2.example/' });
48
+
49
+ expect(packageSourceOf(dir, '@x/a')).toEqual({ version: '1.1.0', registry: 'https://r2.example/' });
50
+ });
51
+
52
+ it('卸载后删掉;没有这条时也不写空文件', () => {
53
+ const dir = profile();
54
+ rememberPackageSource(dir, '@x/a', { version: '1.0.0', registry: 'https://r1.example/' });
55
+ forgetPackageSource(dir, '@x/a');
56
+
57
+ expect(packageSourceOf(dir, '@x/a')).toBeUndefined();
58
+ const before = fs.readFileSync(installedSourcesFile(dir), 'utf8');
59
+ forgetPackageSource(dir, '@x/never');
60
+ expect(fs.readFileSync(installedSourcesFile(dir), 'utf8')).toBe(before);
61
+ });
62
+
63
+ it('文件坏了 / 没有 / profile 不认识 → 一律当没有记录,不抛', () => {
64
+ const dir = profile();
65
+ fs.writeFileSync(installedSourcesFile(dir), '{ 坏的 json', 'utf8');
66
+ expect(readInstalledSources(dir)).toEqual({});
67
+ expect(packageSourceOf(dir, '@x/a')).toBeUndefined();
68
+ expect(readInstalledSources(null)).toEqual({});
69
+ });
70
+
71
+ it('空包名 / 空源 / 空版本不落盘(别把没问到的写进去当事实)', () => {
72
+ const dir = profile();
73
+ rememberPackageSource(dir, ' ', { version: '1.0.0', registry: 'https://r1.example/' });
74
+ rememberPackageSource(dir, '@x/a', { version: '', registry: 'https://r1.example/' });
75
+ rememberPackageSource(dir, '@x/b', { version: '1.0.0', registry: ' ' });
76
+
77
+ expect(readInstalledSources(dir)).toEqual({});
78
+ expect(fs.existsSync(installedSourcesFile(dir))).toBe(false);
79
+ });
80
+ });
@@ -0,0 +1,73 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ export interface InstalledSource {
5
+ readonly version: string;
6
+ readonly registry: string;
7
+ }
8
+
9
+ export type InstalledSourceMap = Readonly<Record<string, InstalledSource>>;
10
+
11
+ const FILE_NAME = '.dsh-workbench-sources.json';
12
+ const FILE_VERSION = 1;
13
+
14
+ function asRecord(value: unknown): Readonly<Record<string, unknown>> | undefined {
15
+ return typeof value === 'object' && value !== null ? (value as Readonly<Record<string, unknown>>) : undefined;
16
+ }
17
+
18
+ function asText(value: unknown): string {
19
+ return typeof value === 'string' ? value.trim() : '';
20
+ }
21
+
22
+ export function installedSourcesFile(profileDir: string): string {
23
+ return path.join(profileDir, FILE_NAME);
24
+ }
25
+
26
+ export function readInstalledSources(profileDir: string | null): InstalledSourceMap {
27
+ if (profileDir === null) return {};
28
+ let parsed: unknown;
29
+ try {
30
+ parsed = JSON.parse(fs.readFileSync(installedSourcesFile(profileDir), 'utf8'));
31
+ } catch {
32
+ return {};
33
+ }
34
+ const packages = asRecord(asRecord(parsed)?.packages);
35
+ if (packages === undefined) return {};
36
+ const found: Record<string, InstalledSource> = {};
37
+ for (const [pkg, value] of Object.entries(packages)) {
38
+ const record = asRecord(value);
39
+ if (record === undefined) continue;
40
+ const version = asText(record.version);
41
+ const registry = asText(record.registry);
42
+ if (pkg === '' || version === '' || registry === '') continue;
43
+ found[pkg] = { version, registry };
44
+ }
45
+ return found;
46
+ }
47
+
48
+ export function packageSourceOf(profileDir: string | null, pkg: string): InstalledSource | undefined {
49
+ return readInstalledSources(profileDir)[pkg];
50
+ }
51
+
52
+ function writeInstalledSources(profileDir: string, sources: InstalledSourceMap): void {
53
+ const file = installedSourcesFile(profileDir);
54
+ const temp = `${file}.tmp`;
55
+ const text = `${JSON.stringify({ version: FILE_VERSION, packages: sources }, null, 2)}\n`;
56
+ fs.writeFileSync(temp, text, { mode: 0o600 });
57
+ fs.renameSync(temp, file);
58
+ }
59
+
60
+ export function rememberPackageSource(profileDir: string, pkg: string, source: InstalledSource): void {
61
+ const trimmed = pkg.trim();
62
+ if (trimmed === '' || source.version.trim() === '' || source.registry.trim() === '') return;
63
+ const all = { ...readInstalledSources(profileDir) };
64
+ all[trimmed] = { version: source.version.trim(), registry: source.registry.trim() };
65
+ writeInstalledSources(profileDir, all);
66
+ }
67
+
68
+ export function forgetPackageSource(profileDir: string, pkg: string): void {
69
+ const all = { ...readInstalledSources(profileDir) };
70
+ if (all[pkg] === undefined) return;
71
+ delete all[pkg];
72
+ writeInstalledSources(profileDir, all);
73
+ }
@@ -1,4 +1,5 @@
1
- import type { LibNeed, SupplySource } from '../contract/needs.js';
1
+ import type { BinNeed, LibNeed, SupplySource } from '../contract/needs.js';
2
+ import type { HostOs } from '../contract/protocol/platform.js';
2
3
  import { npmRegistryOf } from './plugin-source.js';
3
4
  import { quoteShellArg } from './shell-text.js';
4
5
  import { libsPrefix, npmCacheDir } from './paths.js';
@@ -9,10 +10,45 @@ export function libInstallCommand(options: {
9
10
  readonly need: LibNeed;
10
11
  readonly source: NpmSource;
11
12
  readonly version?: string | undefined;
13
+ readonly env?: NodeJS.ProcessEnv | undefined;
12
14
  }): string {
13
15
  const { need, source, version } = options;
14
- const registry = npmRegistryOf(source, process.env);
16
+ const registry = npmRegistryOf(source, options.env ?? process.env);
15
17
  const spec = version ?? need.version ?? 'latest';
16
18
  const registryFlag = registry.ok ? ` --registry ${quoteShellArg(registry.value)}` : '';
17
19
  return `npm i --prefix ${quoteShellArg(libsPrefix())} ${quoteShellArg(`${need.name}@${spec}`)}${registryFlag} --cache ${quoteShellArg(npmCacheDir())}`;
18
20
  }
21
+
22
+ /**
23
+ * 这个 bin 在这台机器上怎么装:作者写了 `install` 且平台对得上才给出那条命令。
24
+ * `command` 原样照抄;`npm` 装进隔离运行时的共用依赖前缀(那份包里的 bin 会自动上 PATH)。
25
+ * @param need 一条 bin 需要。
26
+ * @param os 宿主平台(由平台适配器给出)。
27
+ * @param env 取 registry 环境变量的地方。
28
+ * @returns 一条能直接粘进终端的命令;没声明或平台不对就是 undefined。
29
+ */
30
+ export function binInstallCommand(
31
+ need: BinNeed,
32
+ os: HostOs,
33
+ env: NodeJS.ProcessEnv = process.env,
34
+ ): string | undefined {
35
+ const install = need.install;
36
+ if (install === undefined) return undefined;
37
+ if (install.platforms !== undefined && !install.platforms.includes(os)) return undefined;
38
+ if (install.kind === 'command') {
39
+ const line = install.line.trim();
40
+ return line === '' ? undefined : line;
41
+ }
42
+ const packageName = install.package.trim();
43
+ if (packageName === '') return undefined;
44
+ return libInstallCommand({
45
+ need: { kind: 'lib', name: packageName, purpose: need.purpose },
46
+ source: {
47
+ kind: 'npm',
48
+ package: packageName,
49
+ ...(install.registryEnv === undefined ? {} : { registryEnv: install.registryEnv }),
50
+ ...(install.registryDefault === undefined ? {} : { registryDefault: install.registryDefault }),
51
+ },
52
+ env,
53
+ });
54
+ }
@@ -0,0 +1,26 @@
1
+ import { delimiter } from 'node:path';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { ensureLibsBinOnPath, libsBinDir, npmCacheDir } from './paths.js';
4
+
5
+ describe('隔离运行时的路径', () => {
6
+ it('共用依赖的 bin 目录就在 libs 下面', () => {
7
+ expect(libsBinDir().endsWith('/opencli/libs/node_modules/.bin')).toBe(true);
8
+ expect(npmCacheDir().endsWith('/opencli/npm-cache')).toBe(true);
9
+ });
10
+
11
+ it('★ 把 bin 目录前插一次;再插不会重复(PATH 不许越跑越长)', () => {
12
+ const env = { PATH: `/usr/bin${delimiter}/bin` };
13
+ const dir = libsBinDir();
14
+
15
+ expect(ensureLibsBinOnPath(env)).toBe(true);
16
+ expect(env.PATH).toBe(`${dir}${delimiter}/usr/bin${delimiter}/bin`);
17
+ expect(ensureLibsBinOnPath(env)).toBe(false);
18
+ expect(env.PATH.split(delimiter).filter((one) => one === dir)).toHaveLength(1);
19
+ });
20
+
21
+ it('PATH 是空的也能插', () => {
22
+ const env: NodeJS.ProcessEnv = {};
23
+ expect(ensureLibsBinOnPath(env)).toBe(true);
24
+ expect(env.PATH).toBe(libsBinDir());
25
+ });
26
+ });
package/src/host/paths.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { delimiter } from 'node:path';
1
2
  import { workbenchRoot } from './data-root.js';
2
3
 
3
4
  export function isolatedRoot(): string {
@@ -35,3 +36,20 @@ export function vendorPeerLink(): { readonly dir: string; readonly parent: strin
35
36
  export function npmCacheDir(): string {
36
37
  return `${isolatedRoot()}/npm-cache`;
37
38
  }
39
+
40
+ export function libsBinDir(): string {
41
+ return `${libsPrefix()}/node_modules/.bin`;
42
+ }
43
+
44
+ /**
45
+ * 把隔离运行时的共用依赖 bin 目录放到 PATH 最前面(可执行文件都从这儿找)。
46
+ * @param env 改哪份环境(默认当前进程)。
47
+ * @returns 真的插进去了就 `true`;已经在里面就 `false`。
48
+ */
49
+ export function ensureLibsBinOnPath(env: NodeJS.ProcessEnv = process.env): boolean {
50
+ const dir = libsBinDir();
51
+ const parts = (env.PATH ?? '').split(delimiter).filter((one) => one !== '');
52
+ if (parts.includes(dir)) return false;
53
+ env.PATH = [dir, ...parts].join(delimiter);
54
+ return true;
55
+ }
@@ -0,0 +1,31 @@
1
+ import { existsSync, mkdirSync, mkdtempSync, realpathSync } from 'node:fs';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { afterEach, describe, expect, it, vi } from 'vitest';
5
+ import { ensureLibsOpencliPeer } from './runtime-links.js';
6
+
7
+ afterEach(() => {
8
+ vi.unstubAllEnvs();
9
+ });
10
+
11
+ describe('ensureLibsOpencliPeer', () => {
12
+ it('★ 运行时那份 opencli 在 → 链到 libs,libs 里的命令才 import 得到它', () => {
13
+ const root = mkdtempSync(join(tmpdir(), 'wb-peer-'));
14
+ vi.stubEnv('DSH_WORKBENCH_HOME', root);
15
+ mkdirSync(join(root, 'opencli/toolchain/node_modules/@jackwener/opencli'), { recursive: true });
16
+
17
+ expect(ensureLibsOpencliPeer()).toBe(true);
18
+ const link = join(root, 'opencli/libs/node_modules/@jackwener/opencli');
19
+ expect(existsSync(link)).toBe(true);
20
+ expect(realpathSync(link)).toContain('toolchain/node_modules/@jackwener/opencli');
21
+ expect(ensureLibsOpencliPeer()).toBe(false);
22
+ });
23
+
24
+ it('运行时还没装 opencli → 不建空链接(等它装好再说)', () => {
25
+ const root = mkdtempSync(join(tmpdir(), 'wb-peer-'));
26
+ vi.stubEnv('DSH_WORKBENCH_HOME', root);
27
+
28
+ expect(ensureLibsOpencliPeer()).toBe(false);
29
+ expect(existsSync(join(root, 'opencli/libs/node_modules/@jackwener/opencli'))).toBe(false);
30
+ });
31
+ });
@@ -0,0 +1,23 @@
1
+ import { existsSync, mkdirSync, symlinkSync } from 'node:fs';
2
+ import { dirname } from 'node:path';
3
+ import { OPENCLI_NPM_PACKAGE, libsPrefix, toolchainOpencliDir } from './paths.js';
4
+
5
+ /**
6
+ * 让共用依赖也能 import opencli:把隔离运行时里那份 opencli 链接到 libs 的 node_modules。
7
+ * 跑在 libs 里的命令(比如 opencli-workflow 带的 gitops)会按包名找 @jackwener/opencli,
8
+ * 找不到就起不来 —— 它不在 libs 的依赖里,是运行时提供的那一份。
9
+ * @returns 这次真的建了链接就 `true`;已经有了、或运行时那份还没装,就是 `false`。
10
+ */
11
+ export function ensureLibsOpencliPeer(): boolean {
12
+ const link = `${libsPrefix()}/node_modules/${OPENCLI_NPM_PACKAGE}`;
13
+ if (existsSync(link)) return false;
14
+ const target = toolchainOpencliDir();
15
+ if (!existsSync(target)) return false;
16
+ try {
17
+ mkdirSync(dirname(link), { recursive: true });
18
+ symlinkSync(target, link);
19
+ return true;
20
+ } catch {
21
+ return false;
22
+ }
23
+ }
@@ -1,3 +1,7 @@
1
+ export { APPROVAL_NOTE_ARG } from '../contract/approval.js';
2
+
3
+ import { APPROVAL_NOTE_ARG } from '../contract/approval.js';
4
+
1
5
  export type AskDecision =
2
6
  | {
3
7
  readonly kind: 'ask';
@@ -16,8 +20,6 @@ export interface ApprovalDetail {
16
20
 
17
21
  export type ApprovalFacts = ApprovalDetail | { readonly blocked: string };
18
22
 
19
- export const APPROVAL_NOTE_ARG = 'approval_note';
20
-
21
23
  export const APPROVAL_NOTE_LIMIT = 40;
22
24
 
23
25
  export const APPROVAL_DETAIL_LIMIT = 20;
@@ -77,7 +79,8 @@ export interface ApprovalCall {
77
79
 
78
80
  /**
79
81
  * 套件对一次写调用给出的判定:`read` 只是看看、`allow` 会写但本套件不问、`deny` 这一次根本跑不成
80
- * (连问都不该问,`reason` 原样交给 agent)、`ask` 要用户点头(`question` 接在明细后,`fallback` 是没明细时的整句)。
82
+ * (连问都不该问,`reason` 原样交给 agent)、`ask` 要用户点头(`question` 接在明细后,`fallback` 是没明细时的整句;
83
+ * `noteInDetail` 为真表示套件把 agent 的附注画在自己的详情座里,标题只留动作、不再追加)。
81
84
  */
82
85
  export type ApprovalVerdict =
83
86
  | { readonly kind: 'read' }
@@ -91,6 +94,7 @@ export type ApprovalVerdict =
91
94
  readonly reason: string;
92
95
  readonly question: ApprovalDetail;
93
96
  readonly fallback: ApprovalDetail;
97
+ readonly noteInDetail?: boolean;
94
98
  };
95
99
 
96
100
  export type ApprovalPolicy = (call: ApprovalCall) => ApprovalVerdict | undefined;
@@ -168,9 +172,12 @@ export async function approvalFor(
168
172
 
169
173
  if (context.skipAsk === true) return undefined;
170
174
 
175
+ const note =
176
+ context.declaredNote === true && verdict.noteInDetail !== true ? approvalNoteOf(call) : undefined;
177
+
171
178
  return {
172
179
  kind: 'ask',
173
180
  reason: verdict.reason,
174
- displayReason: compose(facts, verdict, context.declaredNote === true ? approvalNoteOf(call) : undefined),
181
+ displayReason: compose(facts, verdict, note),
175
182
  };
176
183
  }