@dsh-workbench/shared 0.1.6 → 0.1.8

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 (96) hide show
  1. package/dist/api/dsh-client.d.ts +11 -0
  2. package/dist/components/Activity.css +69 -0
  3. package/dist/components/Activity.d.ts +23 -0
  4. package/dist/components/Activity.js +23 -0
  5. package/dist/components/ApprovalPlan.css +41 -0
  6. package/dist/components/ApprovalPlan.d.ts +13 -0
  7. package/dist/components/ApprovalPlan.js +11 -0
  8. package/dist/components/Card.css +20 -0
  9. package/dist/components/Card.d.ts +13 -0
  10. package/dist/components/Card.js +8 -0
  11. package/dist/components/LongTaskRow.css +10 -0
  12. package/dist/components/Note.css +14 -0
  13. package/dist/components/Note.d.ts +8 -0
  14. package/dist/components/Note.js +6 -0
  15. package/dist/components/ResourceStates.css +5 -1
  16. package/dist/components/Rows.css +30 -0
  17. package/dist/components/Rows.d.ts +11 -0
  18. package/dist/components/Rows.js +10 -0
  19. package/dist/components/SettingsSection.css +26 -0
  20. package/dist/components/SettingsSection.d.ts +9 -0
  21. package/dist/components/SettingsSection.js +9 -0
  22. package/dist/components/Stack.css +7 -0
  23. package/dist/components/Stack.d.ts +6 -0
  24. package/dist/components/Stack.js +5 -0
  25. package/dist/components/StateBlock.css +36 -0
  26. package/dist/components/StateBlock.d.ts +11 -0
  27. package/dist/components/StateBlock.js +12 -0
  28. package/dist/components/StateDot.d.ts +5 -0
  29. package/dist/components/StateDot.js +10 -0
  30. package/dist/components/icons.d.ts +1 -0
  31. package/dist/components/icons.js +4 -0
  32. package/dist/config/constants.d.ts +2 -0
  33. package/dist/config/constants.js +2 -0
  34. package/dist/host/__tests__/fakes.d.ts +41 -0
  35. package/dist/host/__tests__/fakes.js +76 -0
  36. package/dist/host/__tests__/preflight-fixtures.d.ts +19 -0
  37. package/dist/host/__tests__/preflight-fixtures.js +47 -0
  38. package/dist/host/context-inject.d.ts +24 -0
  39. package/dist/host/context-inject.js +74 -0
  40. package/dist/host/events.d.ts +9 -0
  41. package/dist/host/events.js +70 -0
  42. package/dist/host/mount-units.d.ts +52 -0
  43. package/dist/host/mount-units.js +186 -0
  44. package/dist/host/preflight.d.ts +58 -0
  45. package/dist/host/preflight.js +233 -0
  46. package/dist/host/probe-cache.d.ts +6 -0
  47. package/dist/host/probe-cache.js +19 -0
  48. package/dist/host/probe-libs.d.ts +45 -0
  49. package/dist/host/probe-libs.js +61 -0
  50. package/dist/host/probe-units.d.ts +17 -0
  51. package/dist/host/probe-units.js +102 -0
  52. package/dist/host/probe-verdict.d.ts +7 -0
  53. package/dist/host/probe-verdict.js +143 -0
  54. package/dist/host/tool-scope.d.ts +32 -0
  55. package/dist/host/tool-scope.js +84 -0
  56. package/dist/host/unit-readiness.d.ts +7 -0
  57. package/dist/host/unit-readiness.js +5 -0
  58. package/dist/host/unit-registry.d.ts +9 -0
  59. package/dist/host/unit-registry.js +33 -0
  60. package/dist/host/version-range.d.ts +2 -0
  61. package/dist/host/version-range.js +73 -0
  62. package/dist/i18n/en.js +4 -1
  63. package/dist/i18n/index.d.ts +5 -2
  64. package/dist/i18n/zh.d.ts +4 -1
  65. package/dist/i18n/zh.js +4 -1
  66. package/dist/index.d.ts +8 -0
  67. package/dist/index.js +8 -0
  68. package/dist/lib/react.d.ts +1 -0
  69. package/dist/lib/react.js +1 -0
  70. package/package.json +1 -1
  71. package/src/api/dsh-client.ts +13 -0
  72. package/src/components/Activity.css +69 -0
  73. package/src/components/Activity.tsx +76 -0
  74. package/src/components/ApprovalPlan.css +41 -0
  75. package/src/components/ApprovalPlan.tsx +33 -0
  76. package/src/components/Card.css +20 -0
  77. package/src/components/Card.tsx +28 -0
  78. package/src/components/LongTaskRow.css +10 -0
  79. package/src/components/Note.css +14 -0
  80. package/src/components/Note.tsx +14 -0
  81. package/src/components/ResourceStates.css +5 -1
  82. package/src/components/Rows.css +30 -0
  83. package/src/components/Rows.tsx +25 -0
  84. package/src/components/SettingsSection.css +26 -0
  85. package/src/components/SettingsSection.tsx +22 -0
  86. package/src/components/Stack.css +7 -0
  87. package/src/components/Stack.tsx +11 -0
  88. package/src/components/StateBlock.css +36 -0
  89. package/src/components/StateBlock.tsx +29 -0
  90. package/src/components/icons.tsx +16 -0
  91. package/src/config/constants.ts +4 -0
  92. package/src/i18n/en.ts +4 -1
  93. package/src/i18n/zh.ts +4 -1
  94. package/src/index.ts +8 -0
  95. package/src/lib/react.test.ts +2 -1
  96. package/src/lib/react.ts +1 -0
