@dsh-workbench/shared 1.1.14-beta.1 → 1.1.14

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/config/constants.d.ts +5 -1
  6. package/dist/config/constants.js +5 -1
  7. package/dist/contract/unit-declaration.js +15 -1
  8. package/dist/contract/unit.d.ts +3 -0
  9. package/dist/host/__scratch-guard.d.ts +1 -0
  10. package/dist/host/__scratch-guard.js +1 -0
  11. package/dist/host/adapter-set.js +0 -1
  12. package/dist/host/changelog.js +2 -1
  13. package/dist/host/channel-plugin.d.ts +15 -0
  14. package/dist/host/channel-plugin.js +42 -0
  15. package/dist/host/command-name.d.ts +1 -0
  16. package/dist/host/command-name.js +14 -0
  17. package/dist/host/config-sync.d.ts +1 -9
  18. package/dist/host/config-sync.js +3 -78
  19. package/dist/host/env-policy.d.ts +75 -0
  20. package/dist/host/env-policy.js +161 -0
  21. package/dist/host/extension.js +2 -1
  22. package/dist/host/flow/run.js +2 -1
  23. package/dist/host/fs-write.d.ts +62 -0
  24. package/dist/host/fs-write.js +112 -0
  25. package/dist/host/host-kit.d.ts +6 -2
  26. package/dist/host/host-kit.js +3 -1
  27. package/dist/host/http.js +11 -5
  28. package/dist/host/installed-sources.js +4 -5
  29. package/dist/host/long-task.js +3 -3
  30. package/dist/host/opencli-runner.d.ts +26 -0
  31. package/dist/host/opencli-runner.js +50 -0
  32. package/dist/host/package-content.d.ts +2 -2
  33. package/dist/host/package-content.js +20 -6
  34. package/dist/host/package-route.d.ts +3 -3
  35. package/dist/host/package-route.js +7 -6
  36. package/dist/host/panel-state.js +2 -1
  37. package/dist/host/patch-blocks.d.ts +13 -0
  38. package/dist/host/patch-blocks.js +52 -0
  39. package/dist/host/patch.d.ts +3 -8
  40. package/dist/host/patch.js +5 -76
  41. package/dist/host/plugin-source.d.ts +33 -0
  42. package/dist/host/plugin-source.js +91 -0
  43. package/dist/host/port-types.d.ts +9 -9
  44. package/dist/host/profile-registries.js +2 -3
  45. package/dist/host/registry-declaration.d.ts +2 -2
  46. package/dist/host/registry-declaration.js +14 -5
  47. package/dist/host/release-probe.d.ts +4 -4
  48. package/dist/host/release-probe.js +39 -14
  49. package/dist/host/runner.d.ts +26 -0
  50. package/dist/host/runner.js +50 -0
  51. package/dist/host/runtime-links.d.ts +7 -0
  52. package/dist/host/runtime-links.js +25 -0
  53. package/dist/host/seam/denial.d.ts +4 -0
  54. package/dist/host/seam/denial.js +24 -0
  55. package/dist/host/seam/env.d.ts +15 -0
  56. package/dist/host/seam/env.js +17 -0
  57. package/dist/host/seam/exec.d.ts +84 -0
  58. package/dist/host/seam/exec.js +1 -0
  59. package/dist/host/seam/host-env.d.ts +2 -0
  60. package/dist/host/seam/host-env.js +18 -0
  61. package/dist/host/seam/intent.d.ts +66 -0
  62. package/dist/host/seam/intent.js +24 -0
  63. package/dist/host/seam/policy.d.ts +10 -0
  64. package/dist/host/seam/policy.js +85 -0
  65. package/dist/host/seam/process.d.ts +2 -0
  66. package/dist/host/seam/process.js +80 -0
  67. package/dist/host/seam/run.d.ts +44 -0
  68. package/dist/host/seam/run.js +421 -0
  69. package/dist/host/seam/service.d.ts +4 -0
  70. package/dist/host/seam/service.js +4 -0
  71. package/dist/host/seam/unit-seam.d.ts +14 -0
  72. package/dist/host/seam/unit-seam.js +43 -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 +11 -0
  80. package/dist/host.js +11 -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/config/__tests__/constants.test.ts +10 -0
  95. package/src/config/constants.ts +7 -1
  96. package/src/contract/unit-declaration.ts +20 -1
  97. package/src/contract/unit.ts +3 -0
  98. package/src/host/__tests__/env-policy.test.ts +114 -0
  99. package/src/host/__tests__/fs-write.test.ts +96 -0
  100. package/src/host/__tests__/preflight-route.test.ts +29 -0
  101. package/src/host/__tests__/seam.test.ts +296 -0
  102. package/src/host/__tests__/shell.test.ts +24 -0
  103. package/src/host/__tests__/time.test.ts +20 -0
  104. package/src/host/adapter-set.ts +0 -1
  105. package/src/host/changelog.ts +2 -1
  106. package/src/host/config-sync.ts +3 -90
  107. package/src/host/env-policy.ts +192 -0
  108. package/src/host/extension.ts +2 -1
  109. package/src/host/flow/run.ts +2 -1
  110. package/src/host/fs-write.ts +137 -0
  111. package/src/host/host-kit.ts +7 -2
  112. package/src/host/http.ts +11 -5
  113. package/src/host/installed-sources.ts +4 -5
  114. package/src/host/long-task.ts +3 -3
  115. package/src/host/package-content.ts +23 -13
  116. package/src/host/package-route.ts +10 -9
  117. package/src/host/panel-state.ts +2 -1
  118. package/src/host/patch-blocks.ts +67 -0
  119. package/src/host/patch.ts +5 -89
  120. package/src/host/port-types.ts +9 -9
  121. package/src/host/profile-registries.ts +2 -3
  122. package/src/host/registry-declaration.ts +17 -12
  123. package/src/host/release-probe.ts +47 -34
  124. package/src/host/seam/denial.ts +26 -0
  125. package/src/host/seam/env.ts +23 -0
  126. package/src/host/seam/exec.ts +94 -0
  127. package/src/host/seam/host-env.ts +20 -0
  128. package/src/host/seam/intent.ts +101 -0
  129. package/src/host/seam/policy.ts +89 -0
  130. package/src/host/seam/process.ts +90 -0
  131. package/src/host/seam/run.ts +484 -0
  132. package/src/host/seam/service.ts +8 -0
  133. package/src/host/seam/unit-seam.ts +62 -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 +11 -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
