@dsh-workbench/shared 1.1.12 → 1.1.14-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.
- package/README.md +6 -4
- package/dist/api/dsh.d.ts +2 -0
- package/dist/config/constants.d.ts +2 -0
- package/dist/config/constants.js +2 -0
- package/dist/contract/command.d.ts +2 -0
- package/dist/contract/unit-declaration.js +3 -2
- package/dist/host/data-root.js +5 -3
- package/dist/host/flow/step.d.ts +11 -0
- package/dist/host/flow/step.js +21 -0
- package/dist/host/host-kit.d.ts +7 -2
- package/dist/host/host-kit.js +4 -1
- package/dist/host/install-failure.js +1 -1
- package/dist/host/lib-install.d.ts +1 -0
- package/dist/host/lib-install.js +4 -3
- package/dist/host/paths.js +8 -7
- package/dist/host/payload.d.ts +12 -0
- package/dist/host/payload.js +16 -0
- package/dist/host/preflight.d.ts +4 -2
- package/dist/host/shell-text.d.ts +11 -1
- package/dist/host/shell-text.js +11 -3
- package/dist/host/shell.d.ts +2 -0
- package/dist/host/shell.js +41 -11
- package/dist/host/tool-approval.d.ts +20 -0
- package/dist/host/tool-approval.js +20 -0
- package/dist/host/unit-face.d.ts +5 -0
- package/dist/host/unit-plugin.d.ts +33 -0
- package/dist/host/unit-plugin.js +25 -0
- package/dist/host/workspace-store.js +12 -6
- package/dist/host.d.ts +3 -0
- package/dist/host.js +3 -0
- package/dist/i18n/en.js +4 -0
- package/dist/i18n/index.d.ts +5 -1
- package/dist/i18n/zh.d.ts +4 -0
- package/dist/i18n/zh.js +4 -0
- package/dist/lib/board-fact.d.ts +2 -1
- package/dist/lib/board-fact.js +10 -4
- package/dist/lib/ui.d.ts +8 -1
- package/dist/lib/ui.js +21 -3
- package/dist/lib/unit-plugin.d.ts +28 -0
- package/dist/lib/unit-plugin.js +23 -0
- package/dist/testing/host-fakes.d.ts +2 -0
- package/dist/testing/host-fakes.js +1 -1
- package/package.json +2 -2
- package/src/api/dsh.ts +2 -0
- package/src/components/__tests__/long-task-row.test.ts +1 -1
- package/src/config/constants.ts +3 -0
- package/src/contract/command.ts +2 -0
- package/src/contract/unit-declaration.ts +3 -2
- package/src/host/__tests__/data-root.test.ts +47 -0
- package/src/host/__tests__/plugin-helpers.test.ts +68 -0
- package/src/host/__tests__/runtime-layout.test.ts +5 -4
- package/src/host/__tests__/shell-text.test.ts +32 -7
- package/src/host/__tests__/shell.test.ts +94 -0
- package/src/host/data-root.ts +6 -3
- package/src/host/flow/__tests__/step.test.ts +43 -0
- package/src/host/flow/step.ts +21 -0
- package/src/host/host-kit.ts +11 -3
- package/src/host/install-failure.ts +1 -1
- package/src/host/lib-install.ts +5 -3
- package/src/host/paths.ts +8 -7
- package/src/host/payload.ts +17 -0
- package/src/host/preflight.ts +4 -2
- package/src/host/shell-text.ts +16 -2
- package/src/host/shell.ts +52 -12
- package/src/host/tool-approval.ts +31 -0
- package/src/host/unit-face.ts +5 -0
- package/src/host/unit-plugin.ts +49 -0
- package/src/host/workspace-store.ts +13 -6
- package/src/host.ts +3 -0
- package/src/i18n/en.ts +4 -0
- package/src/i18n/zh.ts +4 -0
- package/src/lib/__tests__/board-fact.test.ts +8 -0
- package/src/lib/__tests__/ui.test.ts +26 -0
- package/src/lib/board-fact.ts +11 -4
- package/src/lib/ui.ts +21 -3
- package/src/testing/host-fakes.ts +3 -1
- package/dist/components/SelectFieldWheel.d.ts +0 -9
- package/dist/components/SelectFieldWheel.js +0 -35
- package/dist/host/channel-plugin.d.ts +0 -15
- package/dist/host/channel-plugin.js +0 -42
- package/dist/host/command-name.d.ts +0 -1
- package/dist/host/command-name.js +0 -14
- package/dist/host/opencli-runner.d.ts +0 -26
- package/dist/host/opencli-runner.js +0 -50
- package/dist/host/plugin-source.d.ts +0 -33
- package/dist/host/plugin-source.js +0 -91
- package/dist/host/runner.d.ts +0 -26
- package/dist/host/runner.js +0 -50
- package/dist/host/runtime-links.d.ts +0 -7
- package/dist/host/runtime-links.js +0 -25
- package/dist/lib/version.d.ts +0 -4
- package/dist/lib/version.js +0 -21
- package/dist/testing/git.d.ts +0 -3
- package/dist/testing/git.js +0 -36
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { defineUnitHostPlugin, type UnitHostContextLike } from '../unit-plugin.js';
|
|
4
|
+
import { recordOf, textOf } from '../payload.js';
|
|
5
|
+
import { writeApprovalOf } from '../tool-approval.js';
|
|
6
|
+
import type { UnitModuleFace } from '../unit-face.js';
|
|
7
|
+
|
|
8
|
+
const face: UnitModuleFace = {
|
|
9
|
+
unit: 'demo',
|
|
10
|
+
mount: () => ({ routes: {}, registerTools: () => [] }),
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
describe('recordOf / textOf', () => {
|
|
14
|
+
it('非对象回空对象;字符串 trim;别的算空串', () => {
|
|
15
|
+
expect(recordOf(null)).toEqual({});
|
|
16
|
+
expect(recordOf('x')).toEqual({});
|
|
17
|
+
expect(recordOf({ a: 1 })).toEqual({ a: 1 });
|
|
18
|
+
expect(textOf(' hi ')).toBe('hi');
|
|
19
|
+
expect(textOf(3)).toBe('');
|
|
20
|
+
expect(textOf(undefined)).toBe('');
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe('writeApprovalOf', () => {
|
|
25
|
+
const approval = writeApprovalOf({
|
|
26
|
+
tools: ['demo_write'],
|
|
27
|
+
reason: '会写一条',
|
|
28
|
+
question: { zh: '写吗?', en: 'Write?' },
|
|
29
|
+
fallback: { zh: '只写模块自己的文件', en: 'Writes the module’s own file' },
|
|
30
|
+
detail: { zh: '写 1 条', en: 'Write 1' },
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('点名的写工具:ask + 明细', () => {
|
|
34
|
+
expect(approval.needsApproval({ name: 'demo_write' })).toMatchObject({ kind: 'ask' });
|
|
35
|
+
expect(approval.approvalDetail({ name: 'demo_write' })).toEqual({ zh: '写 1 条', en: 'Write 1' });
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('别的工具:read、没有明细', () => {
|
|
39
|
+
expect(approval.needsApproval({ name: 'demo_read' })).toEqual({ kind: 'read' });
|
|
40
|
+
expect(approval.needsApproval({})).toEqual({ kind: 'read' });
|
|
41
|
+
expect(approval.approvalDetail({ name: 'demo_read' })).toBeUndefined();
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe('defineUnitHostPlugin', () => {
|
|
46
|
+
it('导出 name / inject;apply 把面注册进 workbenchUnits 并交回清理函数', () => {
|
|
47
|
+
const plugin = defineUnitHostPlugin({ name: '@demo/module', unit: 'demo', face });
|
|
48
|
+
expect(plugin.name).toBe('@demo/module');
|
|
49
|
+
expect(plugin.inject).toEqual(['workbenchUnits']);
|
|
50
|
+
const dispose = vi.fn();
|
|
51
|
+
const register = vi.fn(() => dispose);
|
|
52
|
+
const effect = vi.fn((run: () => void | (() => void)) => {
|
|
53
|
+
run();
|
|
54
|
+
return () => {};
|
|
55
|
+
});
|
|
56
|
+
plugin.apply({ get: () => ({ register }), effect } as unknown as UnitHostContextLike);
|
|
57
|
+
expect(register).toHaveBeenCalledWith(face);
|
|
58
|
+
expect(effect).toHaveBeenCalledWith(expect.any(Function), '@demo/module: 注册 demo');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('外壳没给服务:warn 一声,不抛', () => {
|
|
62
|
+
const plugin = defineUnitHostPlugin({ name: '@demo/module', unit: 'demo', face });
|
|
63
|
+
const warn = vi.fn();
|
|
64
|
+
const ctx = { get: () => undefined, effect: () => () => {}, logger: { warn } } as unknown as UnitHostContextLike;
|
|
65
|
+
expect(() => plugin.apply(ctx)).not.toThrow();
|
|
66
|
+
expect(warn).toHaveBeenCalledWith(expect.stringContaining('workbenchUnits'));
|
|
67
|
+
});
|
|
68
|
+
});
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
1
2
|
import { describe, expect, it } from 'vitest';
|
|
2
3
|
import { runtimeLayoutOf, runtimeRootOf } from '../paths.js';
|
|
3
4
|
|
|
@@ -6,9 +7,9 @@ describe('运行时根的算法(这里不认识任何具体运行时)', () =
|
|
|
6
7
|
const layout = runtimeLayoutOf('demo-runtime');
|
|
7
8
|
|
|
8
9
|
expect(layout.root).toBe(runtimeRootOf('demo-runtime'));
|
|
9
|
-
expect(layout.root.endsWith('
|
|
10
|
-
expect(layout.vendorPluginsDir).toBe(
|
|
11
|
-
expect(layout.libsPrefix).toBe(
|
|
12
|
-
expect(layout.npmCacheDir).toBe(
|
|
10
|
+
expect(layout.root.endsWith(join('workbench', 'demo-runtime'))).toBe(true);
|
|
11
|
+
expect(layout.vendorPluginsDir).toBe(join(layout.root, 'plugins-npm', 'node_modules'));
|
|
12
|
+
expect(layout.libsPrefix).toBe(join(layout.root, 'libs'));
|
|
13
|
+
expect(layout.npmCacheDir).toBe(join(layout.root, 'npm-cache'));
|
|
13
14
|
});
|
|
14
15
|
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { execFileSync } from 'node:child_process';
|
|
2
2
|
import { describe, expect, it } from 'vitest';
|
|
3
|
-
import { quoteShellArg, quoteShellArgFor, tailLines } from '../shell-text.js';
|
|
3
|
+
import { quoteShellArg, quoteShellArgFor, shellKindOf, tailLines } from '../shell-text.js';
|
|
4
4
|
|
|
5
5
|
function shellSees(value: string): string {
|
|
6
6
|
return execFileSync('/bin/sh', ['-c', `printf '%s' ${quoteShellArg(value)}`], {
|
|
@@ -8,7 +8,7 @@ function shellSees(value: string): string {
|
|
|
8
8
|
});
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
-
describe('quoteShellArg', () => {
|
|
11
|
+
describe.skipIf(process.platform === 'win32')('quoteShellArg(POSIX /bin/sh)', () => {
|
|
12
12
|
it('普通值原样往返', () => {
|
|
13
13
|
expect(shellSees('example-monorepo')).toBe('example-monorepo');
|
|
14
14
|
});
|
|
@@ -45,15 +45,40 @@ describe('tailLines', () => {
|
|
|
45
45
|
|
|
46
46
|
|
|
47
47
|
describe('quoteShellArgFor', () => {
|
|
48
|
-
it('win32
|
|
49
|
-
expect(quoteShellArgFor('C:\\Program Files\\dsh\\x.cjs', 'win32')).toBe('
|
|
48
|
+
it('★ win32 按 pwsh 的字面量走:单引号,路径里的空格与反斜杠都原样', () => {
|
|
49
|
+
expect(quoteShellArgFor('C:\\Program Files\\dsh\\x.cjs', 'win32')).toBe("'C:\\Program Files\\dsh\\x.cjs'");
|
|
50
50
|
});
|
|
51
51
|
|
|
52
|
-
it('
|
|
53
|
-
expect(quoteShellArgFor('
|
|
52
|
+
it('★ pwsh 里值自身的单引号按双写逃逸(不是 POSIX 那套反斜杠)', () => {
|
|
53
|
+
expect(quoteShellArgFor("it's", 'win32')).toBe("'it''s'");
|
|
54
54
|
});
|
|
55
55
|
|
|
56
|
-
it('
|
|
56
|
+
it('★ pwsh 里 $ 与双引号不再被当成语法(单引号内是字面量)', () => {
|
|
57
|
+
expect(quoteShellArgFor('a$b"c', 'win32')).toBe(`'a$b"c'`);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('POSIX 仍用单引号加反斜杠逃逸', () => {
|
|
57
61
|
expect(quoteShellArgFor("it's", 'darwin')).toBe("'it'\\''s'");
|
|
58
62
|
});
|
|
59
63
|
});
|
|
64
|
+
|
|
65
|
+
describe.skipIf(process.platform !== 'win32')('quoteShellArg(Windows pwsh)', () => {
|
|
66
|
+
it('经真 pwsh 解析后原样往返', { timeout: 30000 }, () => {
|
|
67
|
+
for (const raw of ["it's", '$HOME', 'a;b', 'a && b']) {
|
|
68
|
+
const out = execFileSync(
|
|
69
|
+
'pwsh',
|
|
70
|
+
['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', `[Console]::Out.Write(${quoteShellArg(raw)})`],
|
|
71
|
+
{ encoding: 'utf8' },
|
|
72
|
+
);
|
|
73
|
+
expect(out, raw).toBe(raw);
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe('shellKindOf', () => {
|
|
79
|
+
it('win32 是 pwsh,其余是 POSIX', () => {
|
|
80
|
+
expect(shellKindOf('win32')).toBe('pwsh');
|
|
81
|
+
expect(shellKindOf('darwin')).toBe('posix');
|
|
82
|
+
expect(shellKindOf('linux')).toBe('posix');
|
|
83
|
+
});
|
|
84
|
+
});
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import type { DshShell, DshShellExecution } from '../../api/dsh.js';
|
|
3
|
+
import { runShell } from '../shell.js';
|
|
4
|
+
|
|
5
|
+
function hangingShell(): DshShell {
|
|
6
|
+
return {
|
|
7
|
+
resolve: (request) => request,
|
|
8
|
+
execute: () => new Promise<DshShellExecution>(() => {}),
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function replyingShell(exitCode: number, timedOut = false): DshShell {
|
|
13
|
+
return {
|
|
14
|
+
resolve: (request) => request,
|
|
15
|
+
execute: async () => ({
|
|
16
|
+
result: async () => ({
|
|
17
|
+
exitCode,
|
|
18
|
+
timedOut,
|
|
19
|
+
aborted: false,
|
|
20
|
+
stdout: { text: 'v24.21.0\n', truncated: false },
|
|
21
|
+
stderr: { text: '', truncated: false },
|
|
22
|
+
}),
|
|
23
|
+
}),
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe('runShell', () => {
|
|
28
|
+
it('★ 宿主一直不交结果 → 到点自己收尾,不把调用方挂住', async () => {
|
|
29
|
+
vi.useFakeTimers();
|
|
30
|
+
try {
|
|
31
|
+
const pending = runShell(hangingShell(), 'node --version', { timeoutMs: 10_000 });
|
|
32
|
+
await vi.advanceTimersByTimeAsync(11_000);
|
|
33
|
+
const result = await pending;
|
|
34
|
+
|
|
35
|
+
expect(result.stalled).toBe(true);
|
|
36
|
+
expect(result.timedOut).toBe(true);
|
|
37
|
+
expect(result.exitCode).toBeNull();
|
|
38
|
+
expect(result.stdout).toBe('');
|
|
39
|
+
} finally {
|
|
40
|
+
vi.useRealTimers();
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('★ 宿主准时交结果 → stalled=false,退出码与输出原样', async () => {
|
|
45
|
+
const result = await runShell(replyingShell(0), 'node --version');
|
|
46
|
+
|
|
47
|
+
expect(result.stalled).toBe(false);
|
|
48
|
+
expect(result.timedOut).toBe(false);
|
|
49
|
+
expect(result.exitCode).toBe(0);
|
|
50
|
+
expect(result.stdout).toBe('v24.21.0\n');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('★ 宿主自己报超时 → timedOut=true,但不算 stalled', async () => {
|
|
54
|
+
const result = await runShell(replyingShell(0, true), 'node --version');
|
|
55
|
+
|
|
56
|
+
expect(result.timedOut).toBe(true);
|
|
57
|
+
expect(result.stalled).toBe(false);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('★ env 走 resolve 的覆盖字段,不拼进命令行的 shell 语法', async () => {
|
|
61
|
+
const seen: Array<Record<string, unknown>> = [];
|
|
62
|
+
const shell: DshShell = {
|
|
63
|
+
resolve: (request) => {
|
|
64
|
+
seen.push(request as unknown as Record<string, unknown>);
|
|
65
|
+
return request;
|
|
66
|
+
},
|
|
67
|
+
execute: async () => ({
|
|
68
|
+
result: async () => ({
|
|
69
|
+
exitCode: 0,
|
|
70
|
+
timedOut: false,
|
|
71
|
+
aborted: false,
|
|
72
|
+
stdout: { text: '', truncated: false },
|
|
73
|
+
stderr: { text: '', truncated: false },
|
|
74
|
+
}),
|
|
75
|
+
}),
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
await runShell(shell, 'node --version', { env: { OPENCLI_WINDOW: 'background' } });
|
|
79
|
+
|
|
80
|
+
expect(seen[0]?.command).toBe('node --version');
|
|
81
|
+
expect(seen[0]?.env).toEqual({ OPENCLI_WINDOW: 'background' });
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('★ 宿主自己抛错 → 照旧抛出去,不吞成 stalled', async () => {
|
|
85
|
+
const shell: DshShell = {
|
|
86
|
+
resolve: (request) => request,
|
|
87
|
+
execute: async () => {
|
|
88
|
+
throw new Error('spawn /bin/sh ENOENT');
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
await expect(runShell(shell, 'node --version')).rejects.toThrow('ENOENT');
|
|
93
|
+
});
|
|
94
|
+
});
|
package/src/host/data-root.ts
CHANGED
|
@@ -1,15 +1,18 @@
|
|
|
1
|
+
import { homedir } from 'node:os';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
1
4
|
export function dshHome(): string {
|
|
2
5
|
const configured = process.env.DSH_HOME?.trim();
|
|
3
6
|
if (configured !== undefined && configured !== '') return configured;
|
|
4
|
-
return
|
|
7
|
+
return join(homedir(), '.dsh');
|
|
5
8
|
}
|
|
6
9
|
|
|
7
10
|
export function workbenchRoot(): string {
|
|
8
11
|
const configured = process.env.DSH_WORKBENCH_HOME?.trim();
|
|
9
12
|
if (configured !== undefined && configured !== '') return configured;
|
|
10
|
-
return
|
|
13
|
+
return join(dshHome(), 'workbench');
|
|
11
14
|
}
|
|
12
15
|
|
|
13
16
|
export function workspacesRoot(): string {
|
|
14
|
-
return
|
|
17
|
+
return join(workbenchRoot(), 'workspaces');
|
|
15
18
|
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import type { FlowStepEvent } from '../types.js';
|
|
4
|
+
import { longTaskStepOf } from '../step.js';
|
|
5
|
+
|
|
6
|
+
const eventOf = (status: FlowStepEvent['status'], extra: Partial<FlowStepEvent> = {}): FlowStepEvent => ({
|
|
7
|
+
stepId: 'read',
|
|
8
|
+
title: '读目录',
|
|
9
|
+
status,
|
|
10
|
+
...extra,
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
describe('longTaskStepOf:流程事件 → 长任务步骤的默认映射', () => {
|
|
14
|
+
it('running 记成 start,文字用 title', () => {
|
|
15
|
+
expect(longTaskStepOf(eventOf('running'))).toEqual({ kind: 'start', text: '读目录' });
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('ok 记成 succeed', () => {
|
|
19
|
+
expect(longTaskStepOf(eventOf('ok'))).toEqual({ kind: 'succeed', text: '读目录' });
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('skipped 记成 info', () => {
|
|
23
|
+
expect(longTaskStepOf(eventOf('skipped'))).toEqual({ kind: 'info', text: '读目录' });
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('failed 记成 fail', () => {
|
|
27
|
+
expect(longTaskStepOf(eventOf('failed'))).toEqual({ kind: 'fail', text: '读目录' });
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('needs-login 带 loginUrl', () => {
|
|
31
|
+
expect(longTaskStepOf(eventOf('needs-login', { loginUrl: 'https://example.com/login' }))).toEqual({
|
|
32
|
+
kind: 'info',
|
|
33
|
+
text: '读目录',
|
|
34
|
+
loginUrl: 'https://example.com/login',
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('message 盖过 title;没有 loginUrl 就不带那个键', () => {
|
|
39
|
+
const step = longTaskStepOf(eventOf('running', { message: '正在读' }));
|
|
40
|
+
expect(step).toEqual({ kind: 'start', text: '正在读' });
|
|
41
|
+
expect('loginUrl' in step).toBe(false);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { LongTaskStep } from '../../contract/long-task.js';
|
|
2
|
+
import type { FlowStepEvent } from './types.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* 把流程的一步事件翻成长任务步骤流的默认一行。
|
|
6
|
+
* `running` → `start`、`ok` → `succeed`、`skipped` → `info`、`failed` → `fail`、
|
|
7
|
+
* `needs-login` → `info`(带 `loginUrl`);文字取 `message ?? title`。
|
|
8
|
+
* 要改文案或过滤步骤,就在模块里自己映射 —— 这只是默认值。
|
|
9
|
+
* @param event `flow.run({ onStep })` 收到的那条事件。
|
|
10
|
+
* @returns 能直接交给长任务 `run({ onStep })` 的一行。
|
|
11
|
+
*/
|
|
12
|
+
export function longTaskStepOf(event: FlowStepEvent): LongTaskStep {
|
|
13
|
+
const text = event.message ?? event.title;
|
|
14
|
+
if (event.status === 'failed') return { kind: 'fail', text };
|
|
15
|
+
if (event.status === 'needs-login') {
|
|
16
|
+
return { kind: 'info', text, ...(event.loginUrl === undefined ? {} : { loginUrl: event.loginUrl }) };
|
|
17
|
+
}
|
|
18
|
+
if (event.status === 'ok') return { kind: 'succeed', text };
|
|
19
|
+
if (event.status === 'skipped') return { kind: 'info', text };
|
|
20
|
+
return { kind: 'start', text };
|
|
21
|
+
}
|
package/src/host/host-kit.ts
CHANGED
|
@@ -1,26 +1,34 @@
|
|
|
1
|
+
import type { HostSurface } from '../contract/protocol/platform.js';
|
|
1
2
|
import type { EnvSupplyPort } from './port-types.js';
|
|
2
3
|
import { createFlowHost, type FlowHost } from './flow/kit.js';
|
|
3
4
|
import { sessionIdOf, sessionWorkspace } from './tool-kit.js';
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
|
-
* 宿主 kit 的公共部分:flow、工具侧 session
|
|
7
|
+
* 宿主 kit 的公共部分:flow、工具侧 session 读法、外部供给装法、宿主平台事实。
|
|
7
8
|
*/
|
|
8
9
|
export interface HostKit {
|
|
9
10
|
readonly flow: FlowHost;
|
|
10
11
|
readonly sessionIdOf: typeof sessionIdOf;
|
|
11
12
|
readonly sessionWorkspace: typeof sessionWorkspace;
|
|
12
13
|
readonly libInstallCommand: EnvSupplyPort['libInstallCommand'];
|
|
14
|
+
readonly surface: HostSurface;
|
|
13
15
|
}
|
|
14
16
|
|
|
15
17
|
/**
|
|
16
18
|
* 建一份宿主 kit;每个挂载点一份,没有模块级状态。
|
|
17
|
-
* @
|
|
19
|
+
* @param parts.libInstallCommand 拼「装这个共用依赖」的那条命令。
|
|
20
|
+
* @param parts.surface 宿主平台事实;由组合根按 profile 与宿主进程算好再交进来。
|
|
21
|
+
* @returns flow / sessionIdOf / sessionWorkspace / libInstallCommand / surface。
|
|
18
22
|
*/
|
|
19
|
-
export function createHostKit(parts: {
|
|
23
|
+
export function createHostKit(parts: {
|
|
24
|
+
readonly libInstallCommand: EnvSupplyPort['libInstallCommand'];
|
|
25
|
+
readonly surface: HostSurface;
|
|
26
|
+
}): HostKit {
|
|
20
27
|
return {
|
|
21
28
|
flow: createFlowHost(),
|
|
22
29
|
sessionIdOf,
|
|
23
30
|
sessionWorkspace,
|
|
24
31
|
libInstallCommand: parts.libInstallCommand,
|
|
32
|
+
surface: parts.surface,
|
|
25
33
|
};
|
|
26
34
|
}
|
|
@@ -4,7 +4,7 @@ import type { InstallFailure, InstallFailureKind } from '../contract/command.js'
|
|
|
4
4
|
|
|
5
5
|
const TARGET_FLAGS = ['--prefix', '--cache', '--dir', '--store-dir'];
|
|
6
6
|
|
|
7
|
-
const INSTALL_VERBS = /^\s*npm\s+(i|install|add)\b/;
|
|
7
|
+
const INSTALL_VERBS = /^\s*(?:npm\s+(?:i|install|add)|pnpm\s+(?:add|install))\b/;
|
|
8
8
|
|
|
9
9
|
const NPM_PATH = /^npm (?:error|ERR!) path (.+)$/m;
|
|
10
10
|
|
package/src/host/lib-install.ts
CHANGED
|
@@ -12,13 +12,15 @@ export function libInstallCommand(options: {
|
|
|
12
12
|
readonly env?: NodeJS.ProcessEnv | undefined;
|
|
13
13
|
readonly prefix: string;
|
|
14
14
|
readonly cacheDir?: string | undefined;
|
|
15
|
+
readonly pnpm?: string | undefined;
|
|
15
16
|
}): string {
|
|
16
17
|
const { need, source, version } = options;
|
|
18
|
+
const pnpm = options.pnpm ?? process.env.DSH_WB_PNPM?.trim() ?? 'pnpm';
|
|
17
19
|
const registry = npmRegistryOf(source, options.env ?? process.env);
|
|
18
20
|
const spec = version ?? need.version ?? 'latest';
|
|
19
21
|
const registryFlag = registry.ok ? ` --registry ${quoteShellArg(registry.value)}` : '';
|
|
20
|
-
const
|
|
21
|
-
return
|
|
22
|
+
const storeFlag = options.cacheDir === undefined ? '' : ` --store-dir ${quoteShellArg(options.cacheDir)}`;
|
|
23
|
+
return `${pnpm} add --dir ${quoteShellArg(options.prefix)} ${quoteShellArg(`${need.name}@${spec}`)}${registryFlag}${storeFlag} --allow-build=${quoteShellArg(need.name)}`;
|
|
22
24
|
}
|
|
23
25
|
|
|
24
26
|
export function binInstallCommand(
|
|
@@ -52,5 +54,5 @@ export function binInstallCommand(
|
|
|
52
54
|
}
|
|
53
55
|
|
|
54
56
|
export function libUninstallCommand(options: { readonly need: LibNeed; readonly prefix: string }): string {
|
|
55
|
-
return `
|
|
57
|
+
return `pnpm remove --dir ${quoteShellArg(options.prefix)} ${quoteShellArg(options.need.name)}`;
|
|
56
58
|
}
|
package/src/host/paths.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
1
2
|
import { workbenchRoot } from './data-root.js';
|
|
2
3
|
|
|
3
4
|
export interface RuntimeLayout {
|
|
@@ -11,20 +12,20 @@ export interface RuntimeLayout {
|
|
|
11
12
|
}
|
|
12
13
|
|
|
13
14
|
export function runtimeRootOf(id: string): string {
|
|
14
|
-
return
|
|
15
|
+
return join(workbenchRoot(), id);
|
|
15
16
|
}
|
|
16
17
|
|
|
17
18
|
export function runtimeLayoutOf(id: string): RuntimeLayout {
|
|
18
19
|
const root = runtimeRootOf(id);
|
|
19
|
-
const vendorPrefix =
|
|
20
|
-
const libsPrefix =
|
|
20
|
+
const vendorPrefix = join(root, 'plugins-npm');
|
|
21
|
+
const libsPrefix = join(root, 'libs');
|
|
21
22
|
return {
|
|
22
23
|
root,
|
|
23
|
-
home:
|
|
24
|
+
home: join(root, 'home'),
|
|
24
25
|
vendorPrefix,
|
|
25
|
-
vendorPluginsDir:
|
|
26
|
+
vendorPluginsDir: join(vendorPrefix, 'node_modules'),
|
|
26
27
|
libsPrefix,
|
|
27
|
-
libsBinDir:
|
|
28
|
-
npmCacheDir:
|
|
28
|
+
libsBinDir: join(libsPrefix, 'node_modules', '.bin'),
|
|
29
|
+
npmCacheDir: join(root, 'npm-cache'),
|
|
29
30
|
};
|
|
30
31
|
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 把任意 payload 当对象看;不是对象就回空对象。
|
|
3
|
+
* @param value 任意入参。
|
|
4
|
+
* @returns 可按下标取值的安全对象。
|
|
5
|
+
*/
|
|
6
|
+
export function recordOf(value: unknown): Record<string, unknown> {
|
|
7
|
+
return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : {};
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* 把任意 payload 当一段文字看:字符串就 trim,别的都算空串。
|
|
12
|
+
* @param value 任意入参。
|
|
13
|
+
* @returns 去掉首尾空白的字符串;不是字符串时是空串。
|
|
14
|
+
*/
|
|
15
|
+
export function textOf(value: unknown): string {
|
|
16
|
+
return typeof value === 'string' ? value.trim() : '';
|
|
17
|
+
}
|
package/src/host/preflight.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export type PreflightStepId = 'toolchain' | 'cli' | 'bridge' | 'runtime' | 'diy' | 'warm';
|
|
2
2
|
export type PreflightStepStatus = 'pending' | 'running' | 'ok' | 'warn' | 'failed';
|
|
3
|
-
export type PreflightFailure = 'permission' | 'network' | 'missing' | 'unknown';
|
|
4
|
-
export type PreflightGuidance = 'install' | 'path' | 'shell';
|
|
3
|
+
export type PreflightFailure = 'permission' | 'network' | 'missing' | 'host' | 'unknown';
|
|
4
|
+
export type PreflightGuidance = 'install' | 'path' | 'shell' | 'host';
|
|
5
5
|
export type PreflightBridgeIssue = string;
|
|
6
6
|
|
|
7
7
|
export interface PreflightStep {
|
|
@@ -11,6 +11,7 @@ export interface PreflightStep {
|
|
|
11
11
|
readonly detail?: string;
|
|
12
12
|
readonly action?: string;
|
|
13
13
|
readonly command?: string;
|
|
14
|
+
readonly log?: readonly string[];
|
|
14
15
|
}
|
|
15
16
|
|
|
16
17
|
export interface PreflightDiyGap {
|
|
@@ -29,4 +30,5 @@ export interface PreflightSnapshot {
|
|
|
29
30
|
readonly bridgeIssues?: readonly PreflightBridgeIssue[];
|
|
30
31
|
readonly diyGap?: PreflightDiyGap;
|
|
31
32
|
readonly canWakeBrowser?: boolean;
|
|
33
|
+
readonly log?: readonly string[];
|
|
32
34
|
}
|
package/src/host/shell-text.ts
CHANGED
|
@@ -1,12 +1,26 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* 一台机器上跑命令的那种 shell:POSIX 系(sh / bash / zsh),或 dsh 在 Windows 上用的 pwsh。
|
|
3
|
+
*/
|
|
4
|
+
export type ShellKind = 'posix' | 'pwsh';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* 按目标平台判 shell 种类:win32 是 pwsh(dsh 的 Windows 执行器就是 PowerShell),其余是 POSIX。
|
|
8
|
+
* @param platform - 目标平台,默认当前 `process.platform`。
|
|
9
|
+
* @returns `'pwsh'` 或 `'posix'`。
|
|
10
|
+
*/
|
|
11
|
+
export function shellKindOf(platform: NodeJS.Platform = process.platform): ShellKind {
|
|
12
|
+
return platform === 'win32' ? 'pwsh' : 'posix';
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* 按目标平台把一个值裹成 shell 字面量:POSIX 用单引号并按 `'\''` 逃逸,pwsh 也用单引号但按 `''` 逃逸。
|
|
3
17
|
* @param value - 任意值;不是字符串的先 `String()`。
|
|
4
18
|
* @param platform - 目标平台,默认当前 `process.platform`。
|
|
5
19
|
* @returns 可安全拼进该平台 shell 命令的一段文本;含空格 / 元字符的值不会被当成语法。
|
|
6
20
|
*/
|
|
7
21
|
export function quoteShellArgFor(value: unknown, platform: NodeJS.Platform = process.platform): string {
|
|
8
22
|
const text = String(value);
|
|
9
|
-
if (platform === '
|
|
23
|
+
if (shellKindOf(platform) === 'pwsh') return `'${text.replace(/'/g, "''")}'`;
|
|
10
24
|
return `'${text.replace(/'/g, `'\\''`)}'`;
|
|
11
25
|
}
|
|
12
26
|
|
package/src/host/shell.ts
CHANGED
|
@@ -8,16 +8,48 @@ export interface ShellResult {
|
|
|
8
8
|
readonly stdout: string;
|
|
9
9
|
readonly stderr: string;
|
|
10
10
|
readonly truncated: boolean;
|
|
11
|
+
readonly stalled: boolean;
|
|
11
12
|
}
|
|
12
13
|
|
|
13
14
|
const DEFAULT_MAX_BYTES = 8 * 1024 * 1024;
|
|
14
15
|
|
|
16
|
+
const GUARD_SLACK_MS = 1_000;
|
|
17
|
+
|
|
18
|
+
function stalledResult(): ShellResult {
|
|
19
|
+
return {
|
|
20
|
+
exitCode: null,
|
|
21
|
+
timedOut: true,
|
|
22
|
+
aborted: false,
|
|
23
|
+
stdout: '',
|
|
24
|
+
stderr: '',
|
|
25
|
+
truncated: false,
|
|
26
|
+
stalled: true,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function settleWithin<T>(work: Promise<T>, limitMs: number): Promise<T | null> {
|
|
31
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
32
|
+
const guard = new Promise<null>((resolve) => {
|
|
33
|
+
timer = setTimeout(() => resolve(null), limitMs);
|
|
34
|
+
});
|
|
35
|
+
try {
|
|
36
|
+
return await Promise.race([work, guard]);
|
|
37
|
+
} finally {
|
|
38
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
15
42
|
export async function runShell(
|
|
16
43
|
shell: DshShell,
|
|
17
44
|
command: string,
|
|
18
|
-
options: {
|
|
45
|
+
options: {
|
|
46
|
+
workdir?: string;
|
|
47
|
+
timeoutMs?: number;
|
|
48
|
+
signal?: AbortSignal;
|
|
49
|
+
env?: Readonly<Record<string, string>>;
|
|
50
|
+
} = {},
|
|
19
51
|
): Promise<ShellResult> {
|
|
20
|
-
const { workdir, timeoutMs = GIT_TIMEOUT_MS, signal } = options;
|
|
52
|
+
const { workdir, timeoutMs = GIT_TIMEOUT_MS, signal, env } = options;
|
|
21
53
|
|
|
22
54
|
const spec = shell.resolve({
|
|
23
55
|
command,
|
|
@@ -26,17 +58,25 @@ export async function runShell(
|
|
|
26
58
|
onExpiry: 'kill',
|
|
27
59
|
stdoutMaxBytes: DEFAULT_MAX_BYTES,
|
|
28
60
|
...(signal === undefined ? {} : { signal }),
|
|
61
|
+
...(env === undefined ? {} : { env }),
|
|
29
62
|
});
|
|
30
63
|
|
|
31
|
-
const
|
|
32
|
-
|
|
64
|
+
const settled = await settleWithin(
|
|
65
|
+
(async (): Promise<ShellResult> => {
|
|
66
|
+
const execution = await shell.execute(spec);
|
|
67
|
+
const result = await execution.result();
|
|
68
|
+
return {
|
|
69
|
+
exitCode: result.exitCode,
|
|
70
|
+
timedOut: result.timedOut === true,
|
|
71
|
+
aborted: result.aborted === true,
|
|
72
|
+
stdout: result.stdout.text,
|
|
73
|
+
stderr: result.stderr.text,
|
|
74
|
+
truncated: result.stdout.truncated || result.stderr.truncated,
|
|
75
|
+
stalled: false,
|
|
76
|
+
};
|
|
77
|
+
})(),
|
|
78
|
+
timeoutMs + GUARD_SLACK_MS,
|
|
79
|
+
);
|
|
33
80
|
|
|
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
|
-
};
|
|
81
|
+
return settled ?? stalledResult();
|
|
42
82
|
}
|
|
@@ -181,3 +181,34 @@ export async function approvalFor(
|
|
|
181
181
|
displayReason: compose(facts, verdict, note),
|
|
182
182
|
};
|
|
183
183
|
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* 「写工具要点头」那一对的默认口径:点名的工具一律 `ask` + 同一份明细,其余一律 `read`。
|
|
187
|
+
* 把它直接摊在 `mount()` 的返回值里:`...writeApprovalOf({ … })`。
|
|
188
|
+
* @param options.tools 需要点头的工具名。
|
|
189
|
+
* @param options.reason 为什么问(给 agent 看的理由)。
|
|
190
|
+
* @param options.question 问句(zh / en,≤40 字)。
|
|
191
|
+
* @param options.fallback 没有明细时的整句(zh / en,≤40 字)。
|
|
192
|
+
* @param options.detail 审批屏头行那句明细(zh / en,≤20 字)。
|
|
193
|
+
* @returns `{ needsApproval, approvalDetail }`。
|
|
194
|
+
*/
|
|
195
|
+
export function writeApprovalOf(options: {
|
|
196
|
+
readonly tools: readonly string[];
|
|
197
|
+
readonly reason: string;
|
|
198
|
+
readonly question: ApprovalDetail;
|
|
199
|
+
readonly fallback: ApprovalDetail;
|
|
200
|
+
readonly detail: ApprovalDetail;
|
|
201
|
+
}): {
|
|
202
|
+
needsApproval(call: ApprovalCall): ApprovalVerdict;
|
|
203
|
+
approvalDetail(call: ApprovalCall): ApprovalFacts | undefined;
|
|
204
|
+
} {
|
|
205
|
+
const tools = new Set(options.tools);
|
|
206
|
+
const named = (call: ApprovalCall): boolean => call.name !== undefined && tools.has(call.name);
|
|
207
|
+
return {
|
|
208
|
+
needsApproval: (call) =>
|
|
209
|
+
named(call)
|
|
210
|
+
? { kind: 'ask', reason: options.reason, question: options.question, fallback: options.fallback }
|
|
211
|
+
: { kind: 'read' },
|
|
212
|
+
approvalDetail: (call) => (named(call) ? options.detail : undefined),
|
|
213
|
+
};
|
|
214
|
+
}
|
package/src/host/unit-face.ts
CHANGED
|
@@ -53,6 +53,11 @@ export interface UnitCall {
|
|
|
53
53
|
readonly result: unknown;
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
/**
|
|
57
|
+
* 挂载结果:路由表 + 工具注册。
|
|
58
|
+
* `registerTools()` 是**调用时才注册**,返回一组清理函数(卸载 / 热换时外壳逐个调);
|
|
59
|
+
* 工具名必须与清单 `dsh.workbench.tools` 一字不差,没有工具就 `() => []`。
|
|
60
|
+
*/
|
|
56
61
|
export interface UnitMountResult {
|
|
57
62
|
readonly routes: RouteTable;
|
|
58
63
|
registerTools(): Array<() => void>;
|