@dsh-workbench/shared 0.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/dist/api/blocker-table.d.ts +9 -0
- package/dist/api/blocker-table.js +130 -0
- package/dist/api/blocker.d.ts +30 -0
- package/dist/api/blocker.js +1 -0
- package/dist/api/context-ref.d.ts +10 -0
- package/dist/api/context-ref.js +40 -0
- package/dist/api/dsh-client.d.ts +170 -0
- package/dist/api/dsh-client.js +1 -0
- package/dist/api/dsh.d.ts +153 -0
- package/dist/api/dsh.js +1 -0
- package/dist/api/facts.d.ts +27 -0
- package/dist/api/facts.js +146 -0
- package/dist/api/host-write.d.ts +12 -0
- package/dist/api/host-write.js +17 -0
- package/dist/api/long-task.d.ts +18 -0
- package/dist/api/long-task.js +22 -0
- package/dist/api/unit-board.d.ts +14 -0
- package/dist/api/unit-board.js +29 -0
- package/dist/api/unit.d.ts +54 -0
- package/dist/api/unit.js +1 -0
- package/dist/api/workbench-client.d.ts +19 -0
- package/dist/api/workbench-client.js +33 -0
- package/dist/api/workbench-contract.d.ts +32 -0
- package/dist/api/workbench-contract.js +1 -0
- package/dist/components/ApprovalDetail.d.ts +27 -0
- package/dist/components/ApprovalDetail.js +33 -0
- package/dist/components/CardHead.d.ts +16 -0
- package/dist/components/CardHead.js +14 -0
- package/dist/components/ChoiceGroup.d.ts +14 -0
- package/dist/components/ChoiceGroup.js +7 -0
- package/dist/components/LongTaskRow.d.ts +16 -0
- package/dist/components/LongTaskRow.js +28 -0
- package/dist/components/MockStrip.d.ts +14 -0
- package/dist/components/MockStrip.js +7 -0
- package/dist/components/ResourceStates.d.ts +18 -0
- package/dist/components/ResourceStates.js +17 -0
- package/dist/components/SaveBar.d.ts +12 -0
- package/dist/components/SaveBar.js +6 -0
- package/dist/components/StateDot.d.ts +5 -0
- package/dist/components/StateDot.js +10 -0
- package/dist/components/board-module.d.ts +67 -0
- package/dist/components/board-module.js +1 -0
- package/dist/components/icons.d.ts +18 -0
- package/dist/components/icons.js +81 -0
- package/dist/config/constants.d.ts +27 -0
- package/dist/config/constants.js +27 -0
- package/dist/config/storage.d.ts +4 -0
- package/dist/config/storage.js +27 -0
- package/dist/host/__tests__/fakes.d.ts +41 -0
- package/dist/host/__tests__/fakes.js +76 -0
- package/dist/host/__tests__/preflight-fixtures.d.ts +19 -0
- package/dist/host/__tests__/preflight-fixtures.js +47 -0
- package/dist/host/cli-error.d.ts +8 -0
- package/dist/host/cli-error.js +70 -0
- package/dist/host/context-inject.d.ts +24 -0
- package/dist/host/context-inject.js +74 -0
- package/dist/host/data-root.d.ts +3 -0
- package/dist/host/data-root.js +12 -0
- package/dist/host/events.d.ts +9 -0
- package/dist/host/events.js +70 -0
- package/dist/host/facts.d.ts +24 -0
- package/dist/host/facts.js +44 -0
- package/dist/host/git.d.ts +55 -0
- package/dist/host/git.js +64 -0
- package/dist/host/http.d.ts +36 -0
- package/dist/host/http.js +242 -0
- package/dist/host/long-task.d.ts +58 -0
- package/dist/host/long-task.js +148 -0
- package/dist/host/mount-units.d.ts +52 -0
- package/dist/host/mount-units.js +186 -0
- package/dist/host/opencli-runtime.d.ts +22 -0
- package/dist/host/opencli-runtime.js +46 -0
- package/dist/host/opencli.d.ts +37 -0
- package/dist/host/opencli.js +152 -0
- package/dist/host/plugin-source.d.ts +31 -0
- package/dist/host/plugin-source.js +81 -0
- package/dist/host/preflight.d.ts +58 -0
- package/dist/host/preflight.js +233 -0
- package/dist/host/probe-cache.d.ts +6 -0
- package/dist/host/probe-cache.js +19 -0
- package/dist/host/probe-libs.d.ts +45 -0
- package/dist/host/probe-libs.js +61 -0
- package/dist/host/probe-units.d.ts +17 -0
- package/dist/host/probe-units.js +102 -0
- package/dist/host/probe-verdict.d.ts +7 -0
- package/dist/host/probe-verdict.js +143 -0
- package/dist/host/provision.d.ts +14 -0
- package/dist/host/provision.js +12 -0
- package/dist/host/shell.d.ts +14 -0
- package/dist/host/shell.js +23 -0
- package/dist/host/tool-approval.d.ts +29 -0
- package/dist/host/tool-approval.js +190 -0
- package/dist/host/tool-kit.d.ts +19 -0
- package/dist/host/tool-kit.js +37 -0
- package/dist/host/tool-scope.d.ts +32 -0
- package/dist/host/tool-scope.js +84 -0
- package/dist/host/unit-face.d.ts +66 -0
- package/dist/host/unit-face.js +1 -0
- package/dist/host/unit-libs.d.ts +37 -0
- package/dist/host/unit-libs.js +1 -0
- package/dist/host/unit-readiness.d.ts +7 -0
- package/dist/host/unit-readiness.js +5 -0
- package/dist/host/unit-registry.d.ts +9 -0
- package/dist/host/unit-registry.js +33 -0
- package/dist/host/value-handoff.d.ts +16 -0
- package/dist/host/value-handoff.js +21 -0
- package/dist/host/version-range.d.ts +2 -0
- package/dist/host/version-range.js +73 -0
- package/dist/host/workspace-store.d.ts +19 -0
- package/dist/host/workspace-store.js +133 -0
- package/dist/host/write-result.d.ts +1 -0
- package/dist/host/write-result.js +18 -0
- package/dist/host.d.ts +19 -0
- package/dist/host.js +19 -0
- package/dist/i18n/en.d.ts +2 -0
- package/dist/i18n/en.js +162 -0
- package/dist/i18n/index.d.ts +172 -0
- package/dist/i18n/index.js +3 -0
- package/dist/i18n/merge.d.ts +6 -0
- package/dist/i18n/merge.js +40 -0
- package/dist/i18n/zh.d.ts +163 -0
- package/dist/i18n/zh.js +162 -0
- package/dist/index.d.ts +33 -0
- package/dist/index.js +33 -0
- package/dist/lib/blocker-action.d.ts +13 -0
- package/dist/lib/blocker-action.js +12 -0
- package/dist/lib/busy.d.ts +5 -0
- package/dist/lib/busy.js +7 -0
- package/dist/lib/react.d.ts +10 -0
- package/dist/lib/react.js +9 -0
- package/dist/lib/text.d.ts +4 -0
- package/dist/lib/text.js +48 -0
- package/dist/lib/ui.d.ts +11 -0
- package/dist/lib/ui.js +37 -0
- package/package.json +44 -0
- package/src/api/__tests__/blocker-table.test.ts +253 -0
- package/src/api/__tests__/facts.test.ts +274 -0
- package/src/api/__tests__/host-write.test.ts +55 -0
- package/src/api/__tests__/long-task.test.ts +52 -0
- package/src/api/__tests__/unit-board.test.ts +76 -0
- package/src/api/blocker-table.ts +155 -0
- package/src/api/blocker.ts +25 -0
- package/src/api/context-ref.ts +47 -0
- package/src/api/dsh-client.ts +176 -0
- package/src/api/dsh.ts +156 -0
- package/src/api/facts.ts +166 -0
- package/src/api/host-write.ts +27 -0
- package/src/api/long-task.ts +38 -0
- package/src/api/unit-board.ts +44 -0
- package/src/api/unit.ts +59 -0
- package/src/api/workbench-client.test.ts +92 -0
- package/src/api/workbench-client.ts +59 -0
- package/src/api/workbench-contract.ts +40 -0
- package/src/components/ApprovalDetail.tsx +57 -0
- package/src/components/CardHead.tsx +74 -0
- package/src/components/ChoiceGroup.tsx +38 -0
- package/src/components/LongTaskRow.tsx +65 -0
- package/src/components/MockStrip.tsx +40 -0
- package/src/components/ResourceStates.tsx +44 -0
- package/src/components/SaveBar.tsx +31 -0
- package/src/components/StateDot.tsx +31 -0
- package/src/components/board-module.ts +73 -0
- package/src/components/icons.tsx +232 -0
- package/src/config/constants.test.ts +41 -0
- package/src/config/constants.ts +52 -0
- package/src/config/storage.test.ts +82 -0
- package/src/config/storage.ts +25 -0
- package/src/host/__tests__/fakes.ts +116 -0
- package/src/host/__tests__/opencli-runtime.test.ts +46 -0
- package/src/host/cli-error.ts +77 -0
- package/src/host/data-root.ts +13 -0
- package/src/host/facts.ts +69 -0
- package/src/host/git.ts +99 -0
- package/src/host/http.ts +311 -0
- package/src/host/long-task.ts +235 -0
- package/src/host/opencli-runtime.ts +66 -0
- package/src/host/opencli.ts +210 -0
- package/src/host/plugin-source.ts +145 -0
- package/src/host/provision.ts +34 -0
- package/src/host/shell.ts +42 -0
- package/src/host/tool-approval.ts +243 -0
- package/src/host/tool-kit.ts +56 -0
- package/src/host/unit-face.ts +65 -0
- package/src/host/unit-libs.ts +41 -0
- package/src/host/value-handoff.ts +38 -0
- package/src/host/workspace-store.ts +161 -0
- package/src/host/write-result.ts +16 -0
- package/src/host.ts +19 -0
- package/src/i18n/en.ts +170 -0
- package/src/i18n/index.test.ts +15 -0
- package/src/i18n/index.ts +12 -0
- package/src/i18n/merge.test.ts +60 -0
- package/src/i18n/merge.ts +54 -0
- package/src/i18n/zh.test.ts +40 -0
- package/src/i18n/zh.ts +170 -0
- package/src/index.ts +33 -0
- package/src/lib/blocker-action.test.ts +40 -0
- package/src/lib/blocker-action.ts +28 -0
- package/src/lib/busy.test.ts +27 -0
- package/src/lib/busy.ts +11 -0
- package/src/lib/react.test.ts +26 -0
- package/src/lib/react.ts +16 -0
- package/src/lib/text.test.ts +92 -0
- package/src/lib/text.ts +46 -0
- package/src/lib/ui.test.ts +93 -0
- package/src/lib/ui.ts +38 -0
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import type { DshShell } from '../api/dsh.js';
|
|
2
|
+
import { OPENCLI_MAX_CONCURRENT, OPENCLI_TIMEOUT_MS } from '../config/constants.js';
|
|
3
|
+
import { parseJsonLoose, quoteShellArg, tailLines } from '../lib/text.js';
|
|
4
|
+
import { helpUrlOf, parseCliErrorEnvelope } from './cli-error';
|
|
5
|
+
import { runShell } from './shell';
|
|
6
|
+
|
|
7
|
+
const FORBIDDEN_CODES = new Set(['FORBIDDEN', 'PERMISSION_DENIED']);
|
|
8
|
+
|
|
9
|
+
const SITE_SESSION_ARGS = ['--site-session', 'persistent'] as const;
|
|
10
|
+
|
|
11
|
+
export class WorkbenchDataError extends Error {
|
|
12
|
+
readonly code: string;
|
|
13
|
+
readonly detail?: string;
|
|
14
|
+
readonly hint?: string;
|
|
15
|
+
readonly project?: unknown;
|
|
16
|
+
readonly helpUrl?: string;
|
|
17
|
+
|
|
18
|
+
constructor(
|
|
19
|
+
code: string,
|
|
20
|
+
message: string,
|
|
21
|
+
extra: { detail?: string; hint?: string; project?: unknown; helpUrl?: string } = {},
|
|
22
|
+
) {
|
|
23
|
+
super(message);
|
|
24
|
+
this.name = 'WorkbenchDataError';
|
|
25
|
+
this.code = code;
|
|
26
|
+
if (extra.detail !== undefined) this.detail = extra.detail;
|
|
27
|
+
if (extra.hint !== undefined) this.hint = extra.hint;
|
|
28
|
+
if (extra.project !== undefined) this.project = extra.project;
|
|
29
|
+
if (extra.helpUrl !== undefined) this.helpUrl = extra.helpUrl;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface OpencliResult {
|
|
34
|
+
readonly value: unknown;
|
|
35
|
+
readonly at: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type OpencliSession = 'persistent' | 'ephemeral';
|
|
39
|
+
|
|
40
|
+
export function opencliCommandLine(
|
|
41
|
+
command: string,
|
|
42
|
+
argv: readonly string[],
|
|
43
|
+
session: OpencliSession = 'persistent',
|
|
44
|
+
): string {
|
|
45
|
+
return [
|
|
46
|
+
quoteShellArg(command),
|
|
47
|
+
...argv.map(quoteShellArg),
|
|
48
|
+
...(session === 'persistent' ? SITE_SESSION_ARGS : []),
|
|
49
|
+
'-f',
|
|
50
|
+
'json',
|
|
51
|
+
].join(' ');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface Opencli {
|
|
55
|
+
json(
|
|
56
|
+
args: readonly string[],
|
|
57
|
+
options?: { key?: string; workdir?: string; session?: OpencliSession },
|
|
58
|
+
): Promise<OpencliResult>;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function createOpencli(options: {
|
|
62
|
+
shell: DshShell;
|
|
63
|
+
plugin: string;
|
|
64
|
+
logger?: { warn?(message: string): void } | undefined;
|
|
65
|
+
command?: string;
|
|
66
|
+
timeoutMs?: number;
|
|
67
|
+
maxConcurrent?: number;
|
|
68
|
+
}): Opencli {
|
|
69
|
+
const {
|
|
70
|
+
shell,
|
|
71
|
+
plugin,
|
|
72
|
+
logger,
|
|
73
|
+
command = 'opencli',
|
|
74
|
+
timeoutMs = OPENCLI_TIMEOUT_MS,
|
|
75
|
+
maxConcurrent = OPENCLI_MAX_CONCURRENT,
|
|
76
|
+
} = options;
|
|
77
|
+
|
|
78
|
+
const inflight = new Map<string, Promise<OpencliResult>>();
|
|
79
|
+
|
|
80
|
+
const MAX_CONCURRENT = maxConcurrent;
|
|
81
|
+
let running = 0;
|
|
82
|
+
const waiting: Array<() => void> = [];
|
|
83
|
+
|
|
84
|
+
async function acquire(): Promise<void> {
|
|
85
|
+
if (running < MAX_CONCURRENT) {
|
|
86
|
+
running += 1;
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
await new Promise<void>((resolve) => waiting.push(resolve));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function release(): void {
|
|
93
|
+
const next = waiting.shift();
|
|
94
|
+
if (next === undefined) running -= 1;
|
|
95
|
+
else next();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function runLimited<T>(task: () => Promise<T>): Promise<T> {
|
|
99
|
+
await acquire();
|
|
100
|
+
try {
|
|
101
|
+
return await task();
|
|
102
|
+
} finally {
|
|
103
|
+
release();
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function call(commandLine: string, workdir: string | undefined): Promise<unknown> {
|
|
108
|
+
let result;
|
|
109
|
+
try {
|
|
110
|
+
result = await runShell(shell, commandLine, {
|
|
111
|
+
...(workdir === undefined ? {} : { workdir }),
|
|
112
|
+
timeoutMs,
|
|
113
|
+
});
|
|
114
|
+
} catch (error) {
|
|
115
|
+
throw new WorkbenchDataError('cli-exec-failed', `执行 opencli 失败:${String(error)}`, {
|
|
116
|
+
hint: '确认 opencli 已安装且在 PATH 里(which opencli)。',
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (result.timedOut) {
|
|
121
|
+
throw new WorkbenchDataError(
|
|
122
|
+
'cli-timeout',
|
|
123
|
+
`opencli 超过 ${Math.round(timeoutMs / 1000)} 秒没有返回`,
|
|
124
|
+
{ hint: '无头浏览器可能没起来,重试一次通常就好。' },
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (result.exitCode !== 0) {
|
|
129
|
+
const detail = tailLines(result.stderr || result.stdout, 6);
|
|
130
|
+
const envelope = parseCliErrorEnvelope(result.stderr) ?? parseCliErrorEnvelope(result.stdout);
|
|
131
|
+
|
|
132
|
+
const url = envelope === undefined ? undefined : helpUrlOf(envelope);
|
|
133
|
+
if (envelope?.code === 'AUTH_REQUIRED') {
|
|
134
|
+
throw new WorkbenchDataError('auth-required', envelope.message, {
|
|
135
|
+
detail,
|
|
136
|
+
...(url === undefined ? {} : { helpUrl: url }),
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
if (envelope !== undefined && FORBIDDEN_CODES.has(envelope.code)) {
|
|
140
|
+
throw new WorkbenchDataError('permission-denied', envelope.message, {
|
|
141
|
+
detail,
|
|
142
|
+
...(url === undefined ? {} : { helpUrl: url }),
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
if (envelope !== undefined) {
|
|
146
|
+
throw new WorkbenchDataError('cli-command-failed', `${envelope.code}:${envelope.message}`, {
|
|
147
|
+
detail,
|
|
148
|
+
...(envelope.help === undefined ? {} : { hint: envelope.help }),
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
throw new WorkbenchDataError(
|
|
152
|
+
'cli-unclassified',
|
|
153
|
+
`opencli 这条命令没跑通(退出码 ${result.exitCode}),而且没有结构化错误码`,
|
|
154
|
+
{
|
|
155
|
+
detail,
|
|
156
|
+
hint: '多半是插件里没有这条命令(版本旧就会缺),也可能是命令自己失败了:先确认命令存在,再更新插件重试。',
|
|
157
|
+
},
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const parsed = parseJsonLoose(result.stdout);
|
|
162
|
+
if (parsed === undefined) {
|
|
163
|
+
throw new WorkbenchDataError('cli-bad-output', 'opencli 输出不是 JSON', {
|
|
164
|
+
detail: tailLines(result.stdout, 4),
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
return parsed;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function json(
|
|
171
|
+
args: readonly string[],
|
|
172
|
+
callOptions: { key?: string; workdir?: string; session?: OpencliSession } = {},
|
|
173
|
+
): Promise<OpencliResult> {
|
|
174
|
+
const { key, workdir } = callOptions;
|
|
175
|
+
const session = callOptions.session ?? 'persistent';
|
|
176
|
+
const callKey = key ?? args.map((arg) => String(arg)).join(' ');
|
|
177
|
+
|
|
178
|
+
const pending = inflight.get(callKey);
|
|
179
|
+
if (pending !== undefined) return pending;
|
|
180
|
+
|
|
181
|
+
const commandLine = opencliCommandLine(command, [plugin, ...args], session);
|
|
182
|
+
|
|
183
|
+
const once = async (): Promise<OpencliResult> => {
|
|
184
|
+
const value = await call(commandLine, workdir);
|
|
185
|
+
return { value, at: Date.now() };
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
const task = runLimited(async (): Promise<OpencliResult> => {
|
|
189
|
+
try {
|
|
190
|
+
return await once();
|
|
191
|
+
} catch (error) {
|
|
192
|
+
if (!(error instanceof WorkbenchDataError) || error.code !== 'auth-required') throw error;
|
|
193
|
+
return await once();
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
inflight.set(callKey, task);
|
|
198
|
+
try {
|
|
199
|
+
return await task;
|
|
200
|
+
} catch (error) {
|
|
201
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
202
|
+
logger?.warn?.(`[dsh-workbench] opencli 调用失败:${message}`);
|
|
203
|
+
throw error;
|
|
204
|
+
} finally {
|
|
205
|
+
inflight.delete(callKey);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return { json };
|
|
210
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import type { PluginSource } from '../api/unit.js';
|
|
2
|
+
import { quoteShellArg } from '../lib/text.js';
|
|
3
|
+
import { npmCacheDir, toolchainOpencliDir, vendorPeerLink, vendorPrefix } from '../host/opencli-runtime.js';
|
|
4
|
+
|
|
5
|
+
export type PluginSourceProblem =
|
|
6
|
+
| "not-declared"
|
|
7
|
+
| "env-missing"
|
|
8
|
+
| "malformed";
|
|
9
|
+
|
|
10
|
+
export interface PluginSourceCommand {
|
|
11
|
+
readonly line: string;
|
|
12
|
+
readonly optional?: true;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type ResolvedPluginSource =
|
|
16
|
+
| {
|
|
17
|
+
readonly ok: true;
|
|
18
|
+
readonly commands: readonly PluginSourceCommand[];
|
|
19
|
+
readonly line: string;
|
|
20
|
+
}
|
|
21
|
+
| {
|
|
22
|
+
readonly ok: false;
|
|
23
|
+
readonly problem: PluginSourceProblem;
|
|
24
|
+
readonly env?: string;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export interface PluginSourceOptions {
|
|
28
|
+
readonly command?: string;
|
|
29
|
+
readonly plugin?: string;
|
|
30
|
+
readonly version?: string;
|
|
31
|
+
readonly mode?: "install" | "update";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function mergeLines(commands: readonly PluginSourceCommand[]): string {
|
|
35
|
+
return commands
|
|
36
|
+
.map((item) =>
|
|
37
|
+
item.optional === true ? `(${item.line} || true)` : item.line,
|
|
38
|
+
)
|
|
39
|
+
.join(" && ");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function envValue(
|
|
43
|
+
env: Readonly<Record<string, string | undefined>>,
|
|
44
|
+
name: string,
|
|
45
|
+
): { readonly ok: true; readonly value: string } | { readonly ok: false } {
|
|
46
|
+
const value = env[name]?.trim();
|
|
47
|
+
return value === undefined || value === ""
|
|
48
|
+
? { ok: false }
|
|
49
|
+
: { ok: true, value };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function npmRegistryOf(
|
|
53
|
+
source: { readonly registryEnv: string; readonly registryDefault?: string },
|
|
54
|
+
env: Readonly<Record<string, string | undefined>>,
|
|
55
|
+
): { readonly ok: true; readonly value: string } | { readonly ok: false } {
|
|
56
|
+
const fromEnv = envValue(env, source.registryEnv);
|
|
57
|
+
if (fromEnv.ok) return fromEnv;
|
|
58
|
+
const fallback = source.registryDefault?.trim();
|
|
59
|
+
return fallback === undefined || fallback === ""
|
|
60
|
+
? { ok: false }
|
|
61
|
+
: { ok: true, value: fallback };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function installOne(command: string, spec: string): ResolvedPluginSource {
|
|
65
|
+
const line = `${quoteShellArg(command)} plugin install ${quoteShellArg(spec)}`;
|
|
66
|
+
return { ok: true, commands: [{ line }], line };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function npmSource(
|
|
70
|
+
source: {
|
|
71
|
+
readonly package: string;
|
|
72
|
+
readonly registryEnv: string;
|
|
73
|
+
readonly registryDefault?: string;
|
|
74
|
+
},
|
|
75
|
+
env: Readonly<Record<string, string | undefined>>,
|
|
76
|
+
options: PluginSourceOptions,
|
|
77
|
+
): ResolvedPluginSource {
|
|
78
|
+
const registry = npmRegistryOf(source, env);
|
|
79
|
+
if (!registry.ok)
|
|
80
|
+
return { ok: false, problem: "env-missing", env: source.registryEnv };
|
|
81
|
+
|
|
82
|
+
const name = source.package.trim();
|
|
83
|
+
if (name === "") return { ok: false, problem: "malformed" };
|
|
84
|
+
|
|
85
|
+
const {
|
|
86
|
+
command = "opencli",
|
|
87
|
+
plugin = "",
|
|
88
|
+
version = "latest",
|
|
89
|
+
mode = "install",
|
|
90
|
+
} = options;
|
|
91
|
+
const prefix = vendorPrefix();
|
|
92
|
+
const dirInPrefix = `${prefix}/node_modules/${name}`;
|
|
93
|
+
const peer = vendorPeerLink();
|
|
94
|
+
|
|
95
|
+
const commands: PluginSourceCommand[] = [
|
|
96
|
+
{
|
|
97
|
+
line: [
|
|
98
|
+
"npm i",
|
|
99
|
+
`--prefix ${quoteShellArg(prefix)}`,
|
|
100
|
+
quoteShellArg(`${name}@${version}`),
|
|
101
|
+
`--registry ${quoteShellArg(registry.value)}`,
|
|
102
|
+
`--cache ${quoteShellArg(npmCacheDir())}`,
|
|
103
|
+
].join(" "),
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
line: `rm -rf ${quoteShellArg(peer.dir)} && mkdir -p ${quoteShellArg(peer.parent)} && ln -s ${quoteShellArg(toolchainOpencliDir())} ${quoteShellArg(peer.dir)}`,
|
|
107
|
+
},
|
|
108
|
+
];
|
|
109
|
+
|
|
110
|
+
if (mode === "install") {
|
|
111
|
+
if (plugin !== "") {
|
|
112
|
+
commands.push({
|
|
113
|
+
line: `${quoteShellArg(command)} plugin uninstall ${quoteShellArg(plugin)}`,
|
|
114
|
+
optional: true,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
commands.push({
|
|
118
|
+
line: `NPM_CONFIG_REGISTRY=${quoteShellArg(registry.value)} ${quoteShellArg(command)} plugin install ${quoteShellArg(dirInPrefix)}`,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return { ok: true, commands, line: mergeLines(commands) };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function resolvePluginSource(
|
|
126
|
+
source: PluginSource | undefined,
|
|
127
|
+
env: Readonly<Record<string, string | undefined>>,
|
|
128
|
+
options: PluginSourceOptions = {},
|
|
129
|
+
): ResolvedPluginSource {
|
|
130
|
+
if (source === undefined) return { ok: false, problem: "not-declared" };
|
|
131
|
+
|
|
132
|
+
const command = options.command ?? "opencli";
|
|
133
|
+
if (source.kind === "git") {
|
|
134
|
+
const url = source.url.trim();
|
|
135
|
+
return url === ""
|
|
136
|
+
? { ok: false, problem: "malformed" }
|
|
137
|
+
: installOne(command, url);
|
|
138
|
+
}
|
|
139
|
+
if (source.kind === "npm") return npmSource(source, env, options);
|
|
140
|
+
|
|
141
|
+
const value = envValue(env, source.env);
|
|
142
|
+
return value.ok
|
|
143
|
+
? installOne(command, value.value)
|
|
144
|
+
: { ok: false, problem: "env-missing", env: source.env };
|
|
145
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { ProvisionAction } from '../api/workbench-contract.js';
|
|
2
|
+
|
|
3
|
+
export type { ProvisionAction };
|
|
4
|
+
|
|
5
|
+
export const PROVISION_ACTIONS = [
|
|
6
|
+
'install',
|
|
7
|
+
'update',
|
|
8
|
+
'uninstall',
|
|
9
|
+
'update-dependency',
|
|
10
|
+
] as const satisfies readonly ProvisionAction[];
|
|
11
|
+
|
|
12
|
+
export const DEPENDENCY_ACTIONS = ['update-dependency'] as const satisfies readonly ProvisionAction[];
|
|
13
|
+
|
|
14
|
+
export function isDependencyAction(
|
|
15
|
+
action: ProvisionAction,
|
|
16
|
+
): action is (typeof DEPENDENCY_ACTIONS)[number] {
|
|
17
|
+
return (DEPENDENCY_ACTIONS as readonly ProvisionAction[]).includes(action);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
type MissingAction = Exclude<ProvisionAction, (typeof PROVISION_ACTIONS)[number]>;
|
|
21
|
+
|
|
22
|
+
const ALL_ACTIONS_COVERED: MissingAction extends never ? true : never = true;
|
|
23
|
+
void ALL_ACTIONS_COVERED;
|
|
24
|
+
|
|
25
|
+
export interface ProvisionRequest {
|
|
26
|
+
readonly action: ProvisionAction;
|
|
27
|
+
readonly unit?: string;
|
|
28
|
+
readonly id?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface ProvisionHandler {
|
|
32
|
+
handle(request: ProvisionRequest): Promise<unknown> | unknown;
|
|
33
|
+
affectedUnits?(request: ProvisionRequest): readonly string[];
|
|
34
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { DshShell } from '../api/dsh.js';
|
|
2
|
+
import { GIT_TIMEOUT_MS } from '../config/constants.js';
|
|
3
|
+
|
|
4
|
+
export interface ShellResult {
|
|
5
|
+
readonly exitCode: number | null;
|
|
6
|
+
readonly timedOut: boolean;
|
|
7
|
+
readonly aborted: boolean;
|
|
8
|
+
readonly stdout: string;
|
|
9
|
+
readonly stderr: string;
|
|
10
|
+
readonly truncated: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const DEFAULT_MAX_BYTES = 8 * 1024 * 1024;
|
|
14
|
+
|
|
15
|
+
export async function runShell(
|
|
16
|
+
shell: DshShell,
|
|
17
|
+
command: string,
|
|
18
|
+
options: { workdir?: string; timeoutMs?: number; signal?: AbortSignal } = {},
|
|
19
|
+
): Promise<ShellResult> {
|
|
20
|
+
const { workdir, timeoutMs = GIT_TIMEOUT_MS, signal } = options;
|
|
21
|
+
|
|
22
|
+
const spec = shell.resolve({
|
|
23
|
+
command,
|
|
24
|
+
...(workdir === undefined ? {} : { workdir }),
|
|
25
|
+
timeoutMs,
|
|
26
|
+
onExpiry: 'kill',
|
|
27
|
+
stdoutMaxBytes: DEFAULT_MAX_BYTES,
|
|
28
|
+
...(signal === undefined ? {} : { signal }),
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const execution = await shell.execute(spec);
|
|
32
|
+
const result = await execution.result();
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
exitCode: result.exitCode,
|
|
36
|
+
timedOut: result.timedOut === true,
|
|
37
|
+
aborted: result.aborted === true,
|
|
38
|
+
stdout: result.stdout.text,
|
|
39
|
+
stderr: result.stderr.text,
|
|
40
|
+
truncated: result.stdout.truncated || result.stderr.truncated,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
export type AskDecision =
|
|
2
|
+
| {
|
|
3
|
+
readonly kind: 'ask';
|
|
4
|
+
readonly reason: string;
|
|
5
|
+
readonly displayReason: { readonly en: string; readonly zh: string };
|
|
6
|
+
}
|
|
7
|
+
| {
|
|
8
|
+
readonly kind: 'deny';
|
|
9
|
+
readonly reason: string;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export interface ApprovalDetail {
|
|
13
|
+
readonly en: string;
|
|
14
|
+
readonly zh: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type ApprovalFacts = ApprovalDetail | { readonly blocked: string };
|
|
18
|
+
|
|
19
|
+
export interface ApprovalCall {
|
|
20
|
+
readonly name?: string | undefined;
|
|
21
|
+
readonly arguments?: unknown;
|
|
22
|
+
readonly workspace?: string | undefined;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface ApprovalContext {
|
|
26
|
+
readonly titleOf?: ((id: string) => string | undefined) | undefined;
|
|
27
|
+
readonly writes?: boolean | undefined;
|
|
28
|
+
readonly detailOf?:
|
|
29
|
+
| ((call: ApprovalCall) => ApprovalFacts | undefined | Promise<ApprovalFacts | undefined>)
|
|
30
|
+
| undefined;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function compose(
|
|
34
|
+
detail: ApprovalDetail | undefined,
|
|
35
|
+
texts: { readonly generic: ApprovalDetail; readonly question: ApprovalDetail },
|
|
36
|
+
): ApprovalDetail {
|
|
37
|
+
if (detail === undefined || typeof detail.zh !== 'string' || detail.zh.trim() === '') return texts.generic;
|
|
38
|
+
return { en: `${detail.en} — ${texts.question.en}`, zh: `${detail.zh} —— ${texts.question.zh}` };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function actionOf(call: ApprovalCall): { action?: string; id?: string } {
|
|
42
|
+
const raw = call.arguments;
|
|
43
|
+
if (typeof raw !== 'object' || raw === null) return {};
|
|
44
|
+
const value = raw as { action?: unknown; id?: unknown; unit?: unknown };
|
|
45
|
+
return {
|
|
46
|
+
...(typeof value.action === 'string' ? { action: value.action } : {}),
|
|
47
|
+
...(typeof value.id === 'string' ? { id: value.id } : typeof value.unit === 'string' ? { id: value.unit } : {}),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const READ_ONLY_ACTIONS = new Set(['list', '']);
|
|
52
|
+
|
|
53
|
+
interface AskSpec {
|
|
54
|
+
readonly reason: string;
|
|
55
|
+
readonly generic: ApprovalDetail;
|
|
56
|
+
readonly question: ApprovalDetail;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
type CallEffect =
|
|
60
|
+
| { readonly kind: 'ask'; readonly spec: AskSpec }
|
|
61
|
+
| { readonly kind: 'read' }
|
|
62
|
+
| { readonly kind: 'unknown' };
|
|
63
|
+
|
|
64
|
+
const READ: CallEffect = { kind: 'read' };
|
|
65
|
+
const UNKNOWN: CallEffect = { kind: 'unknown' };
|
|
66
|
+
|
|
67
|
+
function ask(spec: AskSpec): CallEffect {
|
|
68
|
+
return { kind: 'ask', spec };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function confirmed(call: ApprovalCall): boolean | undefined {
|
|
72
|
+
const raw = call.arguments;
|
|
73
|
+
if (typeof raw !== 'object' || raw === null) return undefined;
|
|
74
|
+
const value = (raw as { confirm?: unknown }).confirm;
|
|
75
|
+
return typeof value === 'boolean' ? value : undefined;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function effectOf(call: ApprovalCall, context: ApprovalContext): CallEffect {
|
|
79
|
+
const name = call.name ?? '';
|
|
80
|
+
const { action, id } = actionOf(call);
|
|
81
|
+
const readAction = action !== undefined && action !== '' && READ_ONLY_ACTIONS.has(action);
|
|
82
|
+
|
|
83
|
+
const what = (): string => {
|
|
84
|
+
if (id === undefined || id === '') return '一个模块';
|
|
85
|
+
return `「${context.titleOf?.(id) ?? id}」`;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
if (name === 'workbench_cmdb_release') {
|
|
89
|
+
if (confirmed(call) !== true) return READ;
|
|
90
|
+
return ask({
|
|
91
|
+
reason: 'confirm=true releases a version to a real environment',
|
|
92
|
+
generic: {
|
|
93
|
+
en: 'The agent wants to deploy a version for real (confirm: true) — it deploys on the platform (non-production). Allow?',
|
|
94
|
+
zh: 'agent 想在非生产环境上**真的部署一个版本**(confirm: true)—— 会在平台上部署。允许吗?',
|
|
95
|
+
},
|
|
96
|
+
question: {
|
|
97
|
+
en: 'it deploys on the platform for real (non-production). Allow?',
|
|
98
|
+
zh: '会在平台上真的部署一次(非生产环境)。允许吗?',
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (name === 'workbench_coding_issue_comment' || name === 'workbench_coding_issue_transfer') {
|
|
104
|
+
if (confirmed(call) !== true) return READ;
|
|
105
|
+
const comment = name === 'workbench_coding_issue_comment';
|
|
106
|
+
return ask({
|
|
107
|
+
reason: comment
|
|
108
|
+
? 'confirm=true posts a comment on the platform'
|
|
109
|
+
: 'confirm=true moves an issue to another status on the platform',
|
|
110
|
+
generic: comment
|
|
111
|
+
? {
|
|
112
|
+
en: 'The agent wants to post a comment on the platform for real (confirm: true) — it writes on the platform. Allow?',
|
|
113
|
+
zh: 'agent 想**真的给一条缺陷加评论**(confirm: true)—— 会写平台数据。允许吗?',
|
|
114
|
+
}
|
|
115
|
+
: {
|
|
116
|
+
en: 'The agent wants to move an issue to another status for real (confirm: true) — it writes on the platform. Allow?',
|
|
117
|
+
zh: 'agent 想**真的把一条缺陷流转到别的状态**(confirm: true)—— 会写平台数据。允许吗?',
|
|
118
|
+
},
|
|
119
|
+
question: comment
|
|
120
|
+
? {
|
|
121
|
+
en: 'it posts that comment on the platform for real. Allow?',
|
|
122
|
+
zh: '会在平台上真的发表这条评论。允许吗?',
|
|
123
|
+
}
|
|
124
|
+
: {
|
|
125
|
+
en: 'it changes that issue status on the platform for real. Allow?',
|
|
126
|
+
zh: '会在平台上真的改这条缺陷的状态。允许吗?',
|
|
127
|
+
},
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (name === 'workbench_modules') {
|
|
132
|
+
if (readAction) return READ;
|
|
133
|
+
if (action === 'update-dependency') {
|
|
134
|
+
return ask({
|
|
135
|
+
reason: `workbench_modules update-dependency ${id ?? ''} upgrades a shared dependency on this machine`,
|
|
136
|
+
generic: {
|
|
137
|
+
en: `The agent wants to upgrade the shared dependency ${what()} (it changes what is installed on this machine). Allow?`,
|
|
138
|
+
zh: `agent 想升级共用依赖 ${what()}(会改这台机器上装的东西)。允许吗?`,
|
|
139
|
+
},
|
|
140
|
+
question: {
|
|
141
|
+
en: 'it upgrades that shared dependency on this machine. Allow?',
|
|
142
|
+
zh: `会升级共用依赖 ${what()}。允许吗?`,
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
const verb = action === 'install' ? '装' : action === 'uninstall' ? '卸载' : action === 'update' ? '更新' : '装 / 更新 / 卸载';
|
|
147
|
+
return ask({
|
|
148
|
+
reason: `workbench_modules ${action ?? '(unknown action)'} ${id ?? ''} changes plugins on this machine`.trim(),
|
|
149
|
+
generic: {
|
|
150
|
+
en: `The agent wants to ${verb === '装' ? 'install' : verb === '卸载' ? 'remove' : verb === '更新' ? 'update' : 'install/update/remove'} the module ${what()} (it changes plugins on this machine). Allow?`,
|
|
151
|
+
zh: `agent 想${verb}模块 ${what()}(会改这台机器上的插件)。允许吗?`,
|
|
152
|
+
},
|
|
153
|
+
question: {
|
|
154
|
+
en: 'it changes plugins on this machine. Allow?',
|
|
155
|
+
zh: '会改这台机器上的插件。允许吗?',
|
|
156
|
+
},
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (name !== 'workbench_panels') return UNKNOWN;
|
|
161
|
+
|
|
162
|
+
if (readAction) return READ;
|
|
163
|
+
|
|
164
|
+
if (action === 'hide') {
|
|
165
|
+
return ask({
|
|
166
|
+
reason: 'workbench_panels hide removes a module from the panel',
|
|
167
|
+
generic: {
|
|
168
|
+
en: `The agent wants to remove ${what()} from this project's panel (its agent tools go offline here too). Allow?`,
|
|
169
|
+
zh: `agent 想把 ${what()} 从这个项目的面板上收起来(这个项目里它的 agent 工具也一起下线)。允许吗?`,
|
|
170
|
+
},
|
|
171
|
+
question: {
|
|
172
|
+
en: 'it takes that module off this panel (its agent tools go offline here too). Allow?',
|
|
173
|
+
zh: `${what()}会从这个项目的面板上收起来(这个项目里它的 agent 工具也一起下线)。允许吗?`,
|
|
174
|
+
},
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
if (action === 'move') {
|
|
178
|
+
return ask({
|
|
179
|
+
reason: 'workbench_panels move reorders the panel',
|
|
180
|
+
generic: {
|
|
181
|
+
en: `The agent wants to reorder this project's panel (put ${what()} first). Allow?`,
|
|
182
|
+
zh: `agent 想调整这个项目面板的顺序(把 ${what()} 放前面)。允许吗?`,
|
|
183
|
+
},
|
|
184
|
+
question: {
|
|
185
|
+
en: `it reorders this panel (${what()} goes first). Allow?`,
|
|
186
|
+
zh: `会调整这个项目面板的顺序(${what()}放前面)。允许吗?`,
|
|
187
|
+
},
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
if (action === 'reset') {
|
|
191
|
+
return ask({
|
|
192
|
+
reason: 'workbench_panels reset clears the panel',
|
|
193
|
+
generic: {
|
|
194
|
+
en: "The agent wants to clear this project's panel (every module goes offline here). Allow?",
|
|
195
|
+
zh: 'agent 想清空这个项目的面板(所有模块在这个项目里都下线)。允许吗?',
|
|
196
|
+
},
|
|
197
|
+
question: {
|
|
198
|
+
en: 'it clears this panel (every module goes offline here). Allow?',
|
|
199
|
+
zh: '会清空这个项目的面板(所有模块在这个项目里都下线)。允许吗?',
|
|
200
|
+
},
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
return ask({
|
|
204
|
+
reason: `workbench_panels ${action ?? '(unknown action)'} changes the user's panel`,
|
|
205
|
+
generic: {
|
|
206
|
+
en: `The agent wants to add ${what()} to this project's panel. Allow?`,
|
|
207
|
+
zh: `agent 想把 ${what()} 加到**这个项目**的面板上。允许吗?`,
|
|
208
|
+
},
|
|
209
|
+
question: {
|
|
210
|
+
en: `it adds ${what()} to this project's panel. Allow?`,
|
|
211
|
+
zh: `会把 ${what()} 加到**这个项目**的面板上。允许吗?`,
|
|
212
|
+
},
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export async function approvalFor(
|
|
217
|
+
call: ApprovalCall,
|
|
218
|
+
context: ApprovalContext = {},
|
|
219
|
+
): Promise<AskDecision | undefined> {
|
|
220
|
+
const effect = effectOf(call, context);
|
|
221
|
+
if (effect.kind === 'read') return undefined;
|
|
222
|
+
if (effect.kind === 'unknown') {
|
|
223
|
+
if (context.writes !== true) return undefined;
|
|
224
|
+
return {
|
|
225
|
+
kind: 'deny',
|
|
226
|
+
reason:
|
|
227
|
+
`${call.name ?? '这把工具'} 在清单里声明会写东西,但审批表没认领这次调用 —— 停下:` +
|
|
228
|
+
'给它在 tool-approval.ts 里补一条审批(含"这次会发生什么"),别让写静默过去',
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
const { spec } = effect;
|
|
232
|
+
|
|
233
|
+
let facts: ApprovalFacts | undefined;
|
|
234
|
+
try {
|
|
235
|
+
facts = await context.detailOf?.(call);
|
|
236
|
+
} catch (error) {
|
|
237
|
+
const why = error instanceof Error ? error.message : String(error);
|
|
238
|
+
return { kind: 'deny', reason: `拿不到"这次会发生什么",已经停下:${why}` };
|
|
239
|
+
}
|
|
240
|
+
if (facts !== undefined && 'blocked' in facts) return { kind: 'deny', reason: facts.blocked };
|
|
241
|
+
|
|
242
|
+
return { kind: 'ask', reason: spec.reason, displayReason: compose(facts, spec) };
|
|
243
|
+
}
|