@@ -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,137 @@
1
+ import { 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
+ * `writeTextFile` 的异步版;语义与默认值相同。
49
+ * @param path 目标文件。
50
+ * @param text 文本内容。
51
+ * @param options 原子/目录/权限/fsync。
52
+ * @returns 落盘完成。
53
+ */
54
+ export async function writeTextFileAsync(path: string, text: string, options: WriteFileOptions = {}): Promise<void> {
55
+ const mode = prepareOf(path, options);
56
+ if (options.atomic === false) {
57
+ writeFileSync(path, text, { mode });
58
+ return;
59
+ }
60
+ await writeFileAtomic(path, text, atomicOptionsOf(mode, options));
61
+ }
62
+
63
+ /**
64
+ * 把二进制写进文件;语义与默认值同 `writeTextFile`。
65
+ * @param path 目标文件。
66
+ * @param bytes 二进制内容。
67
+ * @param options 原子/目录/权限/fsync。
68
+ */
69
+ export function writeBytesFile(path: string, bytes: Uint8Array, options: WriteFileOptions = {}): void {
70
+ const mode = prepareOf(path, options);
71
+ if (options.atomic === false) {
72
+ writeFileSync(path, bytes, { mode });
73
+ return;
74
+ }
75
+ writeFileAtomic.sync(path, Buffer.from(bytes), atomicOptionsOf(mode, options));
76
+ }
77
+
78
+ /**
79
+ * `writeBytesFile` 的异步版;语义与默认值相同。
80
+ * @param path 目标文件。
81
+ * @param bytes 二进制内容。
82
+ * @param options 原子/目录/权限/fsync。
83
+ * @returns 落盘完成。
84
+ */
85
+ export async function writeBytesFileAsync(
86
+ path: string,
87
+ bytes: Uint8Array,
88
+ options: WriteFileOptions = {},
89
+ ): Promise<void> {
90
+ const mode = prepareOf(path, options);
91
+ if (options.atomic === false) {
92
+ writeFileSync(path, bytes, { mode });
93
+ return;
94
+ }
95
+ await writeFileAtomic(path, Buffer.from(bytes), atomicOptionsOf(mode, options));
96
+ }
97
+
98
+ /**
99
+ * 把 JSON 写进文件:`JSON.stringify(value, null, 2)` 加末尾换行;语义与默认值同 `writeTextFile`。
100
+ * @param path 目标文件。
101
+ * @param value 可序列化的值。
102
+ * @param options 原子/目录/权限/fsync。
103
+ */
104
+ export function writeJsonFile(path: string, value: unknown, options: WriteFileOptions = {}): void {
105
+ writeTextFile(path, `${JSON.stringify(value, null, 2)}\n`, options);
106
+ }
107
+
108
+ /**
109
+ * `writeJsonFile` 的异步版;语义与默认值相同。
110
+ * @param path 目标文件。
111
+ * @param value 可序列化的值。
112
+ * @param options 原子/目录/权限/fsync。
113
+ * @returns 落盘完成。
114
+ */
115
+ export async function writeJsonFileAsync(
116
+ path: string,
117
+ value: unknown,
118
+ options: WriteFileOptions = {},
119
+ ): Promise<void> {
120
+ await writeTextFileAsync(path, `${JSON.stringify(value, null, 2)}\n`, options);
121
+ }
122
+
123
+ /**
124
+ * 确保目录存在(`mkdir -p`)。
125
+ * @param dir 目录路径。
126
+ */
127
+ export function ensureDir(dir: string): void {
128
+ mkdirSync(dir, { recursive: true });
129
+ }
130
+
131
+ /**
132
+ * 把已存在的文件设成可执行(0755)。
133
+ * @param path 文件路径。
134
+ */
135
+ export function makeExecutable(path: string): void {
136
+ chmodSync(path, 0o755);
137
+ }
@@ -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
  }
