@dsh-workbench/shared 1.1.14-beta.2 → 1.1.15

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.
Files changed (144) hide show
  1. package/README.md +1 -1
  2. package/dist/api/dsh.d.ts +12 -0
  3. package/dist/components/SelectFieldWheel.d.ts +9 -0
  4. package/dist/components/SelectFieldWheel.js +35 -0
  5. package/dist/contract/unit-declaration.js +15 -1
  6. package/dist/contract/unit.d.ts +3 -0
  7. package/dist/host/__scratch-guard.d.ts +1 -0
  8. package/dist/host/__scratch-guard.js +1 -0
  9. package/dist/host/adapter-set.js +0 -1
  10. package/dist/host/changelog.js +2 -1
  11. package/dist/host/channel-plugin.d.ts +15 -0
  12. package/dist/host/channel-plugin.js +42 -0
  13. package/dist/host/command-name.d.ts +1 -0
  14. package/dist/host/command-name.js +14 -0
  15. package/dist/host/config-sync.d.ts +1 -9
  16. package/dist/host/config-sync.js +3 -78
  17. package/dist/host/env-policy.d.ts +75 -0
  18. package/dist/host/env-policy.js +161 -0
  19. package/dist/host/extension.js +2 -1
  20. package/dist/host/flow/run.js +2 -1
  21. package/dist/host/fs-write.d.ts +68 -0
  22. package/dist/host/fs-write.js +121 -0
  23. package/dist/host/host-kit.d.ts +6 -2
  24. package/dist/host/host-kit.js +3 -1
  25. package/dist/host/http.js +11 -5
  26. package/dist/host/installed-sources.js +4 -5
  27. package/dist/host/long-task.js +3 -3
  28. package/dist/host/opencli-runner.d.ts +26 -0
  29. package/dist/host/opencli-runner.js +50 -0
  30. package/dist/host/package-content.d.ts +2 -2
  31. package/dist/host/package-content.js +20 -6
  32. package/dist/host/package-route.d.ts +3 -3
  33. package/dist/host/package-route.js +7 -6
  34. package/dist/host/panel-state.js +2 -1
  35. package/dist/host/patch-blocks.d.ts +13 -0
  36. package/dist/host/patch-blocks.js +52 -0
  37. package/dist/host/patch.d.ts +3 -8
  38. package/dist/host/patch.js +5 -76
  39. package/dist/host/plugin-source.d.ts +33 -0
  40. package/dist/host/plugin-source.js +91 -0
  41. package/dist/host/port-types.d.ts +9 -9
  42. package/dist/host/profile-registries.js +2 -3
  43. package/dist/host/registry-declaration.d.ts +2 -2
  44. package/dist/host/registry-declaration.js +14 -5
  45. package/dist/host/release-probe.d.ts +4 -4
  46. package/dist/host/release-probe.js +39 -14
  47. package/dist/host/runner.d.ts +26 -0
  48. package/dist/host/runner.js +50 -0
  49. package/dist/host/runtime-links.d.ts +7 -0
  50. package/dist/host/runtime-links.js +25 -0
  51. package/dist/host/seam/denial.d.ts +4 -0
  52. package/dist/host/seam/denial.js +24 -0
  53. package/dist/host/seam/env.d.ts +15 -0
  54. package/dist/host/seam/env.js +17 -0
  55. package/dist/host/seam/exec.d.ts +84 -0
  56. package/dist/host/seam/exec.js +1 -0
  57. package/dist/host/seam/host-env.d.ts +2 -0
  58. package/dist/host/seam/host-env.js +18 -0
  59. package/dist/host/seam/intent.d.ts +66 -0
  60. package/dist/host/seam/intent.js +24 -0
  61. package/dist/host/seam/policy.d.ts +10 -0
  62. package/dist/host/seam/policy.js +88 -0
  63. package/dist/host/seam/process.d.ts +2 -0
  64. package/dist/host/seam/process.js +80 -0
  65. package/dist/host/seam/run.d.ts +44 -0
  66. package/dist/host/seam/run.js +415 -0
  67. package/dist/host/seam/service.d.ts +4 -0
  68. package/dist/host/seam/service.js +4 -0
  69. package/dist/host/seam/unit-seam.d.ts +14 -0
  70. package/dist/host/seam/unit-seam.js +43 -0
  71. package/dist/host/shell-render.d.ts +9 -0
  72. package/dist/host/shell-render.js +18 -0
  73. package/dist/host/shell.d.ts +12 -0
  74. package/dist/host/shell.js +3 -1
  75. package/dist/host/time.d.ts +10 -0
  76. package/dist/host/time.js +14 -0
  77. package/dist/host/value-handoff.js +3 -2
  78. package/dist/host/workspace-store.js +2 -8
  79. package/dist/host.d.ts +12 -0
  80. package/dist/host.js +12 -0
  81. package/dist/i18n/en.js +3 -3
  82. package/dist/i18n/index.d.ts +4 -4
  83. package/dist/i18n/zh.d.ts +3 -3
  84. package/dist/i18n/zh.js +3 -3
  85. package/dist/lib/version.d.ts +4 -0
  86. package/dist/lib/version.js +21 -0
  87. package/dist/testing/fake-adapter-set.js +0 -1
  88. package/dist/testing/git.d.ts +3 -0
  89. package/dist/testing/git.js +36 -0
  90. package/dist/testing/host-fakes.d.ts +30 -0
  91. package/dist/testing/host-fakes.js +104 -0
  92. package/package.json +5 -2
  93. package/src/api/dsh.ts +8 -1
  94. package/src/contract/unit-declaration.ts +20 -1
  95. package/src/contract/unit.ts +3 -0
  96. package/src/host/__tests__/env-policy.test.ts +114 -0
  97. package/src/host/__tests__/fs-write.test.ts +96 -0
  98. package/src/host/__tests__/preflight-route.test.ts +29 -0
  99. package/src/host/__tests__/seam.test.ts +354 -0
  100. package/src/host/__tests__/shell-render.test.ts +58 -0
  101. package/src/host/__tests__/shell.test.ts +24 -0
  102. package/src/host/__tests__/time.test.ts +20 -0
  103. package/src/host/adapter-set.ts +0 -1
  104. package/src/host/changelog.ts +2 -1
  105. package/src/host/config-sync.ts +3 -90
  106. package/src/host/env-policy.ts +192 -0
  107. package/src/host/extension.ts +2 -1
  108. package/src/host/flow/run.ts +2 -1
  109. package/src/host/fs-write.ts +147 -0
  110. package/src/host/host-kit.ts +7 -2
  111. package/src/host/http.ts +11 -5
  112. package/src/host/installed-sources.ts +4 -5
  113. package/src/host/long-task.ts +3 -3
  114. package/src/host/package-content.ts +23 -13
  115. package/src/host/package-route.ts +10 -9
  116. package/src/host/panel-state.ts +2 -1
  117. package/src/host/patch-blocks.ts +67 -0
  118. package/src/host/patch.ts +5 -89
  119. package/src/host/port-types.ts +9 -9
  120. package/src/host/profile-registries.ts +2 -3
  121. package/src/host/registry-declaration.ts +17 -12
  122. package/src/host/release-probe.ts +47 -34
  123. package/src/host/seam/denial.ts +26 -0
  124. package/src/host/seam/env.ts +23 -0
  125. package/src/host/seam/exec.ts +94 -0
  126. package/src/host/seam/host-env.ts +20 -0
  127. package/src/host/seam/intent.ts +101 -0
  128. package/src/host/seam/policy.ts +96 -0
  129. package/src/host/seam/process.ts +90 -0
  130. package/src/host/seam/run.ts +478 -0
  131. package/src/host/seam/service.ts +8 -0
  132. package/src/host/seam/unit-seam.ts +62 -0
  133. package/src/host/shell-render.ts +28 -0
  134. package/src/host/shell.ts +17 -1
  135. package/src/host/time.ts +16 -0
  136. package/src/host/value-handoff.ts +3 -2
  137. package/src/host/workspace-store.ts +2 -8
  138. package/src/host.ts +12 -0
  139. package/src/i18n/en.ts +3 -3
  140. package/src/i18n/zh.ts +3 -3
  141. package/src/testing/fake-adapter-set.ts +0 -1
  142. package/src/testing/host-fakes.ts +126 -0
  143. package/dist/lib/unit-plugin.d.ts +0 -28
  144. package/dist/lib/unit-plugin.js +0 -23
