@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.
- package/dist/host/fs-write.d.ts +6 -0
- package/dist/host/fs-write.js +10 -1
- package/dist/host/seam/policy.js +9 -6
- package/dist/host/seam/run.js +11 -17
- package/dist/host/shell-render.d.ts +9 -0
- package/dist/host/shell-render.js +18 -0
- package/dist/host.d.ts +1 -0
- package/dist/host.js +1 -0
- package/package.json +1 -1
- package/src/host/__tests__/seam.test.ts +60 -2
- package/src/host/__tests__/shell-render.test.ts +58 -0
- package/src/host/fs-write.ts +11 -1
- package/src/host/seam/policy.ts +13 -6
- package/src/host/seam/run.ts +11 -17
- package/src/host/shell-render.ts +28 -0
- package/src/host.ts +1 -0
package/dist/host/fs-write.d.ts
CHANGED
|
@@ -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 目标文件。
|
package/dist/host/fs-write.js
CHANGED
|
@@ -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 目标文件。
|
package/dist/host/seam/policy.js
CHANGED
|
@@ -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
|
|
61
|
-
throw new SeamError('paths-span-trees', '
|
|
63
|
+
if (root === undefined) {
|
|
64
|
+
throw new SeamError('paths-span-trees', '这次写入没有给出任何路径');
|
|
62
65
|
}
|
|
63
|
-
if (root
|
|
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
|
}
|
package/dist/host/seam/run.js
CHANGED
|
@@ -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 {
|
|
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
|
|
59
|
+
return 'argv' in target ? quotedArgvLine(target.argv) : target.line;
|
|
59
60
|
}
|
|
60
|
-
function
|
|
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
|
|
68
|
+
return invocationOf(shellKind ?? shellKindOf(), target.line);
|
|
73
69
|
}
|
|
74
70
|
function sandboxedLineOf(target, shellKind) {
|
|
75
|
-
|
|
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
|
|
79
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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,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
|
-
|
|
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
|
+
});
|
package/src/host/fs-write.ts
CHANGED
|
@@ -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 目标文件。
|
package/src/host/seam/policy.ts
CHANGED
|
@@ -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
|
|
69
|
-
throw new SeamError('paths-span-trees', '
|
|
72
|
+
if (root === undefined) {
|
|
73
|
+
throw new SeamError('paths-span-trees', '这次写入没有给出任何路径');
|
|
70
74
|
}
|
|
71
|
-
if (root
|
|
72
|
-
throw new SeamError(
|
|
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
|
}
|
package/src/host/seam/run.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
123
|
+
return 'argv' in target ? quotedArgvLine(target.argv) : target.line;
|
|
123
124
|
}
|
|
124
125
|
|
|
125
|
-
function
|
|
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
|
|
132
|
+
return invocationOf(shellKind ?? shellKindOf(), target.line);
|
|
138
133
|
}
|
|
139
134
|
|
|
140
135
|
function sandboxedLineOf(target: SeamTarget, shellKind: SeamShellKind | undefined): string {
|
|
141
|
-
|
|
142
|
-
if (shellKind === undefined) return
|
|
143
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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';
|