package/src/host/http.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { now, nowIso } from './time.js';
1
2
  import type { DshHostContext, DshRequest, DshResponse } from '../api/dsh.js';
2
3
  import { unitFactScope } from './facts.js';
3
4
  import { parseCardsReport, parseCrashReport, type PanelStateStore } from './panel-state.js';
@@ -234,8 +235,13 @@ export function preflightHandler(preflight: {
234
235
  snapshot(): unknown;
235
236
  }): RouteHandler {
236
237
  return (search) => {
237
- void preflight.run({ hard: flag(search, 'hard') });
238
- return { ok: true, ...(preflight.snapshot() as Record<string, unknown>) };
238
+ if (flag(search, 'hard')) {
239
+ void preflight.run({ hard: true });
240
+ return { ok: true, ...(preflight.snapshot() as Record<string, unknown>) };
241
+ }
242
+ const snapshot = preflight.snapshot() as Record<string, unknown>;
243
+ void preflight.run({ hard: false });
244
+ return { ok: true, ...snapshot };
239
245
  };
240
246
  }
241
247
 
@@ -265,7 +271,7 @@ export function healthHandler(
265
271
  profile: dir === undefined ? null : dir.split('/').pop(),
266
272
  profileDir: dir ?? null,
267
273
  pid: process.pid,
268
- now: new Date().toISOString(),
274
+ now: nowIso(),
269
275
  ...(runtimes === undefined ? {} : { runtimes }),
270
276
  };
271
277
  };
@@ -310,7 +316,7 @@ export function registerPanelState(ctx: DshHostContext, store: PanelStateStore):
310
316
  const parsed = parseCardsReport(payload);
311
317
  if (!parsed.ok) return { ok: false, text: parsed.message };
312
318
  store.cards(parsed.value);
313
- return { ok: true, at: Date.now() };
319
+ return { ok: true, at: now() };
314
320
  },
315
321
  },
316
322
  {
@@ -319,7 +325,7 @@ export function registerPanelState(ctx: DshHostContext, store: PanelStateStore):
319
325
  const parsed = parseCrashReport(payload);
320
326
  if (!parsed.ok) return { ok: false, text: parsed.message };
321
327
  store.crash(parsed.value);
322
- return { ok: true, at: Date.now() };
328
+ return { ok: true, at: now() };
323
329
  },
324
330
  },
325
331
  ],
@@ -1,5 +1,6 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
+ import { writeTextFile } from './fs-write.js';
3
4
 
4
5
  export interface InstalledSource {
5
6
  readonly version: string;
@@ -50,11 +51,9 @@ export function packageSourceOf(profileDir: string | null, pkg: string): Install
50
51
  }
51
52
 
52
53
  function writeInstalledSources(profileDir: string, sources: InstalledSourceMap): void {
53
- const file = installedSourcesFile(profileDir);
54
- const temp = `${file}.tmp`;
55
- const text = `${JSON.stringify({ version: FILE_VERSION, packages: sources }, null, 2)}\n`;
56
- fs.writeFileSync(temp, text, { mode: 0o600 });
57
- fs.renameSync(temp, file);
54
+ writeTextFile(installedSourcesFile(profileDir), `${JSON.stringify({ version: FILE_VERSION, packages: sources }, null, 2)}\n`, {
55
+ mode: 0o600,
56
+ });
58
57
  }
59
58
 
