@dsh-workbench/shared 1.1.13 → 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/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 +2 -0
- package/dist/host/shell-text.d.ts +11 -1
- package/dist/host/shell-text.js +11 -3
- package/dist/host/shell.d.ts +1 -0
- package/dist/host/shell.js +2 -1
- 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 +1 -0
- package/dist/i18n/index.d.ts +2 -1
- package/dist/i18n/zh.d.ts +1 -0
- package/dist/i18n/zh.js +1 -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/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 +24 -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 +2 -0
- package/src/host/shell-text.ts +16 -2
- package/src/host/shell.ts +8 -2
- 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 +1 -0
- package/src/i18n/zh.ts +1 -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
|
@@ -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
|
+
});
|
|
@@ -57,6 +57,30 @@ describe('runShell', () => {
|
|
|
57
57
|
expect(result.stalled).toBe(false);
|
|
58
58
|
});
|
|
59
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
|
+
|
|
60
84
|
it('★ 宿主自己抛错 → 照旧抛出去,不吞成 stalled', async () => {
|
|
61
85
|
const shell: DshShell = {
|
|
62
86
|
resolve: (request) => request,
|
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
|
@@ -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
|
@@ -42,9 +42,14 @@ async function settleWithin<T>(work: Promise<T>, limitMs: number): Promise<T | n
|
|
|
42
42
|
export async function runShell(
|
|
43
43
|
shell: DshShell,
|
|
44
44
|
command: string,
|
|
45
|
-
options: {
|
|
45
|
+
options: {
|
|
46
|
+
workdir?: string;
|
|
47
|
+
timeoutMs?: number;
|
|
48
|
+
signal?: AbortSignal;
|
|
49
|
+
env?: Readonly<Record<string, string>>;
|
|
50
|
+
} = {},
|
|
46
51
|
): Promise<ShellResult> {
|
|
47
|
-
const { workdir, timeoutMs = GIT_TIMEOUT_MS, signal } = options;
|
|
52
|
+
const { workdir, timeoutMs = GIT_TIMEOUT_MS, signal, env } = options;
|
|
48
53
|
|
|
49
54
|
const spec = shell.resolve({
|
|
50
55
|
command,
|
|
@@ -53,6 +58,7 @@ export async function runShell(
|
|
|
53
58
|
onExpiry: 'kill',
|
|
54
59
|
stdoutMaxBytes: DEFAULT_MAX_BYTES,
|
|
55
60
|
...(signal === undefined ? {} : { signal }),
|
|
61
|
+
...(env === undefined ? {} : { env }),
|
|
56
62
|
});
|
|
57
63
|
|
|
58
64
|
const settled = await settleWithin(
|
|
@@ -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>;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { UNITS_SERVICE } from '../config/constants.js';
|
|
2
|
+
import type { UnitModuleFace } from './unit-face.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* 宿主面注册只需要的那点上下文:`get` / `effect` / 可选的 `logger`。
|
|
6
|
+
*/
|
|
7
|
+
export interface UnitHostContextLike {
|
|
8
|
+
get<T = unknown>(name: string): T | undefined;
|
|
9
|
+
effect(effect: () => void | (() => void), label?: string): () => void;
|
|
10
|
+
readonly logger?: { warn?(message: string): void } | undefined;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* 一个宿主插件导出的三件:`name` / `inject` / `apply`。
|
|
15
|
+
*/
|
|
16
|
+
export interface UnitHostPlugin {
|
|
17
|
+
readonly name: string;
|
|
18
|
+
readonly inject: readonly string[];
|
|
19
|
+
apply(ctx: UnitHostContextLike): void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* 把「宿主面注册进外壳」这段在每个模块里都一样的接线收成一份。
|
|
24
|
+
* 用法:先 `const plugin = defineUnitHostPlugin({ … })`,再逐个导出 `name` / `inject` / `apply`
|
|
25
|
+
* (逐名 `export const` 才过 arch 的导出对账;解构导出它认不出)。
|
|
26
|
+
* @param options.name 插件名(包名)。
|
|
27
|
+
* @param options.unit 单元 id。
|
|
28
|
+
* @param options.face 这个单元的宿主面。
|
|
29
|
+
* @returns `{ name, inject, apply }`;`apply` 里取 `workbenchUnits`、缺了就 warn 一声。
|
|
30
|
+
*/
|
|
31
|
+
export function defineUnitHostPlugin(options: {
|
|
32
|
+
readonly name: string;
|
|
33
|
+
readonly unit: string;
|
|
34
|
+
readonly face: UnitModuleFace;
|
|
35
|
+
}): UnitHostPlugin {
|
|
36
|
+
const { name, unit, face } = options;
|
|
37
|
+
return {
|
|
38
|
+
name,
|
|
39
|
+
inject: [UNITS_SERVICE],
|
|
40
|
+
apply(ctx: UnitHostContextLike): void {
|
|
41
|
+
const units = ctx.get<{ register(face: UnitModuleFace): () => void }>(UNITS_SERVICE);
|
|
42
|
+
if (units === undefined) {
|
|
43
|
+
ctx.logger?.warn?.(`[${unit}] 外壳没有提供 workbenchUnits 服务:这个模块装不进来`);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
ctx.effect(() => units.register(face), `${name}: 注册 ${unit}`);
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}
|
|
@@ -30,15 +30,22 @@ export function workspaceKey(workspace: string): string {
|
|
|
30
30
|
return `${slug(path.basename(abs))}-${createHash('sha1').update(abs).digest('hex').slice(0, 8)}`;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
function
|
|
33
|
+
function safeName(name: string): string {
|
|
34
34
|
const trimmed = name.trim();
|
|
35
35
|
if (trimmed === '' || trimmed === '.' || trimmed === '..') {
|
|
36
36
|
throw new Error(`模块文件夹里只接受相对文件名,收到的是「${name}」`);
|
|
37
37
|
}
|
|
38
|
-
if (trimmed.includes('\0') || path.isAbsolute(trimmed)) {
|
|
38
|
+
if (trimmed.includes('\0') || path.isAbsolute(trimmed) || /^[A-Za-z]:[\\/]/.test(trimmed)) {
|
|
39
39
|
throw new Error(`模块只能写自己的文件夹:不接受绝对路径「${name}」`);
|
|
40
40
|
}
|
|
41
|
-
|
|
41
|
+
if (trimmed.replace(/\\/g, '/').split('/').includes('..')) {
|
|
42
|
+
throw new Error(`模块只能写自己的文件夹:路径越界「${name}」`);
|
|
43
|
+
}
|
|
44
|
+
return trimmed;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function inside(dir: string, name: string): string {
|
|
48
|
+
const full = path.resolve(dir, safeName(name));
|
|
42
49
|
if (full !== dir && !full.startsWith(dir + path.sep)) {
|
|
43
50
|
throw new Error(`模块只能写自己的文件夹:路径越界「${name}」`);
|
|
44
51
|
}
|
|
@@ -50,15 +57,15 @@ function memoryStore(dir: string): ModuleStore {
|
|
|
50
57
|
return {
|
|
51
58
|
dir,
|
|
52
59
|
read: (name) => {
|
|
53
|
-
|
|
60
|
+
safeName(name);
|
|
54
61
|
return files.get(name) ?? null;
|
|
55
62
|
},
|
|
56
63
|
write: (name, value) => {
|
|
57
|
-
|
|
64
|
+
safeName(name);
|
|
58
65
|
files.set(name, value);
|
|
59
66
|
},
|
|
60
67
|
remove: (name) => {
|
|
61
|
-
|
|
68
|
+
safeName(name);
|
|
62
69
|
files.delete(name);
|
|
63
70
|
},
|
|
64
71
|
list: () => [...files.keys()].sort(),
|
package/src/host.ts
CHANGED
|
@@ -57,13 +57,16 @@ export * from './host/flow/expr.js';
|
|
|
57
57
|
export * from './host/flow/check.js';
|
|
58
58
|
export * from './host/flow/run.js';
|
|
59
59
|
export * from './host/flow/kit.js';
|
|
60
|
+
export * from './host/flow/step.js';
|
|
60
61
|
export * from './host/host-kit.js';
|
|
61
62
|
export * from './host/panel-state.js';
|
|
63
|
+
export * from './host/payload.js';
|
|
62
64
|
export * from './host/plugin-ledger.js';
|
|
63
65
|
export * from './host/time.js';
|
|
64
66
|
export * from './host/tokens.js';
|
|
65
67
|
export * from './host/tool-approval.js';
|
|
66
68
|
export * from './host/tool-kit.js';
|
|
69
|
+
export * from './host/unit-plugin.js';
|
|
67
70
|
export * from './host/unit-face.js';
|
|
68
71
|
export * from './host/lib-runners.js';
|
|
69
72
|
export * from './host/value-handoff.js';
|
package/src/i18n/en.ts
CHANGED
|
@@ -621,6 +621,7 @@ export const en: Record<keyof WorkbenchMessages, string> = {
|
|
|
621
621
|
'env.fix.permission': 'Permission denied: rerun with sudo, or point npm\u2019s global directory at your home.',
|
|
622
622
|
'env.fix.network': 'Network unreachable: check your network or proxy, then retry.',
|
|
623
623
|
'env.fix.details': 'Details (for troubleshooting)',
|
|
624
|
+
'env.log': 'Detailed output',
|
|
624
625
|
'env.retry': 'Retry',
|
|
625
626
|
'env.wake': 'Open browser',
|
|
626
627
|
'env.wake.opening': 'Opening the browser…',
|
package/src/i18n/zh.ts
CHANGED
|
@@ -617,6 +617,7 @@ export const zh = {
|
|
|
617
617
|
'env.fix.permission': '权限不够:在命令前加 sudo 重跑,或先把 npm 的全局目录配到你的用户目录。',
|
|
618
618
|
'env.fix.network': '网络不通:检查网络或代理设置后重试。',
|
|
619
619
|
'env.fix.details': '详细输出(排障用)',
|
|
620
|
+
'env.log': '详细输出',
|
|
620
621
|
'env.retry': '重试',
|
|
621
622
|
'env.wake': '打开浏览器',
|
|
622
623
|
'env.wake.opening': '正在打开浏览器…',
|
|
@@ -51,4 +51,12 @@ describe('干不了活是为什么:先看人在不在工坊,再看板画没
|
|
|
51
51
|
expect(insideWorkshop('/a/b', '/a/bc')).toBe(false);
|
|
52
52
|
expect(insideWorkshop('/a/b/', '/a/b/c')).toBe(true);
|
|
53
53
|
});
|
|
54
|
+
|
|
55
|
+
it('★ Windows 路径:反斜杠、混合分隔符与盘符大小写都认', () => {
|
|
56
|
+
expect(insideWorkshop('C:\\w\\ws', 'C:\\w\\ws\\packages\\module-demo')).toBe(true);
|
|
57
|
+
expect(insideWorkshop('C:\\w\\ws', 'C:/w/ws/packages/module-demo')).toBe(true);
|
|
58
|
+
expect(insideWorkshop('c:\\w\\ws', 'C:\\W\\WS\\packages')).toBe(true);
|
|
59
|
+
expect(insideWorkshop('C:\\w\\ws', 'C:\\w\\ws2')).toBe(false);
|
|
60
|
+
expect(insideWorkshop('C:\\w\\ws', 'D:\\w\\ws\\packages')).toBe(false);
|
|
61
|
+
});
|
|
54
62
|
});
|
|
@@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
3
3
|
import {
|
|
4
4
|
cx,
|
|
5
5
|
parseLocalTimestamp,
|
|
6
|
+
pathBase,
|
|
6
7
|
relativeTime,
|
|
7
8
|
relativeTimeAgo,
|
|
8
9
|
relativeTimeBucket,
|
|
@@ -112,8 +113,14 @@ describe('shortenPath', () => {
|
|
|
112
113
|
expect(shortenPath('/Users/you/dev/app')).toBe('~/dev/app');
|
|
113
114
|
});
|
|
114
115
|
|
|
116
|
+
it('Linux 与 Windows 的用户主目录也缩', () => {
|
|
117
|
+
expect(shortenPath('/home/you/dev/app')).toBe('~/dev/app');
|
|
118
|
+
expect(shortenPath('C:\\Users\\you\\dev\\app')).toBe('~\\dev\\app');
|
|
119
|
+
});
|
|
120
|
+
|
|
115
121
|
it('非用户目录原样返回', () => {
|
|
116
122
|
expect(shortenPath('/Volumes/dev/app')).toBe('/Volumes/dev/app');
|
|
123
|
+
expect(shortenPath('D:\\work\\app')).toBe('D:\\work\\app');
|
|
117
124
|
});
|
|
118
125
|
|
|
119
126
|
it('非字符串给空串', () => {
|
|
@@ -122,6 +129,25 @@ describe('shortenPath', () => {
|
|
|
122
129
|
});
|
|
123
130
|
});
|
|
124
131
|
|
|
132
|
+
describe('pathBase', () => {
|
|
133
|
+
it('取最后一段,POSIX 与 Windows 都认', () => {
|
|
134
|
+
expect(pathBase('/Users/you/dev/app')).toBe('app');
|
|
135
|
+
expect(pathBase('C:\\Users\\you\\dev\\app')).toBe('app');
|
|
136
|
+
expect(pathBase('C:/Users/you/dev/app')).toBe('app');
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('尾部有分隔符先去掉', () => {
|
|
140
|
+
expect(pathBase('/Users/you/dev/app/')).toBe('app');
|
|
141
|
+
expect(pathBase('C:\\Users\\you\\dev\\app\\')).toBe('app');
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('没有分隔符就整串,非字符串给空串', () => {
|
|
145
|
+
expect(pathBase('app')).toBe('app');
|
|
146
|
+
expect(pathBase(undefined)).toBe('');
|
|
147
|
+
expect(pathBase(42)).toBe('');
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
125
151
|
describe('parseLocalTimestamp', () => {
|
|
126
152
|
it('按本地时区解析 Coding 的时间戳形态', () => {
|
|
127
153
|
const parsed = parseLocalTimestamp('2026-09-27 08:18:36');
|
package/src/lib/board-fact.ts
CHANGED
|
@@ -29,16 +29,23 @@ export interface BoardProbe {
|
|
|
29
29
|
readonly drawn: boolean;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
function normalizePath(value: string): string {
|
|
33
|
+
const forward = value.replace(/\\/g, '/').replace(/\/+$/g, '');
|
|
34
|
+
return /^[A-Za-z]:\//.test(forward) || forward.startsWith('//') ? forward.toLowerCase() : forward;
|
|
35
|
+
}
|
|
36
|
+
|
|
32
37
|
/**
|
|
33
38
|
* 判一个路径落不落在这个工作区里(按路径前缀比,不是字符串包含)。
|
|
34
39
|
* @param root - 工作区根路径。
|
|
35
40
|
* @param target - 要判的路径。
|
|
36
|
-
* @returns 同一个路径或在其下给 `true
|
|
41
|
+
* @returns 同一个路径或在其下给 `true`;分隔符先归一(Windows 的 `\` 与 `/` 都认),
|
|
42
|
+
* 盘符路径按大小写不敏感比;`root` 末尾带不带斜杠都认(`/a` 不匹配 `/ab`)。
|
|
37
43
|
*/
|
|
38
44
|
export function insideWorkshop(root: string, target: string): boolean {
|
|
39
|
-
|
|
40
|
-
const
|
|
41
|
-
|
|
45
|
+
const base = normalizePath(root);
|
|
46
|
+
const real = normalizePath(target);
|
|
47
|
+
if (real === base) return true;
|
|
48
|
+
return real.startsWith(base === '' ? '/' : `${base}/`);
|
|
42
49
|
}
|
|
43
50
|
|
|
44
51
|
/**
|