@dsh-workbench/shared 1.1.14 → 1.1.16

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.
@@ -12,6 +12,12 @@ export interface WriteFileOptions {
12
12
  * @param options 原子/目录/权限/fsync;`mode` 默认 `'keep'`(新文件 0644)。
13
13
  */
14
14
  export declare function writeTextFile(path: string, text: string, options?: WriteFileOptions): void;
15
+ /**
16
+ * 往文件末尾追加文本;文件或目录不存在就建。追加不是原子写,只给「日志/采集」这类只增不改的流。
17
+ * @param path 目标文件。
18
+ * @param text 追加内容。
19
+ */
20
+ export declare function appendTextFile(path: string, text: string): void;
15
21
  /**
16
22
  * `writeTextFile` 的异步版;语义与默认值相同。
17
23
  * @param path 目标文件。
@@ -1,4 +1,4 @@
1
- import { chmodSync, mkdirSync, statSync, writeFileSync } from 'node:fs';
1
+ import { appendFileSync, chmodSync, mkdirSync, statSync, writeFileSync } from 'node:fs';
2
2
  import { dirname } from 'node:path';
3
3
  import writeFileAtomic from 'write-file-atomic';
4
4
  function modeOf(path, mode) {
@@ -33,6 +33,15 @@ export function writeTextFile(path, text, options = {}) {
33
33
  }
34
34
  writeFileAtomic.sync(path, text, atomicOptionsOf(mode, options));
35
35
  }
36
+ /**
37
+ * 往文件末尾追加文本;文件或目录不存在就建。追加不是原子写,只给「日志/采集」这类只增不改的流。
38
+ * @param path 目标文件。
39
+ * @param text 追加内容。
40
+ */
41
+ export function appendTextFile(path, text) {
42
+ mkdirSync(dirname(path), { recursive: true });
43
+ appendFileSync(path, text, { mode: modeOf(path, 'keep') });
44
+ }
36
45
  /**
37
46
  * `writeTextFile` 的异步版;语义与默认值相同。
38
47
  * @param path 目标文件。
@@ -1,4 +1,4 @@
1
- import { dirname, isAbsolute, resolve, sep } from 'node:path';
1
+ import { dirname, isAbsolute, parse, resolve, sep } from 'node:path';
2
2
  import { SeamError } from './intent.js';
3
3
  export const SEAM_MODES = ['read-only', 'workspace-write', 'danger-full-access'];
4
4
  export function seamModeOf(value) {
@@ -20,7 +20,7 @@ export function insideOf(root, path) {
20
20
  }
21
21
  export function productPolicy(ceiling, productRoot) {
22
22
  return {
23
- mode: ceiling === 'read-only' ? 'read-only' : 'workspace-write',
23
+ mode: ceiling === 'read-only' ? 'read-only' : ceiling === 'danger-full-access' ? 'danger-full-access' : 'workspace-write',
24
24
  workspaceRoot: resolve(productRoot),
25
25
  };
26
26
  }
@@ -34,6 +34,9 @@ export function workspacePolicy(ceiling, grant) {
34
34
  }
35
35
  return { mode, workspaceRoot: resolve(grant.workspaceRoot) };
36
36
  }
37
+ function atFilesystemRoot(path) {
38
+ return parse(path).root === path;
39
+ }
37
40
  function commonDirectory(paths) {
38
41
  const dirs = paths.map((path) => dirname(resolve(path)));
39
42
  let shared = dirs[0];
@@ -57,11 +60,11 @@ export function pathsPolicy(ceiling, paths) {
57
60
  throw new SeamError('read-only', '当前部署是只读模式:这条命令不能写外部路径');
58
61
  }
59
62
  const root = commonDirectory(paths);
60
- if (root === undefined || (root === sep && paths.length > 1)) {
61
- throw new SeamError('paths-span-trees', '这次写入跨了多个目录:请分开做,或把产物改到工作台目录');
63
+ if (root === undefined) {
64
+ throw new SeamError('paths-span-trees', '这次写入没有给出任何路径');
62
65
  }
63
- if (root === sep) {
64
- throw new SeamError('paths-span-trees', '不能把整个文件系统声明成写入域');
66
+ if (atFilesystemRoot(root)) {
67
+ throw new SeamError('paths-span-trees', paths.length > 1 ? '这次写入跨了多个目录:请分开做,或把产物改到工作台目录' : '不能把整个文件系统声明成写入域');
65
68
  }
66
69
  return { mode: 'workspace-write', workspaceRoot: root };
67
70
  }
@@ -2,7 +2,8 @@ import { now } from '../time.js';
2
2
  import { mkdirSync } from 'node:fs';
3
3
  import { dirname, join } from 'node:path';
4
4
  import { workbenchRoot } from '../data-root.js';
5
- import { quoteShellArg, shellKindOf } from '../shell-text.js';
5
+ import { shellKindOf } from '../shell-text.js';
6
+ import { callOperatorOf, invocationOf, quotedArgvLine, quotedLineOf } from '../shell-render.js';
6
7
  import { runShell } from '../shell.js';
7
8
  import { deniedPathOf, sandboxDeniedOf } from './denial.js';
8
9
  import { seamProductEnv } from './env.js';
@@ -55,29 +56,22 @@ function targetOf(request) {
55
56
  throw new SeamError('scope-not-declared', '这条执行请求要么给 argv,要么给一条命令行,不能两个都给或都不给');
56
57
  }
57
58
  function displayOf(target) {
58
- return 'argv' in target ? target.argv.map((one) => quoteShellArg(one)).join(' ') : target.line;
59
+ return 'argv' in target ? quotedArgvLine(target.argv) : target.line;
59
60
  }
60
- function shellInvocationOf(shellKind, line) {
61
- return shellKind === 'pwsh'
62
- ? { file: 'pwsh', args: ['-NoProfile', '-NonInteractive', '-Command', line] }
63
- : { file: 'bash', args: ['-c', line] };
64
- }
65
- function invocationOf(target, shellKind) {
61
+ function targetInvocationOf(target, shellKind) {
66
62
  if ('argv' in target) {
67
63
  const [file, ...args] = target.argv;
68
64
  if (file === undefined)
69
65
  throw new SeamError('scope-not-declared', 'argv 是空的:至少给一个可执行文件');
70
66
  return { file, args };
71
67
  }
72
- return shellInvocationOf(shellKind ?? shellKindOf(), target.line);
68
+ return invocationOf(shellKind ?? shellKindOf(), target.line);
73
69
  }
74
70
  function sandboxedLineOf(target, shellKind) {
75
- if ('argv' in target)
76
- return target.argv.map((one) => quoteShellArg(one)).join(' ');
71
+ const line = 'argv' in target ? quotedArgvLine(target.argv) : target.line;
77
72
  if (shellKind === undefined)
78
- return target.line;
79
- const invocation = shellInvocationOf(shellKind, target.line);
80
- return [invocation.file, ...invocation.args].map((one) => quoteShellArg(one)).join(' ');
73
+ return callOperatorOf(shellKindOf(), line);
74
+ return callOperatorOf(shellKindOf(), quotedLineOf(invocationOf(shellKind, line)));
81
75
  }
82
76
  export function createSeam(ports) {
83
77
  const runtimeRoot = ports.runtimeRoot ?? workbenchRoot();
@@ -133,7 +127,7 @@ export function createSeam(ports) {
133
127
  return undefined;
134
128
  }
135
129
  async function runHost(request, target) {
136
- const invocation = invocationOf(target, request.shellKind);
130
+ const invocation = targetInvocationOf(target, request.shellKind);
137
131
  const child = processes.spawn({
138
132
  file: invocation.file,
139
133
  args: invocation.args,
@@ -186,7 +180,7 @@ export function createSeam(ports) {
186
180
  };
187
181
  }
188
182
  function spawnHost(request) {
189
- const invocation = invocationOf(request.target, undefined);
183
+ const invocation = targetInvocationOf(request.target, undefined);
190
184
  const logFile = request.logFile ?? join(runtimeRoot, 'logs', `${request.label ?? 'spawn'}-${String(now())}.log`);
191
185
  mkdirSync(dirname(logFile), { recursive: true });
192
186
  const child = processes.spawn({
@@ -252,7 +246,7 @@ export function createSeam(ports) {
252
246
  };
253
247
  }
254
248
  function openHost(request) {
255
- const invocation = invocationOf(request.target, undefined);
249
+ const invocation = targetInvocationOf(request.target, undefined);
256
250
  const child = processes.spawn({
257
251
  file: invocation.file,
258
252
  args: invocation.args,
@@ -0,0 +1,9 @@
1
+ import type { ShellKind } from './shell-text.js';
2
+ export interface ShellInvocation {
3
+ readonly file: string;
4
+ readonly args: readonly string[];
5
+ }
6
+ export declare function callOperatorOf(kind: ShellKind, script: string): string;
7
+ export declare function invocationOf(kind: ShellKind, script: string): ShellInvocation;
8
+ export declare function quotedArgvLine(argv: readonly string[], platform?: NodeJS.Platform): string;
9
+ export declare function quotedLineOf(invocation: ShellInvocation, platform?: NodeJS.Platform): string;
@@ -0,0 +1,18 @@
1
+ import { quoteShellArgFor } from './shell-text.js';
2
+ const QUOTED_HEAD = /^\s*['"]/;
3
+ export function callOperatorOf(kind, script) {
4
+ if (kind !== 'pwsh' || !QUOTED_HEAD.test(script))
5
+ return script;
6
+ return `& ${script}`;
7
+ }
8
+ export function invocationOf(kind, script) {
9
+ return kind === 'pwsh'
10
+ ? { file: 'pwsh', args: ['-NoProfile', '-NonInteractive', '-Command', callOperatorOf('pwsh', script)] }
11
+ : { file: 'bash', args: ['-c', script] };
12
+ }
13
+ export function quotedArgvLine(argv, platform = process.platform) {
14
+ return argv.map((one) => quoteShellArgFor(one, platform)).join(' ');
15
+ }
16
+ export function quotedLineOf(invocation, platform = process.platform) {
17
+ return quotedArgvLine([invocation.file, ...invocation.args], platform);
18
+ }
package/dist/host.d.ts CHANGED
@@ -67,6 +67,7 @@ export * from './host/write-result.js';
67
67
  export * from './host/cli-error.js';
68
68
  export * from './host/data-root.js';
69
69
  export * from './host/shell-text.js';
70
+ export * from './host/shell-render.js';
70
71
  export * from './host/shell.js';
71
72
  export * from './host/seam/intent.js';
72
73
  export * from './host/seam/policy.js';
package/dist/host.js CHANGED
@@ -67,6 +67,7 @@ export * from './host/write-result.js';
67
67
  export * from './host/cli-error.js';
68
68
  export * from './host/data-root.js';
69
69
  export * from './host/shell-text.js';
70
+ export * from './host/shell-render.js';
70
71
  export * from './host/shell.js';
71
72
  export * from './host/seam/intent.js';
72
73
  export * from './host/seam/policy.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dsh-workbench/shared",
3
- "version": "1.1.14",
3
+ "version": "1.1.16",
4
4
  "description": "工作台与模块共享的纯代码与契约类型:类型、纯逻辑、哑组件、平台解析。宿主侧的东西从 /host 进(不影响浏览器产物)。",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -1,8 +1,9 @@
1
1
  import { mkdtempSync, rmSync } from 'node:fs';
2
2
  import { tmpdir } from 'node:os';
3
- import { join } from 'node:path';
3
+ import { join, resolve, sep } from 'node:path';
4
4
  import { afterAll, describe, expect, it } from 'vitest';
5
5
  import { SeamError } from '../seam/intent.js';
6
+ import { pathsPolicy } from '../seam/policy.js';
6
7
  import { createSeam } from '../seam/run.js';
7
8
  import { createUnitSeam, unitDataRoot } from '../seam/unit-seam.js';
8
9
  import { createFakeProcesses, createFakeShell, createFakeLogger } from '../../testing/host-fakes.js';
@@ -46,6 +47,13 @@ describe('createSeam', () => {
46
47
  expect(policyOf(fake.calls[0])).toEqual({ mode: 'workspace-write', workspaceRoot: ROOT });
47
48
  });
48
49
 
50
+ it('产品域跟到部署上限:danger-full-access 时不再收窄成 workspace-write', async () => {
51
+ const fake = createFakeShell([{}]);
52
+ const seam = createSeam({ shell: fake.shell, runtimeRoot: ROOT, ceiling: 'danger-full-access' });
53
+ await seam.run({ command: 'npm view x version', intent: { kind: 'product' } });
54
+ expect(policyOf(fake.calls[0])).toEqual({ mode: 'danger-full-access', workspaceRoot: ROOT });
55
+ });
56
+
49
57
  it('工作区域:取会话解析结果;上限更宽时按会话', async () => {
50
58
  const fake = createFakeShell([{}]);
51
59
  const seam = createSeam({
@@ -80,6 +88,15 @@ describe('createSeam', () => {
80
88
  expect(fake.calls).toHaveLength(0);
81
89
  });
82
90
 
91
+ it('路径共同根是文件系统根就拒(win32 的盘根也算)', () => {
92
+ const root = resolve(sep);
93
+ expect(() => pathsPolicy('workspace-write', [join(root, 'a.png'), join(root, 'b.png')])).toThrow(SeamError);
94
+ expect(pathsPolicy('workspace-write', [join(SCRATCH, 'a.png'), join(SCRATCH, 'b.png')])).toEqual({
95
+ mode: 'workspace-write',
96
+ workspaceRoot: SCRATCH,
97
+ });
98
+ });
99
+
83
100
  it('外部路径:同一目录可以,跨目录拒绝', async () => {
84
101
  const fake = createFakeShell([{}]);
85
102
  const seam = createSeam({ shell: fake.shell, runtimeRoot: ROOT });
@@ -291,6 +308,47 @@ describe('宿主域:run / spawn / open', () => {
291
308
  const fake = createFakeShell([{}]);
292
309
  const seam = createSeam({ shell: fake.shell, runtimeRoot: ROOT });
293
310
  await seam.run({ argv: ['npm', 'view', 'left-pad', 'version'], intent: { kind: 'product' } });
294
- expect(fake.calls[0]?.command).toBe("'npm' 'view' 'left-pad' 'version'");
311
+ const call = process.platform === 'win32' ? "& 'npm' 'view' 'left-pad' 'version'" : "'npm' 'view' 'left-pad' 'version'";
312
+ expect(fake.calls[0]?.command).toBe(call);
313
+ });
314
+ });
315
+
316
+ describe('pwsh 命令行', () => {
317
+ it('host 域:命令行以引号开头时补 & 调用符', async () => {
318
+ const fake = createFakeProcesses([{}]);
319
+ const seam = createSeam({ shell: createFakeShell([]).shell, runtimeRoot: ROOT, allowHost: true, processes: fake.host });
320
+ await seam.run({
321
+ command: "'C:\\Program Files\\opencli\\opencli.cmd' 'browser' 'list'",
322
+ shellKind: 'pwsh',
323
+ intent: { kind: 'host' },
324
+ });
325
+ expect(fake.calls[0]?.file).toBe('pwsh');
326
+ expect(fake.calls[0]?.args).toEqual([
327
+ '-NoProfile',
328
+ '-NonInteractive',
329
+ '-Command',
330
+ "& 'C:\\Program Files\\opencli\\opencli.cmd' 'browser' 'list'",
331
+ ]);
332
+ });
333
+
334
+ it('host 域:不以引号开头的命令行不加 &', async () => {
335
+ const fake = createFakeProcesses([{}]);
336
+ const seam = createSeam({ shell: createFakeShell([]).shell, runtimeRoot: ROOT, allowHost: true, processes: fake.host });
337
+ await seam.run({ command: 'pnpm add left-pad', shellKind: 'pwsh', intent: { kind: 'host' } });
338
+ expect(fake.calls[0]?.args?.at(-1)).toBe('pnpm add left-pad');
339
+ });
340
+
341
+ it('沙箱域:pwsh 下带引号的命令行也落在 -Command 里补 &', async () => {
342
+ const fake = createFakeShell([{}]);
343
+ const seam = createSeam({ shell: fake.shell, runtimeRoot: ROOT });
344
+ await seam.run({ command: "'opencli' 'browser' 'list'", shellKind: 'pwsh', intent: { kind: 'none' } });
345
+ expect(fake.calls[0]?.command).toMatch(/'-Command' '& /);
346
+ });
347
+
348
+ it('沙箱域:pwsh 下普通命令行原样进 -Command', async () => {
349
+ const fake = createFakeShell([{}]);
350
+ const seam = createSeam({ shell: fake.shell, runtimeRoot: ROOT });
351
+ await seam.run({ command: 'pnpm install', shellKind: 'pwsh', intent: { kind: 'none' } });
352
+ expect(fake.calls[0]?.command).toContain("'-Command' 'pnpm install'");
295
353
  });
296
354
  });
@@ -0,0 +1,58 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { callOperatorOf, invocationOf, quotedArgvLine, quotedLineOf } from '../shell-render.js';
3
+
4
+ describe('callOperatorOf', () => {
5
+ it('pwsh:命令位置带引号时补 & 调用符', () => {
6
+ expect(callOperatorOf('pwsh', "'C:\\Program Files\\opencli\\opencli.cmd' 'browser'")).toBe(
7
+ "& 'C:\\Program Files\\opencli\\opencli.cmd' 'browser'",
8
+ );
9
+ expect(callOperatorOf('pwsh', '"C:\\tools\\x.cmd" list')).toBe('& "C:\\tools\\x.cmd" list');
10
+ });
11
+
12
+ it('pwsh:不带引号(或以空白开头的不带引号)原样', () => {
13
+ expect(callOperatorOf('pwsh', 'pnpm install')).toBe('pnpm install');
14
+ expect(callOperatorOf('pwsh', ' pnpm install')).toBe(' pnpm install');
15
+ });
16
+
17
+ it('posix 不管引号:bash 里命令位置本来就是命令', () => {
18
+ expect(callOperatorOf('posix', "'opencli' 'browser'")).toBe("'opencli' 'browser'");
19
+ });
20
+ });
21
+
22
+ describe('invocationOf', () => {
23
+ it('pwsh:-NoProfile -NonInteractive -Command,载荷带上 &', () => {
24
+ expect(invocationOf('pwsh', "'opencli' 'browser' 'list'")).toEqual({
25
+ file: 'pwsh',
26
+ args: ['-NoProfile', '-NonInteractive', '-Command', "& 'opencli' 'browser' 'list'"],
27
+ });
28
+ });
29
+
30
+ it('pwsh:普通命令行不加 &', () => {
31
+ expect(invocationOf('pwsh', 'pnpm add left-pad')).toEqual({
32
+ file: 'pwsh',
33
+ args: ['-NoProfile', '-NonInteractive', '-Command', 'pnpm add left-pad'],
34
+ });
35
+ });
36
+
37
+ it('posix:bash -c,脚本原样', () => {
38
+ expect(invocationOf('posix', "'opencli' 'browser'")).toEqual({ file: 'bash', args: ['-c', "'opencli' 'browser'"] });
39
+ });
40
+ });
41
+
42
+ describe('quotedArgvLine / quotedLineOf', () => {
43
+ it('posix:单引号;内嵌引号关引号再转义', () => {
44
+ expect(quotedArgvLine(['a b', "c'd"], 'darwin')).toBe(`'a b' 'c'\\''d'`);
45
+ });
46
+
47
+ it('win32:单引号 + 内嵌引号双写', () => {
48
+ expect(quotedArgvLine(['a b', "c'd"], 'win32')).toBe("'a b' 'c''d'");
49
+ });
50
+
51
+ it('空 argv 给空串', () => {
52
+ expect(quotedArgvLine([], 'darwin')).toBe('');
53
+ });
54
+
55
+ it('整条 invocation 按目标平台引号', () => {
56
+ expect(quotedLineOf({ file: 'pwsh', args: ['-Command', 'dir'] }, 'win32')).toBe("'pwsh' '-Command' 'dir'");
57
+ });
58
+ });
@@ -1,4 +1,4 @@
1
- import { chmodSync, mkdirSync, statSync, writeFileSync } from 'node:fs';
1
+ import { appendFileSync, chmodSync, mkdirSync, statSync, writeFileSync } from 'node:fs';
2
2
  import { dirname } from 'node:path';
3
3
  import writeFileAtomic from 'write-file-atomic';
4
4
 
@@ -44,6 +44,16 @@ export function writeTextFile(path: string, text: string, options: WriteFileOpti
44
44
  writeFileAtomic.sync(path, text, atomicOptionsOf(mode, options));
45
45
  }
46
46
 
47
+ /**
48
+ * 往文件末尾追加文本;文件或目录不存在就建。追加不是原子写,只给「日志/采集」这类只增不改的流。
49
+ * @param path 目标文件。
50
+ * @param text 追加内容。
51
+ */
52
+ export function appendTextFile(path: string, text: string): void {
53
+ mkdirSync(dirname(path), { recursive: true });
54
+ appendFileSync(path, text, { mode: modeOf(path, 'keep') });
55
+ }
56
+
47
57
  /**
48
58
  * `writeTextFile` 的异步版;语义与默认值相同。
49
59
  * @param path 目标文件。
@@ -1,4 +1,4 @@
1
- import { dirname, isAbsolute, resolve, sep } from 'node:path';
1
+ import { dirname, isAbsolute, parse, resolve, sep } from 'node:path';
2
2
  import type { DenialScope, SeamMode, SeamPolicy, WriteIntent } from './intent.js';
3
3
  import { SeamError } from './intent.js';
4
4
 
@@ -26,7 +26,7 @@ export function insideOf(root: string, path: string): boolean {
26
26
 
27
27
  export function productPolicy(ceiling: SeamMode, productRoot: string): SeamPolicy {
28
28
  return {
29
- mode: ceiling === 'read-only' ? 'read-only' : 'workspace-write',
29
+ mode: ceiling === 'read-only' ? 'read-only' : ceiling === 'danger-full-access' ? 'danger-full-access' : 'workspace-write',
30
30
  workspaceRoot: resolve(productRoot),
31
31
  };
32
32
  }
@@ -42,6 +42,10 @@ export function workspacePolicy(ceiling: SeamMode, grant: SeamPolicy | undefined
42
42
  return { mode, workspaceRoot: resolve(grant.workspaceRoot) };
43
43
  }
44
44
 
45
+ function atFilesystemRoot(path: string): boolean {
46
+ return parse(path).root === path;
47
+ }
48
+
45
49
  function commonDirectory(paths: readonly string[]): string | undefined {
46
50
  const dirs = paths.map((path) => dirname(resolve(path)));
47
51
  let shared = dirs[0];
@@ -65,11 +69,14 @@ export function pathsPolicy(ceiling: SeamMode, paths: readonly string[]): SeamPo
65
69
  throw new SeamError('read-only', '当前部署是只读模式:这条命令不能写外部路径');
66
70
  }
67
71
  const root = commonDirectory(paths);
68
- if (root === undefined || (root === sep && paths.length > 1)) {
69
- throw new SeamError('paths-span-trees', '这次写入跨了多个目录:请分开做,或把产物改到工作台目录');
72
+ if (root === undefined) {
73
+ throw new SeamError('paths-span-trees', '这次写入没有给出任何路径');
70
74
  }
71
- if (root === sep) {
72
- throw new SeamError('paths-span-trees', '不能把整个文件系统声明成写入域');
75
+ if (atFilesystemRoot(root)) {
76
+ throw new SeamError(
77
+ 'paths-span-trees',
78
+ paths.length > 1 ? '这次写入跨了多个目录:请分开做,或把产物改到工作台目录' : '不能把整个文件系统声明成写入域',
79
+ );
73
80
  }
74
81
  return { mode: 'workspace-write', workspaceRoot: root };
75
82
  }
@@ -3,7 +3,8 @@ import { mkdirSync } from 'node:fs';
3
3
  import { dirname, join } from 'node:path';
4
4
  import type { DshShell } from '../../api/dsh.js';
5
5
  import { workbenchRoot } from '../data-root.js';
6
- import { quoteShellArg, shellKindOf } from '../shell-text.js';
6
+ import { shellKindOf } from '../shell-text.js';
7
+ import { callOperatorOf, invocationOf, quotedArgvLine, quotedLineOf } from '../shell-render.js';
7
8
  import { runShell, type ShellResult } from '../shell.js';
8
9
  import { deniedPathOf, sandboxDeniedOf } from './denial.js';
9
10
  import { seamProductEnv } from './env.js';
@@ -119,29 +120,22 @@ function targetOf(request: {
119
120
  }
120
121
 
121
122
  function displayOf(target: SeamTarget): string {
122
- return 'argv' in target ? target.argv.map((one) => quoteShellArg(one)).join(' ') : target.line;
123
+ return 'argv' in target ? quotedArgvLine(target.argv) : target.line;
123
124
  }
124
125
 
125
- function shellInvocationOf(shellKind: SeamShellKind, line: string): { file: string; args: string[] } {
126
- return shellKind === 'pwsh'
127
- ? { file: 'pwsh', args: ['-NoProfile', '-NonInteractive', '-Command', line] }
128
- : { file: 'bash', args: ['-c', line] };
129
- }
130
-
131
- function invocationOf(target: SeamTarget, shellKind: SeamShellKind | undefined): { file: string; args: string[] } {
126
+ function targetInvocationOf(target: SeamTarget, shellKind: SeamShellKind | undefined): { file: string; args: readonly string[] } {
132
127
  if ('argv' in target) {
133
128
  const [file, ...args] = target.argv;
134
129
  if (file === undefined) throw new SeamError('scope-not-declared', 'argv 是空的:至少给一个可执行文件');
135
130
  return { file, args };
136
131
  }
137
- return shellInvocationOf(shellKind ?? shellKindOf(), target.line);
132
+ return invocationOf(shellKind ?? shellKindOf(), target.line);
138
133
  }
139
134
 
140
135
  function sandboxedLineOf(target: SeamTarget, shellKind: SeamShellKind | undefined): string {
141
- if ('argv' in target) return target.argv.map((one) => quoteShellArg(one)).join(' ');
142
- if (shellKind === undefined) return target.line;
143
- const invocation = shellInvocationOf(shellKind, target.line);
144
- return [invocation.file, ...invocation.args].map((one) => quoteShellArg(one)).join(' ');
136
+ const line = 'argv' in target ? quotedArgvLine(target.argv) : target.line;
137
+ if (shellKind === undefined) return callOperatorOf(shellKindOf(), line);
138
+ return callOperatorOf(shellKindOf(), quotedLineOf(invocationOf(shellKind, line)));
145
139
  }
146
140
 
147
141
  export function createSeam(ports: SeamPorts): Seam {
@@ -197,7 +191,7 @@ export function createSeam(ports: SeamPorts): Seam {
197
191
  }
198
192
 
199
193
  async function runHost(request: SeamRequest, target: SeamTarget): Promise<ShellResult> {
200
- const invocation = invocationOf(target, request.shellKind);
194
+ const invocation = targetInvocationOf(target, request.shellKind);
201
195
  const child = processes.spawn({
202
196
  file: invocation.file,
203
197
  args: invocation.args,
@@ -249,7 +243,7 @@ export function createSeam(ports: SeamPorts): Seam {
249
243
  }
250
244
 
251
245
  function spawnHost(request: SpawnRequest): SpawnHandle {
252
- const invocation = invocationOf(request.target, undefined);
246
+ const invocation = targetInvocationOf(request.target, undefined);
253
247
  const logFile =
254
248
  request.logFile ?? join(runtimeRoot, 'logs', `${request.label ?? 'spawn'}-${String(now())}.log`);
255
249
  mkdirSync(dirname(logFile), { recursive: true });
@@ -315,7 +309,7 @@ export function createSeam(ports: SeamPorts): Seam {
315
309
  }
316
310
 
317
311
  function openHost(request: OpenRequest): ChannelHandle {
318
- const invocation = invocationOf(request.target, undefined);
312
+ const invocation = targetInvocationOf(request.target, undefined);
319
313
  const child: ProcessChild = processes.spawn({
320
314
  file: invocation.file,
321
315
  args: invocation.args,
@@ -0,0 +1,28 @@
1
+ import type { ShellKind } from './shell-text.js';
2
+ import { quoteShellArgFor } from './shell-text.js';
3
+
4
+ export interface ShellInvocation {
5
+ readonly file: string;
6
+ readonly args: readonly string[];
7
+ }
8
+
9
+ const QUOTED_HEAD = /^\s*['"]/;
10
+
11
+ export function callOperatorOf(kind: ShellKind, script: string): string {
12
+ if (kind !== 'pwsh' || !QUOTED_HEAD.test(script)) return script;
13
+ return `& ${script}`;
14
+ }
15
+
16
+ export function invocationOf(kind: ShellKind, script: string): ShellInvocation {
17
+ return kind === 'pwsh'
18
+ ? { file: 'pwsh', args: ['-NoProfile', '-NonInteractive', '-Command', callOperatorOf('pwsh', script)] }
19
+ : { file: 'bash', args: ['-c', script] };
20
+ }
21
+
22
+ export function quotedArgvLine(argv: readonly string[], platform: NodeJS.Platform = process.platform): string {
23
+ return argv.map((one) => quoteShellArgFor(one, platform)).join(' ');
24
+ }
25
+
26
+ export function quotedLineOf(invocation: ShellInvocation, platform: NodeJS.Platform = process.platform): string {
27
+ return quotedArgvLine([invocation.file, ...invocation.args], platform);
28
+ }
package/src/host.ts CHANGED
@@ -77,6 +77,7 @@ export * from './host/write-result.js';
77
77
  export * from './host/cli-error.js';
78
78
  export * from './host/data-root.js';
79
79
  export * from './host/shell-text.js';
80
+ export * from './host/shell-render.js';
80
81
  export * from './host/shell.js';
81
82
  export * from './host/seam/intent.js';
82
83
  export * from './host/seam/policy.js';