@@ -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
+ });
@@ -24,7 +24,31 @@ function replyingShell(exitCode: number, timedOut = false): DshShell {
24
24
  };
25
25
  }
26
26
 
27
+ function sandboxedShell(): DshShell {
28
+ return {
29
+ resolve: (request) => request,
30
+ execute: async () => ({
31
+ result: async () => ({
32
+ exitCode: 3_221_225_794,
33
+ timedOut: false,
34
+ aborted: false,
35
+ stdout: { text: '', truncated: false },
36
+ stderr: { text: 'windows-acl-run: token setup failed\n', truncated: false },
37
+ sandbox: { mode: 'workspace-write', denied: false, runnerFailed: true },
38
+ }),
39
+ }),
40
+ };
41
+ }
42
+
27
43
  describe('runShell', () => {
44
+ it('★ 沙箱事实原样带回来:runnerFailed 不当成"命令自己退出"', async () => {
45
+ const result = await runShell(sandboxedShell(), 'echo hi');
46
+
47
+ expect(result.exitCode).toBe(3_221_225_794);
48
+ expect(result.sandbox?.runnerFailed).toBe(true);
49
+ expect(result.stderr).toContain('windows-acl-run');
50
+ });
51
+
28
52
  it('★ 宿主一直不交结果 → 到点自己收尾,不把调用方挂住', async () => {
29
53
  vi.useFakeTimers();
30
54
  try {
@@ -0,0 +1,20 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+ import { clockOf, epochOf, now, nowIso } from '../time.js';
3
+
4
+ afterEach(() => {
5
+ vi.useRealTimers();
6
+ });
7
+
8
+ describe('宿主时间入口', () => {
9
+ it('now 读的是 Date.now,nowIso 是合法 ISO', () => {
10
+ vi.useFakeTimers();
11
+ vi.setSystemTime(new Date('2026-01-02T03:04:05.000Z'));
12
+ expect(now()).toBe(Date.parse('2026-01-02T03:04:05.000Z'));
13
+ expect(nowIso()).toBe('2026-01-02T03:04:05.000Z');
14
+ });
15
+
16
+ it('格式化助手仍只看传入值', () => {
17
+ expect(clockOf(0)).toBe('00:00:00');
18
+ expect(epochOf('1970-01-01T00:00:01.000Z')).toBe(1000);
19
+ });
20
+ });
@@ -85,7 +85,6 @@ const FULL_METHODS = [
85
85
  ['runtime', 'runners'],
86
86
  ['runtime', 'health'],
87
87
  ['runtime', 'seed'],
88
- ['runtime', 'warnUnwritableRoot'],
89
88
  ['plugins', 'declarationLookup'],
90
89
  ['plugins', 'createUnits'],
91
90
  ['actions', 'configSyncHandler'],
@@ -1,3 +1,4 @@
1
+ import { now as clockNow } from './time.js';
1
2
  import dayjs from 'dayjs';
2
3
 
3
4
  export const CHANGELOG_FILE = 'CHANGELOG.md';
@@ -37,7 +38,7 @@ export function changelogVersion(value: string): string {
37
38
  .replace(/^v/, '');
38
39
  }
39
40
 
40
- export function changelogDate(now: Date = new Date()): string {
41
+ export function changelogDate(now: Date = new Date(clockNow())): string {
41
42
  return dayjs(now).format('YYYY-MM-DD');
42
43
  }
43
44
 
@@ -1,26 +1,12 @@
1
- import { existsSync, readFileSync, readdirSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
1
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import type { PostResource, RouteHandler } from './http.js';
4
4
  import type { ConfigSyncProfile, ConfigSyncRow, ConfigSyncSnapshot } from '../contract/protocol/config-sync.js';
5
5
  import { CONFIG_SYNC_RESOURCE } from '../contract/protocol/config-sync.js';
6
+ import { writePatchFile } from './patch.js';
6
7
 
7
- export interface PatchBlock {
8
- readonly key: string;
9
- readonly id: string;
10
- readonly insert: boolean;
11
- readonly body: string;
12
- readonly names: readonly string[];
13
- }
14
-
15
- interface SpannedBlock extends PatchBlock {
16
- readonly from: number;
17
- readonly to: number;
18
- }
8
+ import { patchBlocks, spannedBlocks, type PatchBlock } from './patch-blocks.js';
19
9
 
20
- const ID_LINE = /^- id:[ \t]*"?([^"\s#]+)/;
21
- const INSERT_LINE = /^- insert:[ \t]*$/;
22
- const NESTED_ID = /^[ \t]+-[ \t]*id:[ \t]*"?([^"\s#]+)/;
23
- const NAME_LINE = /^[ \t]*-?[ \t]*name:[ \t]*['"]?([^'"\s#]+)/;
24
10
  const COMMENT_LINE = /^[ \t]*#/;
25
11
  const ISOLATION_IDS = new Set([
26
12
  'session-persistence-jsonl',
@@ -29,56 +15,6 @@ const ISOLATION_IDS = new Set([
29
15
  'sandbox-policy',
30
16
  ]);
31
17
 
32
- function trimmedTail(lines: readonly string[]): readonly string[] {
33
- let end = lines.length;
34
- while (end > 0 && (lines[end - 1] ?? '').trim() === '') end -= 1;
35
- return lines.slice(0, end);
36
- }
37
-
38
- function matched(text: string, pattern: RegExp): string | undefined {
39
- const found = pattern.exec(text);
40
- return found === null ? undefined : found[1];
41
- }
42
-
43
- function blockOf(body: string, index: number): PatchBlock {
44
- const lines = body.split('\n');
45
- const first = lines[0] ?? '';
46
- const id = matched(first, ID_LINE);
47
- const nested = lines.flatMap((line) => {
48
- const one = matched(line, NESTED_ID);
49
- return one === undefined ? [] : [one];
50
- });
51
- const insert = id === undefined && INSERT_LINE.test(first);
52
- const label = id ?? nested.join(', ');
53
- const key = id !== undefined ? `id:${id}` : insert ? `insert:${nested.join(', ')}` : `block:${String(index)}`;
54
- const names = lines.flatMap((line) => {
55
- const one = matched(line, NAME_LINE);
56
- return one === undefined ? [] : [one];
57
- });
58
- return { key, id: label === '' ? key : label, insert, body, names };
59
- }
60
-
61
- function spannedBlocks(text: string): SpannedBlock[] {
62
- const lines = text.split('\n');
63
- const starts: number[] = [];
64
- lines.forEach((line, index) => {
65
- if (/^- /.test(line) || line === '-') starts.push(index);
66
- });
67
- const blocks: SpannedBlock[] = [];
68
- for (let i = 0; i < starts.length; i += 1) {
69
- const from = starts[i] ?? 0;
70
- const to = i + 1 < starts.length ? (starts[i + 1] ?? lines.length) : lines.length;
71
- const body = trimmedTail(lines.slice(from, to)).join('\n');
72
- if (body.trim() === '') continue;
73
- blocks.push({ ...blockOf(body, blocks.length), from, to });
74
- }
75
- return blocks;
76
- }
77
-
78
- export function patchBlocks(text: string): PatchBlock[] {
79
- return spannedBlocks(text).map(({ from: _from, to: _to, ...block }) => block);
80
- }
81
-
82
18
  export function normalizeBlock(body: string): string {
83
19
  return body
84
20
  .split('\n')
@@ -184,29 +120,6 @@ export function readPatchBlocks(
184
120
  }
185
121
  }
186
122
 
187
- function patchMode(path: string): number {
188
- try {
189
- return statSync(path).mode & 0o777;
190
- } catch {
191
- return 0o644;
192
- }
193
- }
194
-
195
- export function writePatchFile(path: string, text: string): void {
196
- const mode = patchMode(path);
197
- const tmp = `${path}.wb-sync-${String(process.pid)}`;
198
- writeFileSync(tmp, text, { mode });
199
- try {
200
- renameSync(tmp, path);
201
- } catch (error) {
202
- try {
203
- unlinkSync(tmp);
204
- } catch {
205
- throw error;
206
- }
207
- throw error;
208
- }
209
- }
210
123
 
211
124
  export interface ConfigSyncDeps {
212
125
  readonly home: string;
@@ -0,0 +1,192 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { delimiter, join } from 'node:path';
4
+ import { dshHome } from './data-root.js';
5
+ import type { ShellKind } from './shell-text.js';
6
+
7
+ export type EnvLayer = Readonly<Record<string, string | undefined>>;
8
+
9
+ /**
10
+ * 把父进程环境里的字符串值拷成一层 env(丢掉 undefined)。
11
+ * @param parent 父环境(通常是 `process.env`)。
12
+ * @returns 可继续叠加的 env 层。
13
+ */
14
+ export function inheritedEnvOf(parent: NodeJS.ProcessEnv | EnvLayer): Record<string, string> {
15
+ const out: Record<string, string> = {};
16
+ for (const [key, value] of Object.entries(parent)) {
17
+ if (typeof value === 'string') out[key] = value;
18
+ }
19
+ return out;
20
+ }
21
+
22
+ /**
23
+ * 在一层 env 上叠另一层;`undefined` 表示删掉这个键。
24
+ * @param base 底层 env。
25
+ * @param layer 上层 env;值为 undefined 的键被删除。
26
+ * @returns 叠加后的新对象。
27
+ */
28
+ export function overlayEnvOf(
29
+ base: EnvLayer,
30
+ layer: EnvLayer | undefined,
31
+ ): Record<string, string | undefined> {
32
+ const merged: Record<string, string | undefined> = { ...base };
33
+ for (const [key, value] of Object.entries(layer ?? {})) {
34
+ if (value === undefined) delete merged[key];
35
+ else merged[key] = value;
36
+ }
37
+ return merged;
38
+ }
39
+
40
+ /**
41
+ * 把 env 层收成只含字符串的 env(用于执行缝 `env` 参数)。
42
+ * @param layer env 层。
43
+ * @returns 只含字符串键值的对象。
44
+ */
45
+ export function envTextOf(layer: EnvLayer): Record<string, string> {
46
+ const out: Record<string, string> = {};
47
+ for (const [key, value] of Object.entries(layer)) {
48
+ if (value !== undefined) out[key] = value;
49
+ }
50
+ return out;
51
+ }
52
+
53
+ /**
54
+ * 在 PATH 前面插一组目录(去重、空项丢掉)。
55
+ * @param parentPath 原来的 PATH。
56
+ * @param bins 要前插的目录,按顺序。
57
+ * @param separator 平台分隔符,默认 `path.delimiter`。
58
+ * @returns 新的 PATH。
59
+ */
60
+ export function pathWithBinsOf(
61
+ parentPath: string | undefined,
62
+ bins: readonly string[],
63
+ separator: string = delimiter,
64
+ ): string {
65
+ const parts = (parentPath ?? '').split(separator).filter((part) => part !== '' && !bins.includes(part));
66
+ return [...bins, ...parts].join(separator);
67
+ }
68
+
69
+ function existingPointer(realHome: string, name: string): string | undefined {
70
+ const file = join(realHome, name);
71
+ return existsSync(file) ? file : undefined;
72
+ }
73
+
74
+ export interface ProductEnvInput {
75
+ readonly runtimeRoot: string;
76
+ readonly home?: string | undefined;
77
+ readonly realHome?: string | undefined;
78
+ }
79
+
80
+ /**
81
+ * 产品域(运行时根)的受控 env:隔离 HOME、npm/pnpm 缓存都钉在运行时根,真 npmrc/gitconfig 只读指过去。
82
+ * @param input 运行时根、可选的 HOME 与真实用户目录。
83
+ * @returns 产品域 env。
84
+ */
85
+ export function productEnvOf(input: ProductEnvInput): Record<string, string> {
86
+ const runtimeRoot = input.runtimeRoot;
87
+ const realHome = input.realHome ?? homedir();
88
+ const env: Record<string, string> = {
89
+ HOME: input.home ?? join(runtimeRoot, 'home'),
90
+ DSH_HOME: dshHome(),
91
+ DSH_WORKBENCH_HOME: runtimeRoot,
92
+ npm_config_cache: join(runtimeRoot, 'npm-cache'),
93
+ NPM_CONFIG_CACHE: join(runtimeRoot, 'npm-cache'),
94
+ npm_config_logs_dir: join(runtimeRoot, 'npm-logs'),
95
+ npm_config_store_dir: join(runtimeRoot, 'pnpm-store'),
96
+ npm_config_cache_dir: join(runtimeRoot, 'pnpm-cache'),
97
+ npm_config_state_dir: join(runtimeRoot, 'pnpm-state'),
98
+ npm_config_update_notifier: 'false',
99
+ npm_config_fund: 'false',
100
+ NO_UPDATE_NOTIFIER: '1',
101
+ };
102
+ const npmrc = existingPointer(realHome, '.npmrc');
103
+ if (npmrc !== undefined) env['NPM_CONFIG_USERCONFIG'] = npmrc;
104
+ const gitconfig = existingPointer(realHome, '.gitconfig');
105
+ if (gitconfig !== undefined) env['GIT_CONFIG_GLOBAL'] = gitconfig;
106
+ return env;
107
+ }
108
+
109
+ export interface UnitEnvInput extends ProductEnvInput {
110
+ readonly unitRoot: string;
111
+ }
112
+
113
+ /**
114
+ * 模块单元的受控 env:在产品域基础上把 HOME 与 npm/pnpm 目录全钉到单元数据目录。
115
+ * @param input 运行时根、单元数据目录、可选的 TRUE 用户目录。
116
+ * @returns 单元 env。
117
+ */
118
+ export function unitEnvOf(input: UnitEnvInput): Record<string, string> {
119
+ const unitRoot = input.unitRoot;
120
+ return {
121
+ ...productEnvOf({ ...input, home: join(unitRoot, 'home') }),
122
+ npm_config_cache: join(unitRoot, 'npm-cache'),
123
+ NPM_CONFIG_CACHE: join(unitRoot, 'npm-cache'),
124
+ npm_config_logs_dir: join(unitRoot, 'npm-logs'),
125
+ npm_config_store_dir: join(unitRoot, 'pnpm-store'),
126
+ npm_config_cache_dir: join(unitRoot, 'pnpm-cache'),
127
+ npm_config_state_dir: join(unitRoot, 'pnpm-state'),
128
+ };
129
+ }
130
+
131
+ export interface BootstrapEnvInput {
132
+ readonly root: string;
133
+ readonly realHome: string;
134
+ readonly shellKind: ShellKind;
135
+ readonly parent?: NodeJS.ProcessEnv | EnvLayer | undefined;
136
+ readonly path?: string | undefined;
137
+ }
138
+
139
+ /**
140
+ * 自举/装工具链的受控 env:继承父环境,HOME 与 npm 缓存钉到运行时根,PATH 可换成桌面自带 bin。
141
+ * @param input 运行时根、真实用户目录、shell 种类、父环境与可选的 PATH。
142
+ * @returns 自举 env。
143
+ */
144
+ export function bootstrapEnvOf(input: BootstrapEnvInput): Record<string, string> {
145
+ const env = inheritedEnvOf(input.parent ?? process.env);
146
+ if (input.path !== undefined) env['PATH'] = input.path;
147
+ const home = join(input.root, 'home');
148
+ env['HOME'] = home;
149
+ if (input.shellKind === 'pwsh') env['USERPROFILE'] = home;
150
+ const userConfig = existingPointer(input.realHome, '.npmrc');
151
+ if (userConfig !== undefined) env['NPM_CONFIG_USERCONFIG'] = userConfig;
152
+ const cache = join(input.root, 'npm-cache');
153
+ env['npm_config_cache'] = cache;
154
+ env['NPM_CONFIG_CACHE'] = cache;
155
+ env['NO_COLOR'] = '1';
156
+ env['TERM'] = 'dumb';
157
+ return env;
158
+ }
159
+
160
+ /**
161
+ * git 的非交互 env:关掉终端提示/askpass;Windows 上不给 askpass 指 POSIX 路径(那台机器上没有)。
162
+ * @param platform 宿主平台。
163
+ * @returns git 非交互 env。
164
+ */
165
+ export function promptlessGitEnvOf(platform: NodeJS.Platform): Record<string, string> {
166
+ const shared = {
167
+ GIT_TERMINAL_PROMPT: '0',
168
+ SSH_ASKPASS_REQUIRE: 'never',
169
+ GCM_INTERACTIVE: 'never',
170
+ GIT_SSH_COMMAND: 'ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new',
171
+ };
172
+ if (platform === 'win32') return shared;
173
+ return { ...shared, GIT_ASKPASS: '/usr/bin/false', SSH_ASKPASS: '/usr/bin/false' };
174
+ }
175
+
176
+ /**
177
+ * 在父环境上叠加 `promptlessGitEnvOf`,得到可传给执行缝的 git env。
178
+ * @param parent 父环境。
179
+ * @param platform 宿主平台。
180
+ * @returns 只含字符串的 env。
181
+ */
182
+ export function promptlessProcessEnvOf(
183
+ parent: NodeJS.ProcessEnv,
184
+ platform: NodeJS.Platform,
185
+ ): Record<string, string> {
186
+ const env = { ...inheritedEnvOf(parent), ...promptlessGitEnvOf(platform) };
187
+ if (platform === 'win32') {
188
+ delete env['GIT_ASKPASS'];
189
+ delete env['SSH_ASKPASS'];
190
+ }
191
+ return env;
192
+ }
@@ -1,3 +1,4 @@
1
+ import { now } from './time.js';
1
2
  import type { DataReader } from '../contract/data.js';
2
3
  import type { ExtensionCaller, ExtensionCallOptions, ExtensionValue } from '../contract/extension.js';
3
4
  import type { ExtensionNeed } from '../contract/needs.js';
@@ -30,7 +31,7 @@ export function extensionCallerOf(
30
31
  argv: [need.name, ...args],
31
32
  ...(options.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs }),
32
33
  });
33
- if (result.ok) return { value: result.data, at: Date.now() };
34
+ if (result.ok) return { value: result.data, at: now() };
34
35
  const code = result.error.pageCode ?? cliCodeOf(result.error.code);
35
36
  const command = [need.name, ...args].join(' ');
36
37
  const advice =
@@ -1,3 +1,4 @@
1
+ import { now as clockNow } from '../time.js';
1
2
  import { evalPredicate, resolveInput, resolveValue } from './expr.js';
2
3
  import type {
3
4
  FlowAction,
@@ -197,7 +198,7 @@ async function runOne(options: RunOneOptions): Promise<unknown> {
197
198
  export async function runFlow(options: FlowRunOptions): Promise<FlowResult> {
198
199
  const { flow, input, actions, signal, runId, workspace, onStep } = options;
199
200
  const sleep = options.sleep ?? defaultSleep;
200
- const now = options.now ?? (() => Date.now());
201
+ const now = options.now ?? (() => clockNow());
201
202
  const steps: Record<string, unknown> = {};
202
203
  const emit = (event: FlowStepEvent): void => {
203
204
  try {
@@ -0,0 +1,147 @@
1
+ import { appendFileSync, chmodSync, mkdirSync, statSync, writeFileSync } from 'node:fs';
2
+ import { dirname } from 'node:path';
3
+ import writeFileAtomic from 'write-file-atomic';
4
+
5
+ export type WriteMode = number | 'keep';
6
+
7
+ export interface WriteFileOptions {
8
+ readonly atomic?: boolean | undefined;
9
+ readonly mkdir?: boolean | undefined;
10
+ readonly fsync?: boolean | undefined;
11
+ readonly mode?: WriteMode | undefined;
12
+ }
13
+
14
+ function modeOf(path: string, mode: WriteMode | undefined): number {
15
+ if (typeof mode === 'number') return mode;
16
+ try {
17
+ return statSync(path).mode & 0o777;
18
+ } catch {
19
+ return 0o644;
20
+ }
21
+ }
22
+
23
+ function prepareOf(path: string, options: WriteFileOptions): number {
24
+ if (options.mkdir !== false) mkdirSync(dirname(path), { recursive: true });
25
+ return modeOf(path, options.mode);
26
+ }
27
+
28
+ function atomicOptionsOf(mode: number, options: WriteFileOptions): { mode: number; fsync?: boolean } {
29
+ return options.fsync === true ? { mode, fsync: true } : { mode };
30
+ }
31
+
32
+ /**
33
+ * 把文本写进文件;默认原子写(temp + rename)、目录不存在就建、已有文件保留原权限。
34
+ * @param path 目标文件;父目录按 `mkdir` 决定是否创建。
35
+ * @param text 文本内容。
36
+ * @param options 原子/目录/权限/fsync;`mode` 默认 `'keep'`(新文件 0644)。
37
+ */
38
+ export function writeTextFile(path: string, text: string, options: WriteFileOptions = {}): void {
39
+ const mode = prepareOf(path, options);
40
+ if (options.atomic === false) {
41
+ writeFileSync(path, text, { mode });
42
+ return;
43
+ }
44
+ writeFileAtomic.sync(path, text, atomicOptionsOf(mode, options));
45
+ }
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
+
57
+ /**
58
+ * `writeTextFile` 的异步版;语义与默认值相同。
59
+ * @param path 目标文件。
60
+ * @param text 文本内容。
61
+ * @param options 原子/目录/权限/fsync。
62
+ * @returns 落盘完成。
63
+ */
64
+ export async function writeTextFileAsync(path: string, text: string, options: WriteFileOptions = {}): Promise<void> {
65
+ const mode = prepareOf(path, options);
66
+ if (options.atomic === false) {
67
+ writeFileSync(path, text, { mode });
68
+ return;
69
+ }
70
+ await writeFileAtomic(path, text, atomicOptionsOf(mode, options));
71
+ }
72
+
73
+ /**
74
+ * 把二进制写进文件;语义与默认值同 `writeTextFile`。
75
+ * @param path 目标文件。
76
+ * @param bytes 二进制内容。
77
+ * @param options 原子/目录/权限/fsync。
78
+ */
79
+ export function writeBytesFile(path: string, bytes: Uint8Array, options: WriteFileOptions = {}): void {
80
+ const mode = prepareOf(path, options);
81
+ if (options.atomic === false) {
82
+ writeFileSync(path, bytes, { mode });
83
+ return;
84
+ }
85
+ writeFileAtomic.sync(path, Buffer.from(bytes), atomicOptionsOf(mode, options));
86
+ }
87
+
88
+ /**
89
+ * `writeBytesFile` 的异步版;语义与默认值相同。
90
+ * @param path 目标文件。
91
+ * @param bytes 二进制内容。
92
+ * @param options 原子/目录/权限/fsync。
93
+ * @returns 落盘完成。
94
+ */
95
+ export async function writeBytesFileAsync(
96
+ path: string,
97
+ bytes: Uint8Array,
98
+ options: WriteFileOptions = {},
99
+ ): Promise<void> {
100
+ const mode = prepareOf(path, options);
101
+ if (options.atomic === false) {
102
+ writeFileSync(path, bytes, { mode });
103
+ return;
104
+ }
105
+ await writeFileAtomic(path, Buffer.from(bytes), atomicOptionsOf(mode, options));
106
+ }
107
+
108
+ /**
109
+ * 把 JSON 写进文件:`JSON.stringify(value, null, 2)` 加末尾换行;语义与默认值同 `writeTextFile`。
110
+ * @param path 目标文件。
111
+ * @param value 可序列化的值。
112
+ * @param options 原子/目录/权限/fsync。
113
+ */
114
+ export function writeJsonFile(path: string, value: unknown, options: WriteFileOptions = {}): void {
115
+ writeTextFile(path, `${JSON.stringify(value, null, 2)}\n`, options);
116
+ }
117
+
118
+ /**
119
+ * `writeJsonFile` 的异步版;语义与默认值相同。
120
+ * @param path 目标文件。
121
+ * @param value 可序列化的值。
122
+ * @param options 原子/目录/权限/fsync。
123
+ * @returns 落盘完成。
124
+ */
125
+ export async function writeJsonFileAsync(
126
+ path: string,
127
+ value: unknown,
128
+ options: WriteFileOptions = {},
129
+ ): Promise<void> {
130
+ await writeTextFileAsync(path, `${JSON.stringify(value, null, 2)}\n`, options);
131
+ }
132
+
133
+ /**
134
+ * 确保目录存在(`mkdir -p`)。
135
+ * @param dir 目录路径。
136
+ */
137
+ export function ensureDir(dir: string): void {
138
+ mkdirSync(dir, { recursive: true });
139
+ }
140
+
141
+ /**
142
+ * 把已存在的文件设成可执行(0755)。
143
+ * @param path 文件路径。
144
+ */
145
+ export function makeExecutable(path: string): void {
146
+ chmodSync(path, 0o755);
147
+ }
@@ -1,10 +1,11 @@
1
1
  import type { HostSurface } from '../contract/protocol/platform.js';
2
2
  import type { EnvSupplyPort } from './port-types.js';
3
+ import type { UnitSeam } from './seam/unit-seam.js';
3
4
  import { createFlowHost, type FlowHost } from './flow/kit.js';
4
5
  import { sessionIdOf, sessionWorkspace } from './tool-kit.js';
5
6
 
6
7
  /**
7
- * 宿主 kit 的公共部分:flow、工具侧 session 读法、外部供给装法、宿主平台事实。
8
+ * 宿主 kit 的公共部分:flow、工具侧 session 读法、外部供给装法、宿主平台事实、按 unit 绑定的执行缝门面。
8
9
  */
9
10
  export interface HostKit {
10
11
  readonly flow: FlowHost;
@@ -12,17 +13,20 @@ export interface HostKit {
12
13
  readonly sessionWorkspace: typeof sessionWorkspace;
13
14
  readonly libInstallCommand: EnvSupplyPort['libInstallCommand'];
14
15
  readonly surface: HostSurface;
16
+ readonly seam?: UnitSeam | undefined;
15
17
  }
16
18
 
17
19
  /**
18
20
  * 建一份宿主 kit;每个挂载点一份,没有模块级状态。
19
21
  * @param parts.libInstallCommand 拼「装这个共用依赖」的那条命令。
20
22
  * @param parts.surface 宿主平台事实;由组合根按 profile 与宿主进程算好再交进来。
21
- * @returns flow / sessionIdOf / sessionWorkspace / libInstallCommand / surface。
23
+ * @param parts.seam 按 unit 绑定好的执行缝门面;模块清单没声明写入域时缺省不给。
24
+ * @returns flow / sessionIdOf / sessionWorkspace / libInstallCommand / surface,有门面时带上 seam。
22
25
  */
23
26
  export function createHostKit(parts: {
24
27
  readonly libInstallCommand: EnvSupplyPort['libInstallCommand'];
25
28
  readonly surface: HostSurface;
29
+ readonly seam?: UnitSeam | undefined;
26
30
  }): HostKit {
27
31
  return {
28
32
  flow: createFlowHost(),
@@ -30,5 +34,6 @@ export function createHostKit(parts: {
30
34
  sessionWorkspace,
31
35
  libInstallCommand: parts.libInstallCommand,
32
36
  surface: parts.surface,
37
+ ...(parts.seam === undefined ? {} : { seam: parts.seam }),
33
38
  };
34
39
  }