@@ -0,0 +1,233 @@
1
+ import { INSTALL_TIMEOUT_MS, OPENCLI_TIMEOUT_MS, WARM_TIMEOUT_MS } from '@dsh-workbench/shared';
2
+ import { tailLines } from '@dsh-workbench/shared';
3
+ import { opencliCommandLine } from './opencli';
4
+ import { isolatedInstallCommand } from './opencli-runtime';
5
+ import { runShell } from './shell';
6
+ export function classifyFailure(text) {
7
+ const lower = text.toLowerCase();
8
+ if (/eacces|eperm|permission denied|operation not permitted/.test(lower))
9
+ return 'permission';
10
+ if (/enotfound|etimedout|econnrefused|econnreset|network|proxy|fetch failed/.test(lower))
11
+ return 'network';
12
+ if (/command not found|not found|no such file/.test(lower))
13
+ return 'missing';
14
+ return 'unknown';
15
+ }
16
+ export function readBridgeReport(stdout) {
17
+ const text = String(stdout ?? '');
18
+ const ok = /^\[OK\] Daemon:/m.test(text) && /^\[OK\] Extension:/m.test(text) && /^\[OK\] Connectivity:/m.test(text);
19
+ if (ok) {
20
+ const good = text.split('\n').find((line) => line.trim() === 'Everything looks good!');
21
+ return { ok: true, issues: [], detail: good?.trim() ?? tailLines(text, 1) };
22
+ }
23
+ const found = new Set();
24
+ if (!/^\[OK\] Daemon:/m.test(text))
25
+ found.add('daemon');
26
+ if (!/^\[OK\] Extension:/m.test(text))
27
+ found.add('extension');
28
+ if (/Multiple Chrome profiles are connected/i.test(text))
29
+ found.add('profile');
30
+ if (/Default browser profile is stale|Selected browser profile is not connected/i.test(text)) {
31
+ found.add('stale');
32
+ }
33
+ if (found.has('profile') || found.has('stale'))
34
+ found.delete('extension');
35
+ if (found.size === 0)
36
+ found.add('unknown');
37
+ const order = ['profile', 'stale', 'daemon', 'extension', 'unknown'];
38
+ return { ok: false, issues: order.filter((issue) => found.has(issue)), detail: issuesSection(text) };
39
+ }
40
+ function issuesSection(text) {
41
+ const lines = text.split('\n');
42
+ const start = lines.findIndex((line) => /^Issues:/.test(line.trim()));
43
+ if (start < 0)
44
+ return tailLines(text, 6);
45
+ return lines.slice(start).join('\n').trim();
46
+ }
47
+ function bridgeFixCommand(command, issues) {
48
+ if (issues.includes('profile') || issues.includes('stale'))
49
+ return `${command} profile list`;
50
+ if (issues.includes('daemon') || issues.includes('extension'))
51
+ return `${command} daemon restart`;
52
+ return undefined;
53
+ }
54
+ export const OPENCLI_INSTALL_COMMAND = isolatedInstallCommand();
55
+ const WARMABLE_BRIDGE_ISSUES = ['daemon', 'stale'];
56
+ export function canWarm(bridge) {
57
+ return bridge.ok || bridge.issues.every((issue) => WARMABLE_BRIDGE_ISSUES.includes(issue));
58
+ }
59
+ function step(id) {
60
+ return { id, status: 'pending' };
61
+ }
62
+ export function createPreflight(options) {
63
+ const { shell, command = 'opencli', logger } = options;
64
+ const installCommand = options.installCommand ?? OPENCLI_INSTALL_COMMAND;
65
+ const warmReads = () => options.warmReads?.() ?? [];
66
+ const steps = () => [
67
+ step('opencli'),
68
+ step('bridge'),
69
+ ...(warmReads().length === 0 ? [] : [step('warm')]),
70
+ ];
71
+ let current = { phase: 'idle', steps: steps(), at: Date.now() };
72
+ let running = null;
73
+ let latest = null;
74
+ let rerun = false;
75
+ function set(next) {
76
+ current = { ...current, ...next, at: Date.now() };
77
+ return current;
78
+ }
79
+ function patchStep(id, patch) {
80
+ return set({ steps: current.steps.map((item) => (item.id === id ? { ...item, ...patch } : item)) });
81
+ }
82
+ async function exec(commandLine, timeoutMs) {
83
+ try {
84
+ const result = await runShell(shell, commandLine, { timeoutMs });
85
+ if (result.timedOut) {
86
+ return { kind: 'error', detail: `超时(${String(timeoutMs)} ms):${commandLine}` };
87
+ }
88
+ if (result.exitCode !== 0) {
89
+ return { kind: 'nonzero', detail: tailLines(result.stderr || result.stdout, 4) };
90
+ }
91
+ return { kind: 'ok', stdout: result.stdout };
92
+ }
93
+ catch (error) {
94
+ return { kind: 'error', detail: String(error) };
95
+ }
96
+ }
97
+ async function probeVersion() {
98
+ const result = await exec(`${command} --version`, OPENCLI_TIMEOUT_MS);
99
+ if (result.kind === 'ok')
100
+ return { ok: true, missing: false, detail: result.stdout.trim().split('\n')[0] ?? '' };
101
+ return { ok: false, missing: result.kind === 'nonzero', detail: result.detail };
102
+ }
103
+ async function probeBridge() {
104
+ const result = await exec(`${command} doctor`, OPENCLI_TIMEOUT_MS);
105
+ if (result.kind !== 'ok')
106
+ return { ok: false, detail: result.detail, issues: ['unknown'] };
107
+ const report = readBridgeReport(result.stdout);
108
+ if (report.ok)
109
+ return report;
110
+ return { ...report, command: bridgeFixCommand(command, report.issues) };
111
+ }
112
+ async function installOpencli() {
113
+ if (options.install !== undefined)
114
+ return options.install();
115
+ const result = await exec(installCommand, INSTALL_TIMEOUT_MS);
116
+ if (result.kind !== 'ok')
117
+ return { ok: false, detail: result.detail };
118
+ return { ok: true, detail: tailLines(result.stdout, 1) };
119
+ }
120
+ async function warmSites(bridge) {
121
+ const reads = warmReads();
122
+ if (reads.length === 0)
123
+ return bridge;
124
+ if (!canWarm(bridge)) {
125
+ patchStep('warm', { status: 'warn', detail: '' });
126
+ return bridge;
127
+ }
128
+ patchStep('warm', {
129
+ status: 'running',
130
+ action: `${command} <套件自己声明的那条读命令> --site-session persistent -f json`,
131
+ });
132
+ const results = await Promise.all(reads.map((read) => exec(opencliCommandLine(command, read), WARM_TIMEOUT_MS)));
133
+ const failed = reads.filter((_, index) => results[index]?.kind !== 'ok');
134
+ patchStep('warm', {
135
+ status: failed.length === 0 ? 'ok' : 'warn',
136
+ detail: failed.length === 0 ? String(reads.length) : failed.map((read) => read.join(' ')).join(' '),
137
+ action: undefined,
138
+ });
139
+ if (bridge.ok)
140
+ return bridge;
141
+ const again = await probeBridge();
142
+ patchStep('bridge', {
143
+ status: again.ok ? 'ok' : 'warn',
144
+ detail: again.detail,
145
+ action: undefined,
146
+ command: again.command,
147
+ });
148
+ return again;
149
+ }
150
+ async function doRun() {
151
+ set({ phase: 'checking', error: undefined, failure: undefined, steps: steps() });
152
+ patchStep('opencli', { status: 'running', action: `${command} --version` });
153
+ let version = await probeVersion();
154
+ if (!version.ok && !version.missing) {
155
+ patchStep('opencli', { status: 'failed', detail: version.detail, action: undefined });
156
+ return set({
157
+ phase: 'failed',
158
+ error: version.detail,
159
+ failure: classifyFailure(version.detail),
160
+ guidance: 'shell',
161
+ });
162
+ }
163
+ if (!version.ok) {
164
+ set({ phase: 'installing' });
165
+ patchStep('opencli', { status: 'running', action: installCommand, command: installCommand });
166
+ const installed = await installOpencli();
167
+ if (!installed.ok) {
168
+ patchStep('opencli', {
169
+ status: 'failed',
170
+ detail: installed.detail,
171
+ command: installCommand,
172
+ });
173
+ return set({
174
+ phase: 'failed',
175
+ error: installed.detail,
176
+ failure: classifyFailure(installed.detail),
177
+ guidance: 'install',
178
+ });
179
+ }
180
+ patchStep('opencli', { status: 'running', action: `${command} --version` });
181
+ version = await probeVersion();
182
+ if (!version.ok) {
183
+ patchStep('opencli', { status: 'failed', detail: version.detail, command: installCommand });
184
+ return set({
185
+ phase: 'failed',
186
+ error: version.detail,
187
+ failure: classifyFailure(version.detail),
188
+ guidance: 'path',
189
+ });
190
+ }
191
+ }
192
+ patchStep('opencli', { status: 'ok', detail: version.detail, action: undefined });
193
+ patchStep('bridge', { status: 'running', action: `${command} doctor` });
194
+ const bridge = await probeBridge();
195
+ const healable = !bridge.ok && warmReads().length > 0 && canWarm(bridge);
196
+ if (!healable) {
197
+ patchStep('bridge', {
198
+ status: bridge.ok ? 'ok' : 'warn',
199
+ detail: bridge.detail,
200
+ action: undefined,
201
+ ...(bridge.command === undefined ? {} : { command: bridge.command }),
202
+ });
203
+ }
204
+ const settled = await warmSites(bridge);
205
+ logger?.warn?.(`[dsh-workbench] 环境体检:${current.steps.map((item) => `${item.id}=${item.status}`).join(' ')}`);
206
+ return set({ phase: 'ready', ...(settled.ok ? {} : { bridgeIssues: settled.issues }) });
207
+ }
208
+ async function start(runOptions = {}) {
209
+ if (running !== null) {
210
+ if (runOptions.force === true)
211
+ rerun = true;
212
+ return running;
213
+ }
214
+ if (runOptions.force !== true && latest !== null)
215
+ return latest;
216
+ const inflight = doRun();
217
+ latest = inflight;
218
+ running = inflight;
219
+ await inflight.catch(() => undefined);
220
+ running = null;
221
+ if (rerun) {
222
+ rerun = false;
223
+ return start({ force: true });
224
+ }
225
+ return inflight;
226
+ }
227
+ return {
228
+ run: start,
229
+ snapshot() {
230
+ return current;
231
+ },
232
+ };
233
+ }
@@ -0,0 +1,6 @@
1
+ export interface TimedMemo<T> {
2
+ get(key: string, now?: number): T | undefined;
3
+ set(key: string, value: T, now?: number): void;
4
+ clear(): void;
5
+ }
6
+ export declare function createTimedMemo<T>(ttlMs: number): TimedMemo<T>;
@@ -0,0 +1,19 @@
1
+ export function createTimedMemo(ttlMs) {
2
+ const entries = new Map();
3
+ return {
4
+ get(key, now = Date.now()) {
5
+ const hit = entries.get(key);
6
+ if (hit === undefined)
7
+ return undefined;
8
+ if (now - hit.at >= ttlMs)
9
+ return undefined;
10
+ return hit.value;
11
+ },
12
+ set(key, value, now = Date.now()) {
13
+ entries.set(key, { at: now, value });
14
+ },
15
+ clear() {
16
+ entries.clear();
17
+ },
18
+ };
19
+ }
@@ -0,0 +1,45 @@
1
+ import type { DshShell } from '@dsh-workbench/shared';
2
+ import type { PluginRelease, UnitLibNeed, UnitPluginNeed } from '@dsh-workbench/shared';
3
+ import type { PluginSourceProblem } from './plugin-source';
4
+ export interface UnitLibState {
5
+ readonly name: string;
6
+ readonly purpose: string;
7
+ readonly status: 'ok' | 'outdated' | 'missing' | 'unsatisfied';
8
+ readonly installed?: string;
9
+ readonly latest?: string;
10
+ readonly command?: string;
11
+ }
12
+ export declare function installedLibVersion(name: string): string | undefined;
13
+ export declare function probeLibs(options: {
14
+ readonly needs: readonly UnitLibNeed[];
15
+ readonly shell: DshShell;
16
+ readonly force: boolean;
17
+ }): Promise<ReadonlyMap<string, UnitLibState>>;
18
+ export interface UnitCommands {
19
+ readonly unit: string;
20
+ readonly plugins?: readonly UnitPluginNeed[];
21
+ readonly libs?: readonly UnitLibNeed[];
22
+ }
23
+ export interface UnitPluginState {
24
+ readonly name: string;
25
+ readonly status: "ok" | "outdated" | "missing";
26
+ readonly missing: readonly string[];
27
+ readonly install?: "ready" | PluginSourceProblem;
28
+ readonly command?: string;
29
+ readonly release?: PluginRelease;
30
+ }
31
+ export interface UnitCapability {
32
+ readonly unit: string;
33
+ readonly status: "ok" | "missing-commands" | "missing-plugin" | "unchecked";
34
+ readonly plugin?: string;
35
+ readonly required: readonly string[];
36
+ readonly missing: readonly string[];
37
+ readonly detail: string;
38
+ readonly command?: string;
39
+ readonly fix?: "install" | "update";
40
+ readonly install?: "ready" | PluginSourceProblem;
41
+ readonly plugins?: readonly UnitPluginState[];
42
+ readonly libs?: readonly UnitLibState[];
43
+ readonly released?: boolean;
44
+ }
45
+ export declare function resetLibCache(): void;
@@ -0,0 +1,61 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { libDir, libInstallCommand } from './opencli-runtime';
4
+ import { isNewerVersion, latestVersion } from './release';
5
+ import { resetReleaseCache } from './release';
6
+ import { checkVersionRange } from './version-range';
7
+ export function installedLibVersion(name) {
8
+ try {
9
+ const parsed = JSON.parse(readFileSync(join(libDir(name), 'package.json'), 'utf8'));
10
+ const version = parsed.version;
11
+ return typeof version === 'string' && version.trim() !== ''
12
+ ? version.trim()
13
+ : undefined;
14
+ }
15
+ catch {
16
+ return undefined;
17
+ }
18
+ }
19
+ export async function probeLibs(options) {
20
+ const { needs, shell, force } = options;
21
+ const found = new Map();
22
+ for (const need of needs) {
23
+ if (found.has(need.name))
24
+ continue;
25
+ const base = { name: need.name, purpose: need.purpose };
26
+ const installed = installedLibVersion(need.name);
27
+ if (installed === undefined) {
28
+ found.set(need.name, {
29
+ ...base,
30
+ status: 'missing',
31
+ command: libInstallCommand({ need }),
32
+ });
33
+ continue;
34
+ }
35
+ if (checkVersionRange(need.version, installed) !== 'satisfied') {
36
+ found.set(need.name, {
37
+ ...base,
38
+ status: 'unsatisfied',
39
+ installed,
40
+ command: libInstallCommand({ need }),
41
+ });
42
+ continue;
43
+ }
44
+ const registry = process.env[need.registryEnv]?.trim() || need.registryDefault;
45
+ const latest = await latestVersion({ shell, name: need.name, registry, force });
46
+ const behind = latest !== undefined && isNewerVersion(latest, installed);
47
+ found.set(need.name, {
48
+ ...base,
49
+ status: behind ? 'outdated' : 'ok',
50
+ installed,
51
+ ...(latest === undefined ? {} : { latest }),
52
+ ...(behind
53
+ ? { command: libInstallCommand({ need, version: latest }) }
54
+ : {}),
55
+ });
56
+ }
57
+ return found;
58
+ }
59
+ export function resetLibCache() {
60
+ resetReleaseCache();
61
+ }
@@ -0,0 +1,17 @@
1
+ import type { DshShell } from '@dsh-workbench/shared';
2
+ import { type UnitCapability, type UnitCommands } from "./probe-libs";
3
+ export type { UnitCapability, UnitCommands, UnitLibState, UnitPluginState } from "./probe-libs";
4
+ export declare function parsePluginList(stdout: string): unknown[] | undefined;
5
+ export declare function probeUnitCommands(options: {
6
+ shell: DshShell;
7
+ command?: string;
8
+ unit: UnitCommands;
9
+ force?: boolean;
10
+ }): Promise<UnitCapability>;
11
+ export declare function probeUnits(options: {
12
+ shell: DshShell;
13
+ command?: string;
14
+ units: readonly UnitCommands[];
15
+ force?: boolean;
16
+ }): Promise<UnitCapability[]>;
17
+ export declare const resetProbeCache: () => void;
@@ -0,0 +1,102 @@
1
+ import { OPENCLI_TIMEOUT_MS, PROBE_TTL_MS } from '@dsh-workbench/shared';
2
+ import { createTimedMemo } from "./probe-cache";
3
+ import { parseJsonLoose } from '@dsh-workbench/shared';
4
+ import { npmRegistryOf } from "./plugin-source";
5
+ import { probeRelease } from "./release";
6
+ import { probeLibs } from "./probe-libs";
7
+ import { evaluateUnit } from "./probe-verdict";
8
+ import { runShell } from "./shell";
9
+ export function parsePluginList(stdout) {
10
+ const parsed = parseJsonLoose(stdout);
11
+ if (Array.isArray(parsed))
12
+ return parsed;
13
+ return /no plugins? installed/i.test(stdout) ? [] : undefined;
14
+ }
15
+ async function probeReleases(units, options) {
16
+ const needs = new Map();
17
+ for (const unit of units) {
18
+ for (const need of unit.plugins ?? []) {
19
+ const source = need.installSource;
20
+ if (source?.kind === "npm")
21
+ needs.set(need.name, source);
22
+ }
23
+ }
24
+ const found = new Map();
25
+ for (const [plugin, source] of needs) {
26
+ const registry = npmRegistryOf(source, process.env);
27
+ const signal = await probeRelease({
28
+ shell: options.shell,
29
+ package: source.package,
30
+ ...(registry.ok ? { registry: registry.value } : {}),
31
+ force: options.force,
32
+ });
33
+ if (signal !== undefined)
34
+ found.set(plugin, signal);
35
+ }
36
+ return found;
37
+ }
38
+ export async function probeUnitCommands(options) {
39
+ const all = await probeUnits({ ...options, units: [options.unit] });
40
+ return (all[0] ??
41
+ evaluateUnit(undefined, options.unit, "unchecked", options.command ?? "opencli"));
42
+ }
43
+ export async function probeUnits(options) {
44
+ const { shell, command = "opencli", units, force = false } = options;
45
+ const hits = units.map((unit) => unitCache.get(`${command}:${unit.unit}`));
46
+ if (!force && hits.every((hit) => hit !== undefined)) {
47
+ return hits;
48
+ }
49
+ const result = await runShell(shell, `${command} plugin list -f json`, {
50
+ timeoutMs: OPENCLI_TIMEOUT_MS,
51
+ }).then((value) => ({
52
+ kind: "ok",
53
+ stdout: value.exitCode === 0 ? value.stdout : "",
54
+ stderr: value.stderr,
55
+ }), (error) => ({
56
+ kind: "error",
57
+ stdout: "",
58
+ stderr: String(error),
59
+ }));
60
+ const list = result.kind === "ok" ? parsePluginList(result.stdout) : undefined;
61
+ const failure = result.kind === "error"
62
+ ? result.stderr
63
+ : list === undefined
64
+ ? "plugin list 的输出读不懂(不是 JSON 数组)"
65
+ : undefined;
66
+ if (failure !== undefined) {
67
+ return units.map((unit) => {
68
+ const capability = {
69
+ unit: unit.unit,
70
+ ...(unit.plugins?.[0] === undefined
71
+ ? {}
72
+ : { plugin: unit.plugins[0].name }),
73
+ status: "unchecked",
74
+ required: (unit.plugins ?? []).flatMap((need) => need.commands ?? []),
75
+ missing: [],
76
+ detail: failure,
77
+ };
78
+ unitCache.set(`${command}:${unit.unit}`, capability);
79
+ return capability;
80
+ });
81
+ }
82
+ const [releases, libs] = await Promise.all([
83
+ probeReleases(units, { shell, force }),
84
+ probeLibs({
85
+ needs: units.flatMap((unit) => unit.libs ?? []),
86
+ shell,
87
+ force,
88
+ }),
89
+ ]);
90
+ return units.map((unit) => {
91
+ const capability = evaluateUnit(list, unit, undefined, command, releases, libsOfUnit(unit, libs));
92
+ unitCache.set(`${command}:${unit.unit}`, capability);
93
+ return capability;
94
+ });
95
+ }
96
+ function libsOfUnit(unit, libs) {
97
+ return (unit.libs ?? [])
98
+ .map((need) => libs.get(need.name))
99
+ .filter((state) => state !== undefined);
100
+ }
101
+ const unitCache = createTimedMemo(PROBE_TTL_MS);
102
+ export const resetProbeCache = () => void unitCache.clear();
@@ -0,0 +1,7 @@
1
+ import type { PluginRelease, UnitPluginNeed } from '@dsh-workbench/shared';
2
+ import type { UnitCapability, UnitCommands, UnitLibState } from "./probe-libs";
3
+ export declare function evaluateUnit(list: readonly unknown[] | undefined, unit: UnitCommands, failure: string | undefined, command?: string, releases?: ReadonlyMap<string, PluginRelease>, libs?: readonly UnitLibState[]): UnitCapability;
4
+ export declare function gapsText(gaps: readonly string[]): string;
5
+ export declare function pluginUpdateCommand(plugin: string | undefined): string | undefined;
6
+ export declare function pluginInstallCommand(plugin: string | undefined): string;
7
+ export declare function upgradeLine(need: UnitPluginNeed, command: string, latest: string): string | undefined;
@@ -0,0 +1,143 @@
1
+ import { resolvePluginSource } from "./plugin-source";
2
+ import { isNewerVersion } from "./release";
3
+ export function evaluateUnit(list, unit, failure, command = "opencli", releases = new Map(), libs = []) {
4
+ const isPlugin = (item) => typeof item === "object" && item !== null;
5
+ const needs = unit.plugins ?? [];
6
+ const required = needs.flatMap((need) => need.commands ?? []);
7
+ const primary = needs[0]?.name;
8
+ if (failure !== undefined || list === undefined) {
9
+ return {
10
+ unit: unit.unit,
11
+ ...(primary === undefined ? {} : { plugin: primary }),
12
+ status: "unchecked",
13
+ required,
14
+ missing: [],
15
+ detail: failure ?? "探不动",
16
+ ...(libs.length === 0 ? {} : { libs }),
17
+ };
18
+ }
19
+ const plugins = list.filter(isPlugin);
20
+ const installed = new Map(plugins.map((item) => [String(item.name), item]));
21
+ const states = needs.map((need) => {
22
+ const hit = installed.get(need.name);
23
+ if (hit === undefined) {
24
+ const source = resolvePluginSource(need.installSource, process.env, {
25
+ command,
26
+ plugin: need.name,
27
+ });
28
+ return {
29
+ name: need.name,
30
+ status: "missing",
31
+ missing: [],
32
+ install: source.ok ? "ready" : source.problem,
33
+ command: source.ok ? source.line : pluginInstallCommand(need.name),
34
+ };
35
+ }
36
+ const available = new Set((Array.isArray(hit.commands) ? hit.commands : []).map(String));
37
+ const missing = (need.commands ?? []).filter((name) => !available.has(name));
38
+ const release = releases.get(need.name);
39
+ const behind = release?.latest !== undefined &&
40
+ isNewerVersion(release.latest, release.installed);
41
+ const commandsBehind = missing.length > 0;
42
+ const upgrade = behind && release?.latest !== undefined
43
+ ? upgradeLine(need, command, release.latest)
44
+ : undefined;
45
+ return {
46
+ name: need.name,
47
+ status: commandsBehind || behind ? "outdated" : "ok",
48
+ missing,
49
+ ...(release === undefined ? {} : { release }),
50
+ ...(commandsBehind || behind
51
+ ? {
52
+ command: upgrade ??
53
+ pluginUpdateCommand(need.name) ??
54
+ pluginInstallCommand(need.name),
55
+ }
56
+ : {}),
57
+ };
58
+ });
59
+ const absent = states.filter((state) => state.status === "missing");
60
+ const outdated = states.filter((state) => state.status === "outdated");
61
+ const missing = states.flatMap((state) => state.missing);
62
+ const libMissing = libs.filter((state) => state.status === "missing");
63
+ const libMismatch = libs.filter((state) => state.status === "unsatisfied");
64
+ const libBehind = libs.filter((state) => state.status === "outdated");
65
+ const status = absent.length > 0 || libMissing.length > 0 || libMismatch.length > 0
66
+ ? "missing-plugin"
67
+ : missing.length > 0
68
+ ? "missing-commands"
69
+ : "ok";
70
+ const blocked = absent.find((state) => state.install !== "ready");
71
+ const install = absent.length === 0
72
+ ?
73
+ libMissing.length > 0 || libMismatch.length > 0
74
+ ? "ready"
75
+ : undefined
76
+ : blocked === undefined
77
+ ? "ready"
78
+ : blocked.install;
79
+ const fix = status === "missing-plugin"
80
+ ? install === "ready"
81
+ ? "install"
82
+ : undefined
83
+ : outdated.length > 0 || libBehind.length > 0
84
+ ? "update"
85
+ : undefined;
86
+ const first = absent[0] ?? outdated[0] ?? libMissing[0] ?? libMismatch[0] ?? libBehind[0];
87
+ const gaps = [...(missing.length === 0 ? [] : [`缺必需:${missing.join("、")}`])];
88
+ const newer = states.filter((state) => state.release?.latest !== undefined &&
89
+ isNewerVersion(state.release.latest, state.release.installed));
90
+ const versionText = newer.length === 0
91
+ ? ""
92
+ : `(源上有新版:${newer.map((state) => `${state.release?.installed ?? ""} → ${state.release?.latest ?? ""}`).join("、")})`;
93
+ const absentNames = [
94
+ ...absent.map((state) => state.name),
95
+ ...libMissing.map((state) => state.name),
96
+ ];
97
+ const mismatchText = libMismatch.map((state) => {
98
+ const declared = unit.libs?.find((need) => need.name === state.name)?.version?.trim();
99
+ return `${state.name}(装的是 ${state.installed ?? "未知"},声明要 ${declared === undefined || declared === "" ? "任意版本" : declared})`;
100
+ });
101
+ return {
102
+ unit: unit.unit,
103
+ ...(primary === undefined ? {} : { plugin: primary }),
104
+ status,
105
+ required,
106
+ missing,
107
+ detail: absentNames.length > 0 || mismatchText.length > 0
108
+ ? [
109
+ ...(absentNames.length === 0
110
+ ? []
111
+ : [`${plugins.length} 个已装插件里没有 ${absentNames.join("、")}`]),
112
+ ...mismatchText,
113
+ ].join(";")
114
+ : `${needs.length} 份安装都在${gapsText(gaps)}${versionText}${libs.length === 0
115
+ ? ""
116
+ : `;交叉依赖:${libs.map((state) => `${state.name}${state.installed === undefined ? " 未装" : ` ${state.installed}`}`).join("、")}${libBehind.length === 0 ? "" : "(源上有新版)"}`}`,
117
+ ...(fix === undefined && first === undefined
118
+ ? {}
119
+ : { command: fix === "install" ? (absent[0] ?? first)?.command : first?.command }),
120
+ ...(fix === undefined ? {} : { fix }),
121
+ ...(install === undefined ? {} : { install }),
122
+ plugins: states,
123
+ ...(libs.length === 0 ? {} : { libs }),
124
+ };
125
+ }
126
+ export function gapsText(gaps) {
127
+ return gaps.length === 0 ? "" : `(${gaps.join(";")})`;
128
+ }
129
+ export function pluginUpdateCommand(plugin) {
130
+ return plugin === undefined ? undefined : `opencli plugin update ${plugin}`;
131
+ }
132
+ export function pluginInstallCommand(plugin) {
133
+ return `opencli plugin install <${plugin ?? "插件"}源>`;
134
+ }
135
+ export function upgradeLine(need, command, latest) {
136
+ const source = resolvePluginSource(need.installSource, process.env, {
137
+ command,
138
+ plugin: need.name,
139
+ version: latest,
140
+ mode: "update",
141
+ });
142
+ return source.ok ? source.line : undefined;
143
+ }
@@ -0,0 +1,32 @@
1
+ import type { DshPromptAssembly } from '@dsh-workbench/shared';
2
+ export interface ScopedToolRegistry {
3
+ restrict(filter: {
4
+ readonly allow?: readonly string[];
5
+ readonly deny?: readonly string[];
6
+ }): () => void;
7
+ }
8
+ export interface ToolScopeAgent {
9
+ readonly ctx?: {
10
+ readonly tools?: ScopedToolRegistry | undefined;
11
+ get?<T = unknown>(name: string): T | undefined;
12
+ } | undefined;
13
+ readonly session?: {
14
+ readonly header?: {
15
+ readonly cwd?: string;
16
+ } | undefined;
17
+ } | undefined;
18
+ }
19
+ export interface ToolScopeOptions {
20
+ deniedTools(workspace: string | null): readonly string[];
21
+ logger?: {
22
+ info?(message: string): void;
23
+ warn?(message: string): void;
24
+ } | undefined;
25
+ }
26
+ export interface ToolScope {
27
+ apply(agent: unknown): void;
28
+ narrow(assembly: DshPromptAssembly, agent: unknown): DshPromptAssembly;
29
+ reapply(): void;
30
+ disposeAll(): void;
31
+ }
32
+ export declare function createToolScope(options: ToolScopeOptions): ToolScope;