@dsh-workbench/adapter-probe 0.0.0-stage → 1.0.0-beta.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.
@@ -0,0 +1,372 @@
1
+ import { INSTALL_TIMEOUT_MS, OPENCLI_TIMEOUT_MS, PREFLIGHT_FRESH_MS, WARM_TIMEOUT_MS } from '@dsh-workbench/shared/host';
2
+ import { tailLines } from '@dsh-workbench/shared/host';
3
+ import { opencliCommandLine } from '@dsh-workbench/adapter-runtime';
4
+ import { runShell } from '@dsh-workbench/shared/host';
5
+ import { DIY_UPGRADE_COMMAND } from './diy-preset-health.js';
6
+ import { opencliInstallCommand } from './install.js';
7
+ import { desktopPnpmPath, LOCAL_NPM_VERSION, LOCAL_PNPM_VERSION, } from './toolchain.js';
8
+ export function classifyFailure(text) {
9
+ const lower = text.toLowerCase();
10
+ if (/eacces|eperm|permission denied|operation not permitted/.test(lower))
11
+ return 'permission';
12
+ if (/enotfound|etimedout|econnrefused|econnreset|network|proxy|fetch failed/.test(lower))
13
+ return 'network';
14
+ if (/command not found|not found|no such file/.test(lower))
15
+ return 'missing';
16
+ return 'unknown';
17
+ }
18
+ async function toolVersion(shell, line) {
19
+ try {
20
+ const result = await runShell(shell, line, { timeoutMs: 10_000 });
21
+ const text = (result.stdout || result.stderr).trim().split('\n')[0] ?? '';
22
+ return { ok: result.exitCode === 0 && !result.timedOut, text };
23
+ }
24
+ catch {
25
+ return { ok: false, text: '' };
26
+ }
27
+ }
28
+ export async function probeDesktopToolchain(shell) {
29
+ const [node, npm, pnpm] = await Promise.all([
30
+ toolVersion(shell, 'node --version'),
31
+ toolVersion(shell, 'npm --version'),
32
+ toolVersion(shell, 'pnpm --version'),
33
+ ]);
34
+ const nodeMissing = !node.ok;
35
+ const npmMissing = !npm.ok;
36
+ const hasBundledPnpm = desktopPnpmPath() !== undefined;
37
+ const pnpmMajor = /^v?(\d+)/.exec(pnpm.text)?.[1];
38
+ const pnpmProblem = !pnpm.ok || pnpmMajor !== '10';
39
+ const parts = [
40
+ nodeMissing ? 'node 不在 PATH(Desktop 自带 Node 的 shim 没生效)' : `node ${node.text}(Desktop 自带)`,
41
+ npmMissing
42
+ ? hasBundledPnpm
43
+ ? 'npm 缺(opencli 安装走 pnpm;插件安装/模块库仍需 npm)'
44
+ : 'npm 缺(装不了 opencli:bundled pnpm 也没找到)'
45
+ : `npm ${npm.text}`,
46
+ pnpmProblem
47
+ ? pnpm.ok
48
+ ? `pnpm ${pnpm.text}(工坊按 10 方言)`
49
+ : 'pnpm 缺(只影响工坊 build/install)'
50
+ : `pnpm ${pnpm.text}`,
51
+ ];
52
+ return { ok: !nodeMissing, detail: parts.join(' · '), nodeMissing, npmMissing, pnpmProblem };
53
+ }
54
+ export function readBridgeReport(stdout) {
55
+ const text = String(stdout ?? '');
56
+ const ok = /^\[OK\] Daemon:/m.test(text) && /^\[OK\] Extension:/m.test(text) && /^\[OK\] Connectivity:/m.test(text);
57
+ if (ok) {
58
+ const good = text.split('\n').find((line) => line.trim() === 'Everything looks good!');
59
+ return { ok: true, issues: [], detail: good?.trim() ?? tailLines(text, 1) };
60
+ }
61
+ const found = new Set();
62
+ if (!/^\[OK\] Daemon:/m.test(text))
63
+ found.add('daemon');
64
+ if (!/^\[OK\] Extension:/m.test(text))
65
+ found.add('extension');
66
+ if (/Multiple Chrome profiles are connected/i.test(text))
67
+ found.add('profile');
68
+ if (/Default browser profile is stale|Selected browser profile is not connected/i.test(text)) {
69
+ found.add('stale');
70
+ }
71
+ if (found.has('profile') || found.has('stale'))
72
+ found.delete('extension');
73
+ if (found.size === 0)
74
+ found.add('unknown');
75
+ const order = ['profile', 'stale', 'daemon', 'extension', 'unknown'];
76
+ return { ok: false, issues: order.filter((issue) => found.has(issue)), detail: issuesSection(text) };
77
+ }
78
+ function issuesSection(text) {
79
+ const lines = text.split('\n');
80
+ const start = lines.findIndex((line) => /^Issues:/.test(line.trim()));
81
+ if (start < 0)
82
+ return tailLines(text, 6);
83
+ return lines.slice(start).join('\n').trim();
84
+ }
85
+ function bridgeFixCommand(command, issues) {
86
+ if (issues.includes('profile') || issues.includes('stale'))
87
+ return `${command} profile list`;
88
+ if (issues.includes('daemon'))
89
+ return `${command} daemon restart`;
90
+ return undefined;
91
+ }
92
+ export const OPENCLI_INSTALL_COMMAND = opencliInstallCommand();
93
+ const WARMABLE_BRIDGE_ISSUES = ['daemon', 'stale'];
94
+ export function canWarm(bridge) {
95
+ return bridge.ok || bridge.issues.every((issue) => WARMABLE_BRIDGE_ISSUES.includes(issue));
96
+ }
97
+ function step(id) {
98
+ return { id, status: 'pending' };
99
+ }
100
+ export function createPreflight(options) {
101
+ const { shell, command = 'opencli', logger } = options;
102
+ const installCommand = options.installCommand ?? OPENCLI_INSTALL_COMMAND;
103
+ const warmReads = () => options.warmReads?.() ?? [];
104
+ const steps = () => [
105
+ ...(options.probeToolchain === undefined ? [] : [step('toolchain')]),
106
+ step('opencli'),
107
+ step('bridge'),
108
+ ...(options.probeDiy === undefined ? [] : [step('diy')]),
109
+ ...(warmReads().length === 0 ? [] : [step('warm')]),
110
+ ];
111
+ let current = {
112
+ phase: 'idle',
113
+ steps: steps(),
114
+ at: Date.now(),
115
+ ...(options.desktopBrowser === undefined ? {} : { canWakeBrowser: true }),
116
+ };
117
+ let running = null;
118
+ function set(next) {
119
+ current = { ...current, ...next, at: Date.now() };
120
+ return current;
121
+ }
122
+ function patchStep(id, patch) {
123
+ return set({ steps: current.steps.map((item) => (item.id === id ? { ...item, ...patch } : item)) });
124
+ }
125
+ async function exec(commandLine, timeoutMs) {
126
+ try {
127
+ const result = await runShell(shell, commandLine, { timeoutMs });
128
+ if (result.timedOut) {
129
+ return { kind: 'error', detail: `超时(${String(timeoutMs)} ms):${commandLine}` };
130
+ }
131
+ if (result.exitCode !== 0) {
132
+ return { kind: 'nonzero', detail: tailLines(result.stderr || result.stdout, 4) };
133
+ }
134
+ return { kind: 'ok', stdout: result.stdout };
135
+ }
136
+ catch (error) {
137
+ return { kind: 'error', detail: String(error) };
138
+ }
139
+ }
140
+ async function probeVersion() {
141
+ const result = await exec(`${command} --version`, OPENCLI_TIMEOUT_MS);
142
+ if (result.kind === 'ok')
143
+ return { ok: true, missing: false, detail: result.stdout.trim().split('\n')[0] ?? '' };
144
+ return { ok: false, missing: result.kind === 'nonzero', detail: result.detail };
145
+ }
146
+ async function probeBridge() {
147
+ const result = await exec(`${command} doctor`, OPENCLI_TIMEOUT_MS);
148
+ if (result.kind !== 'ok')
149
+ return { ok: false, detail: result.detail, issues: ['unknown'] };
150
+ const report = readBridgeReport(result.stdout);
151
+ if (report.ok)
152
+ return report;
153
+ return { ...report, command: bridgeFixCommand(command, report.issues) };
154
+ }
155
+ async function installOpencli() {
156
+ if (options.install !== undefined)
157
+ return options.install();
158
+ const result = await exec(installCommand, INSTALL_TIMEOUT_MS);
159
+ if (result.kind !== 'ok')
160
+ return { ok: false, detail: result.detail };
161
+ return { ok: true, detail: tailLines(result.stdout, 1) };
162
+ }
163
+ async function warmSites(bridge) {
164
+ const reads = warmReads();
165
+ if (reads.length === 0)
166
+ return bridge;
167
+ if (!canWarm(bridge)) {
168
+ patchStep('warm', { status: 'warn', detail: '' });
169
+ return bridge;
170
+ }
171
+ patchStep('warm', {
172
+ status: 'running',
173
+ action: `${command} <套件自己声明的那条读命令> --site-session persistent -f json`,
174
+ });
175
+ const results = await Promise.all(reads.map((read) => exec(opencliCommandLine(command, read), WARM_TIMEOUT_MS)));
176
+ const failed = reads.filter((_, index) => results[index]?.kind !== 'ok');
177
+ patchStep('warm', {
178
+ status: failed.length === 0 ? 'ok' : 'warn',
179
+ detail: failed.length === 0 ? String(reads.length) : failed.map((read) => read.join(' ')).join(' '),
180
+ action: undefined,
181
+ });
182
+ if (bridge.ok)
183
+ return bridge;
184
+ const again = await probeBridge();
185
+ patchStep('bridge', {
186
+ status: again.ok ? 'ok' : 'warn',
187
+ detail: again.detail,
188
+ action: undefined,
189
+ command: again.command,
190
+ });
191
+ return again;
192
+ }
193
+ async function settleDiy(started) {
194
+ if (started === undefined)
195
+ return undefined;
196
+ const verdict = await started.catch(() => undefined);
197
+ if (verdict === undefined) {
198
+ set({ steps: current.steps.filter((item) => item.id !== 'diy') });
199
+ return undefined;
200
+ }
201
+ if (verdict.ok) {
202
+ patchStep('diy', { status: 'ok', detail: verdict.detail });
203
+ return undefined;
204
+ }
205
+ patchStep('diy', { status: 'warn', detail: verdict.detail, command: DIY_UPGRADE_COMMAND });
206
+ return { rows: verdict.rows, detail: verdict.detail, command: DIY_UPGRADE_COMMAND };
207
+ }
208
+ async function doRun() {
209
+ set({
210
+ phase: 'checking',
211
+ error: undefined,
212
+ failure: undefined,
213
+ guidance: undefined,
214
+ bridgeIssues: undefined,
215
+ diyGap: undefined,
216
+ steps: steps(),
217
+ });
218
+ const toolchainStarted = options.probeToolchain?.();
219
+ if (toolchainStarted !== undefined) {
220
+ patchStep('toolchain', { status: 'running', action: 'node --version · npm --version · pnpm --version' });
221
+ }
222
+ let toolchain = toolchainStarted === undefined
223
+ ? undefined
224
+ : await toolchainStarted.catch(() => ({
225
+ ok: false,
226
+ detail: '工具链探测没跑起来',
227
+ nodeMissing: true,
228
+ npmMissing: true,
229
+ pnpmProblem: true,
230
+ }));
231
+ if (toolchain !== undefined && toolchain.ok && toolchain.npmMissing && options.provisionNpm !== undefined) {
232
+ patchStep('toolchain', { status: 'running', action: `装本地 npm@${LOCAL_NPM_VERSION}` });
233
+ const provisioned = await options.provisionNpm().catch((error) => ({
234
+ ok: false,
235
+ detail: String(error),
236
+ }));
237
+ if (provisioned.ok) {
238
+ const reprobed = await options.probeToolchain?.();
239
+ if (reprobed !== undefined)
240
+ toolchain = reprobed;
241
+ }
242
+ else {
243
+ toolchain = { ...toolchain, detail: `${toolchain.detail};本地 npm 没装成:${provisioned.detail}` };
244
+ }
245
+ }
246
+ if (toolchain !== undefined && toolchain.ok && toolchain.pnpmProblem && options.provisionPnpm !== undefined) {
247
+ patchStep('toolchain', { status: 'running', action: `装本地 pnpm@${LOCAL_PNPM_VERSION}` });
248
+ const provisioned = await options.provisionPnpm().catch((error) => ({
249
+ ok: false,
250
+ detail: String(error),
251
+ }));
252
+ if (provisioned.ok) {
253
+ const reprobed = await options.probeToolchain?.();
254
+ if (reprobed !== undefined)
255
+ toolchain = reprobed;
256
+ }
257
+ else {
258
+ toolchain = { ...toolchain, detail: `${toolchain.detail};本地 pnpm 10 没装成:${provisioned.detail}` };
259
+ }
260
+ }
261
+ if (toolchain !== undefined) {
262
+ patchStep('toolchain', {
263
+ status: toolchain.ok ? (toolchain.npmMissing || toolchain.pnpmProblem ? 'warn' : 'ok') : 'failed',
264
+ detail: toolchain.detail,
265
+ action: undefined,
266
+ });
267
+ if (!toolchain.ok) {
268
+ return set({
269
+ phase: 'failed',
270
+ error: toolchain.detail,
271
+ failure: 'missing',
272
+ guidance: 'install',
273
+ });
274
+ }
275
+ }
276
+ patchStep('opencli', { status: 'running', action: `${command} --version` });
277
+ const diyStarted = options.probeDiy?.();
278
+ if (diyStarted !== undefined)
279
+ patchStep('diy', { status: 'running' });
280
+ let version = await probeVersion();
281
+ if (!version.ok && !version.missing) {
282
+ patchStep('opencli', { status: 'failed', detail: version.detail, action: undefined });
283
+ return set({
284
+ phase: 'failed',
285
+ error: version.detail,
286
+ failure: classifyFailure(version.detail),
287
+ guidance: 'shell',
288
+ });
289
+ }
290
+ if (!version.ok) {
291
+ if (toolchain?.npmMissing === true && installCommand.trim().startsWith('npm ')) {
292
+ patchStep('opencli', { status: 'failed', detail: '没有 npm:装不了 opencli(只影响 opencli 安装)' });
293
+ return set({
294
+ phase: 'failed',
295
+ error: '没有 npm:装不了 opencli(只影响 opencli 安装)',
296
+ failure: 'missing',
297
+ guidance: 'install',
298
+ });
299
+ }
300
+ set({ phase: 'installing' });
301
+ patchStep('opencli', { status: 'running', action: installCommand, command: installCommand });
302
+ const installed = await installOpencli();
303
+ if (!installed.ok) {
304
+ patchStep('opencli', {
305
+ status: 'failed',
306
+ detail: installed.detail,
307
+ command: installCommand,
308
+ });
309
+ return set({
310
+ phase: 'failed',
311
+ error: installed.detail,
312
+ failure: classifyFailure(installed.detail),
313
+ guidance: 'install',
314
+ });
315
+ }
316
+ patchStep('opencli', { status: 'running', action: `${command} --version` });
317
+ version = await probeVersion();
318
+ if (!version.ok) {
319
+ patchStep('opencli', { status: 'failed', detail: version.detail, command: installCommand });
320
+ return set({
321
+ phase: 'failed',
322
+ error: version.detail,
323
+ failure: classifyFailure(version.detail),
324
+ guidance: 'path',
325
+ });
326
+ }
327
+ }
328
+ patchStep('opencli', { status: 'ok', detail: version.detail, action: undefined });
329
+ patchStep('bridge', { status: 'running', action: `${command} doctor` });
330
+ const bridge = await probeBridge();
331
+ if (options.desktopBrowser !== undefined && bridge.ok) {
332
+ await options.desktopBrowser.observe().catch(() => undefined);
333
+ }
334
+ const healable = !bridge.ok && warmReads().length > 0 && canWarm(bridge);
335
+ if (!healable) {
336
+ patchStep('bridge', {
337
+ status: bridge.ok ? 'ok' : 'warn',
338
+ detail: bridge.detail,
339
+ action: undefined,
340
+ ...(bridge.command === undefined ? {} : { command: bridge.command }),
341
+ });
342
+ }
343
+ const settled = await warmSites(bridge);
344
+ const diyGap = await settleDiy(diyStarted);
345
+ logger?.warn?.(`[dsh-workbench] 环境体检:${current.steps.map((item) => `${item.id}=${item.status}`).join(' ')}`);
346
+ return set({
347
+ phase: 'ready',
348
+ ...(settled.ok ? {} : { bridgeIssues: settled.issues }),
349
+ ...(diyGap === undefined ? {} : { diyGap }),
350
+ });
351
+ }
352
+ function freshReady() {
353
+ return current.phase === 'ready' && Date.now() - current.at < PREFLIGHT_FRESH_MS;
354
+ }
355
+ async function start(runOptions = {}) {
356
+ if (running !== null)
357
+ return running;
358
+ if (runOptions.hard !== true && freshReady())
359
+ return current;
360
+ const inflight = doRun();
361
+ running = inflight;
362
+ await inflight.catch(() => undefined);
363
+ running = null;
364
+ return inflight;
365
+ }
366
+ return {
367
+ run: start,
368
+ snapshot() {
369
+ return current;
370
+ },
371
+ };
372
+ }
@@ -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,10 @@
1
+ import type { DshShell } from '@dsh-workbench/shared';
2
+ import type { LibNeed, NeedState, SupplyIndex } from '@dsh-workbench/shared/host';
3
+ export declare function installedLibVersion(name: string): string | undefined;
4
+ export declare function probeLibs(options: {
5
+ readonly needs: readonly LibNeed[];
6
+ readonly shell: DshShell;
7
+ readonly force: boolean;
8
+ readonly index?: SupplyIndex | undefined;
9
+ }): Promise<ReadonlyMap<string, NeedState>>;
10
+ export declare function resetLibCache(): void;
@@ -0,0 +1,67 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { libInstallCommand, needIdOf, npmSourceOf } from '@dsh-workbench/shared/host';
4
+ import { libDir } from '@dsh-workbench/adapter-runtime';
5
+ import { npmRegistryOf } from '@dsh-workbench/adapter-supply';
6
+ import { isNewerVersion, latestVersion, resetReleaseCache } from '@dsh-workbench/adapter-profile';
7
+ import { checkVersionRange } from './version-range.js';
8
+ export function installedLibVersion(name) {
9
+ try {
10
+ const parsed = JSON.parse(readFileSync(join(libDir(name), 'package.json'), 'utf8'));
11
+ const version = parsed.version;
12
+ return typeof version === 'string' && version.trim() !== '' ? version.trim() : undefined;
13
+ }
14
+ catch {
15
+ return undefined;
16
+ }
17
+ }
18
+ function stateOf(need, status, detail, extra = {}) {
19
+ return {
20
+ id: needIdOf(need),
21
+ kind: 'lib',
22
+ name: need.name,
23
+ status,
24
+ detail,
25
+ ...extra,
26
+ };
27
+ }
28
+ export async function probeLibs(options) {
29
+ const { needs, shell, force, index } = options;
30
+ const found = new Map();
31
+ for (const need of needs) {
32
+ if (found.has(need.name))
33
+ continue;
34
+ const source = npmSourceOf(index?.sourcesOf('lib', need.name) ?? need.sources);
35
+ const install = source === undefined
36
+ ? {}
37
+ : { command: libInstallCommand({ need, source, ...(need.version === undefined ? {} : { version: need.version }) }) };
38
+ const installed = installedLibVersion(need.name);
39
+ if (installed === undefined) {
40
+ found.set(need.name, stateOf(need, 'missing', `共用依赖 ${need.name} 没装`, install));
41
+ continue;
42
+ }
43
+ if (checkVersionRange(need.version, installed) !== 'satisfied') {
44
+ found.set(need.name, stateOf(need, 'unsatisfied', `装的是 ${installed},声明要 ${need.version ?? '任意版本'}`, {
45
+ installed,
46
+ ...install,
47
+ }));
48
+ continue;
49
+ }
50
+ const registry = source === undefined ? undefined : npmRegistryOf(source, process.env);
51
+ const latest = registry === undefined || !registry.ok
52
+ ? undefined
53
+ : await latestVersion({ shell, name: need.name, registry: registry.value, force });
54
+ const behind = latest !== undefined && isNewerVersion(latest, installed);
55
+ found.set(need.name, stateOf(need, behind ? 'outdated' : 'ok', behind ? `装的 ${installed},源上最新 ${latest ?? ''}` : `${installed} 版本对`, {
56
+ installed,
57
+ ...(latest === undefined ? {} : { latest }),
58
+ ...(behind && source !== undefined
59
+ ? { command: libInstallCommand({ need, source, version: latest }) }
60
+ : {}),
61
+ }));
62
+ }
63
+ return found;
64
+ }
65
+ export function resetLibCache() {
66
+ resetReleaseCache();
67
+ }
@@ -0,0 +1,18 @@
1
+ import type { DshShell } from '@dsh-workbench/shared';
2
+ import { type NeedProbeInput, type UnitCapability } from '@dsh-workbench/shared/host';
3
+ export type { NeedProbeInput, NeedState, UnitCapability, } from '@dsh-workbench/shared/host';
4
+ export declare function parsePluginList(stdout: string): unknown[] | undefined;
5
+ export declare function probeUnits(options: {
6
+ shell: DshShell;
7
+ runtimeCommands: Readonly<Record<string, string>>;
8
+ units: readonly NeedProbeInput[];
9
+ force?: boolean;
10
+ }): Promise<UnitCapability[]>;
11
+ export declare function probeUnitCommands(options: {
12
+ shell: DshShell;
13
+ runtimeCommands?: Readonly<Record<string, string>> | undefined;
14
+ unit: NeedProbeInput;
15
+ force?: boolean;
16
+ catalog?: readonly NeedProbeInput[];
17
+ }): Promise<UnitCapability>;
18
+ export declare const resetProbeCache: () => void;