60
59
  export function rememberPackageSource(profileDir: string, pkg: string, source: InstalledSource): void {
@@ -1,4 +1,4 @@
1
- import { epochOf } from './time.js';
1
+ import { epochOf, now as clockNow, nowIso } from './time.js';
2
2
  import { runsRouteOf, type LongTaskStatus, type LongTaskStep } from '../contract/long-task.js';
3
3
 
4
4
  const LIVE: ReadonlySet<string> = new Set<LongTaskStatus>(['running', 'needs-login']);
@@ -66,9 +66,9 @@ export interface LongTaskRegistry<S extends LongTaskBase> {
66
66
  export function createLongTaskRegistry<S extends LongTaskBase>(deps: LongTaskDeps<S>): LongTaskRegistry<S> {
67
67
  const runs = new Map<string, LongTaskBase>();
68
68
  const controls = new Map<string, AbortController>();
69
- const now = deps.now ?? (() => new Date().toISOString());
69
+ const now = deps.now ?? (() => nowIso());
70
70
  let counter = 0;
71
- const nextId = deps.nextId ?? (() => `run-${Date.now()}-${(counter += 1)}`);
71
+ const nextId = deps.nextId ?? (() => `run-${clockNow()}-${(counter += 1)}`);
72
72
  const recentMs = deps.recentMs ?? RECENT_MS;
73
73
  const listeners = new Set<(change: LongTaskChange<S>) => void>();
74
74
 
@@ -1,11 +1,11 @@
1
1
  import fs from 'node:fs';
2
2
  import os from 'node:os';
3
3
  import path from 'node:path';
4
- import type { DshShell } from '../api/dsh.js';
4
+ import { productWrite } from './seam/intent.js';
5
+ import type { Seam } from './seam/run.js';
5
6
  import { INSTALL_TIMEOUT_MS } from '../config/constants.js';
6
7
  import { parseJsonLoose } from '../lib/text.js';
7
8
  import { quoteShellArg } from './shell-text.js';
8
- import { runShell } from './shell.js';
9
9
  import { splitNpmSpec } from './package-kind.js';
10
10
  import { localDirOf } from './local-declaration.js';
11
11
 
@@ -34,7 +34,7 @@ function packedFileFrom(stdout: string, root: string): string | undefined {
34
34
 
35
35
  export async function withPackageDir<T>(
36
36
  options: {
37
- readonly shell?: DshShell | undefined;
37
+ readonly seam?: Seam | undefined;
38
38
  readonly spec: string;
39
39
  readonly registry?: string | undefined;
40
40
  readonly cacheDir?: string | undefined;
@@ -44,8 +44,8 @@ export async function withPackageDir<T>(
44
44
  const local = localDirOf(options.spec);
45
45
  if (local !== undefined) return visit(local);
46
46
  if (splitNpmSpec(options.spec) === undefined) return undefined;
47
- const shell = options.shell;
48
- if (shell === undefined) return undefined;
47
+ const seam = options.seam;
48
+ if (seam === undefined) return undefined;
49
49
 
50
50
  let root: string;
51
51
  try {
@@ -55,17 +55,27 @@ export async function withPackageDir<T>(
55
55
  }
56
56
  try {
57
57
  const at = options.registry === undefined ? '' : ` --registry ${quoteShellArg(options.registry)}`;
58
- const packed = await runShell(
59
- shell,
60
- `npm pack ${quoteShellArg(options.spec)} --ignore-scripts --json --pack-destination ${quoteShellArg(root)}${cacheFlag(options.cacheDir)}${at}`,
61
- { timeoutMs: INSTALL_TIMEOUT_MS },
62
- ).catch(() => undefined);
58
+ const packed = await seam
59
+ .run({
60
+ command: `npm pack ${quoteShellArg(options.spec)} --ignore-scripts --json --pack-destination ${quoteShellArg(root)}${cacheFlag(options.cacheDir)}${at}`,
61
+ intent: productWrite(),
62
+ retry: 'safe',
63
+ timeoutMs: INSTALL_TIMEOUT_MS,
64
+ bestEffort: true,
65
+ })
66
+ .catch(() => undefined);
63
67
  if (packed === undefined || packed.exitCode !== 0) return undefined;
64
68
  const file = packedFileFrom(packed.stdout, root);
65
69
  if (file === undefined) return undefined;
66
- const unpacked = await runShell(shell, `tar -xzf ${quoteShellArg(file)} -C ${quoteShellArg(root)}`, {
67
- timeoutMs: INSTALL_TIMEOUT_MS,
68
- }).catch(() => undefined);
70
+ const unpacked = await seam
71
+ .run({
72
+ command: `tar -xzf ${quoteShellArg(file)} -C ${quoteShellArg(root)}`,
73
+ intent: productWrite(),
74
+ retry: 'safe',
75
+ timeoutMs: INSTALL_TIMEOUT_MS,
76
+ bestEffort: true,
77
+ })
78
+ .catch(() => undefined);
69
79
  if (unpacked === undefined || unpacked.exitCode !== 0) return undefined;
70
80
  const dir = path.join(root, 'package');
71
81
  if (!fs.existsSync(dir)) return undefined;