@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
@@ -1,4 +1,6 @@
1
- import type { DshShell } from '../api/dsh.js';
1
+ import { now } from './time.js';
2
+ import type { Seam } from './seam/run.js';
3
+ import { productWrite } from './seam/intent.js';
2
4
  import type { PluginRelease } from '../contract/unit.js';
3
5
 
4
6
  import { RELEASE_PACKAGES } from './release-packages.generated.js';
@@ -6,7 +8,6 @@ import { VERSION_CHECK_TIMEOUT_MS, VERSION_CHECK_TTL_MS } from '../config/consta
6
8
  import { isValidVersion } from './version.js';
7
9
  import { parseJsonLoose } from '../lib/text.js';
8
10
  import { quoteShellArg } from './shell-text.js';
9
- import { runShell } from './shell.js';
10
11
 
11
12
  function cacheFlag(cacheDir: string | undefined): string {
12
13
  return cacheDir === undefined ? '' : ` --cache ${quoteShellArg(cacheDir)}`;
@@ -61,7 +62,7 @@ export function createReleaseProbe(): ReleaseProbe {
61
62
  }
62
63
 
63
64
  interface LatestVersionOptions {
64
- readonly shell: DshShell;
65
+ readonly seam: Seam;
65
66
  readonly name: string;
66
67
  readonly registry: string;
67
68
  readonly force?: boolean;
@@ -69,7 +70,7 @@ interface LatestVersionOptions {
69
70
  }
70
71
 
71
72
  interface MissingPartsOptions {
72
- readonly shell: DshShell;
73
+ readonly seam: Seam;
73
74
  readonly name: string;
74
75
  readonly registry: string;
75
76
  readonly version: string;
@@ -78,7 +79,7 @@ interface MissingPartsOptions {
78
79
  }
79
80
 
80
81
  interface ProbeReleaseOptions {
81
- readonly shell: DshShell;
82
+ readonly seam: Seam;
82
83
  readonly package: string;
83
84
  readonly registry?: string | undefined;
84
85
  readonly force?: boolean;
@@ -88,25 +89,29 @@ interface ProbeReleaseOptions {
88
89
  }
89
90
 
90
91
  async function latestVersionOf(latestCache: Map<string, CacheEntry>, options: {
91
- shell: DshShell;
92
+ seam: Seam;
92
93
  name: string;
93
94
  registry: string;
94
95
  force?: boolean;
95
96
  cacheDir?: string | undefined;
96
97
  }): Promise<string | undefined> {
97
- const { shell, name, registry, force = false, cacheDir } = options;
98
+ const { seam, name, registry, force = false, cacheDir } = options;
98
99
  const key = `${registry}|${name}`;
99
100
  const hit = latestCache.get(key);
100
- if (!force && hit !== undefined && Date.now() - hit.at < VERSION_CHECK_TTL_MS)
101
+ if (!force && hit !== undefined && now() - hit.at < VERSION_CHECK_TTL_MS)
101
102
  return hit.value;
102
103
 
103
- const result = await runShell(
104
- shell,
105
- `npm view ${quoteShellArg(name)} version --registry ${quoteShellArg(registry)}${cacheFlag(cacheDir)}`,
106
- { timeoutMs: VERSION_CHECK_TIMEOUT_MS },
107
- ).catch(() => undefined);
104
+ const result = await seam
105
+ .run({
106
+ command: `npm view ${quoteShellArg(name)} version --registry ${quoteShellArg(registry)}${cacheFlag(cacheDir)}`,
107
+ intent: productWrite(),
108
+ retry: 'safe',
109
+ timeoutMs: VERSION_CHECK_TIMEOUT_MS,
110
+ bestEffort: true,
111
+ })
112
+ .catch(() => undefined);
108
113
  const read = result?.exitCode === 0 ? parseVersion(result.stdout) : undefined;
109
- latestCache.set(key, { at: Date.now(), value: read });
114
+ latestCache.set(key, { at: now(), value: read });
110
115
  return read;
111
116
  }
112
117
 
@@ -119,26 +124,30 @@ interface ExistsEntry {
119
124
  async function versionExists(
120
125
  existsCache: Map<string, ExistsEntry>,
121
126
  options: {
122
- shell: DshShell;
127
+ seam: Seam;
123
128
  name: string;
124
129
  version: string;
125
130
  registry: string;
126
131
  force?: boolean;
127
132
  cacheDir?: string | undefined;
128
133
  }): Promise<boolean> {
129
- const { shell, name, version, registry, force = false, cacheDir } = options;
134
+ const { seam, name, version, registry, force = false, cacheDir } = options;
130
135
  const key = `${registry}|${name}@${version}`;
131
136
  const hit = existsCache.get(key);
132
- if (!force && hit !== undefined && (hit.value || Date.now() - hit.at < VERSION_CHECK_TTL_MS))
137
+ if (!force && hit !== undefined && (hit.value || now() - hit.at < VERSION_CHECK_TTL_MS))
133
138
  return hit.value;
134
139
 
135
- const result = await runShell(
136
- shell,
137
- `npm view ${quoteShellArg(`${name}@${version}`)} version --registry ${quoteShellArg(registry)}${cacheFlag(cacheDir)}`,
138
- { timeoutMs: VERSION_CHECK_TIMEOUT_MS },
139
- ).catch(() => undefined);
140
+ const result = await seam
141
+ .run({
142
+ command: `npm view ${quoteShellArg(`${name}@${version}`)} version --registry ${quoteShellArg(registry)}${cacheFlag(cacheDir)}`,
143
+ intent: productWrite(),
144
+ retry: 'safe',
145
+ timeoutMs: VERSION_CHECK_TIMEOUT_MS,
146
+ bestEffort: true,
147
+ })
148
+ .catch(() => undefined);
140
149
  const value = result?.exitCode === 0 && parseVersion(result.stdout) !== undefined;
141
- existsCache.set(key, { at: Date.now(), value });
150
+ existsCache.set(key, { at: now(), value });
142
151
  return value;
143
152
  }
144
153
 
@@ -146,18 +155,18 @@ async function missingPartsOf(
146
155
  latestCache: Map<string, CacheEntry>,
147
156
  existsCache: Map<string, ExistsEntry>,
148
157
  options: {
149
- shell: DshShell;
158
+ seam: Seam;
150
159
  name: string;
151
160
  registry: string;
152
161
  version: string;
153
162
  force?: boolean;
154
163
  cacheDir?: string | undefined;
155
164
  }): Promise<readonly string[]> {
156
- const { shell, name, registry, version, force = false, cacheDir } = options;
165
+ const { seam, name, registry, version, force = false, cacheDir } = options;
157
166
  const checked = await Promise.all(
158
167
  RELEASE_PACKAGES.filter((part) => part !== name).map(async (part) => ({
159
168
  part,
160
- present: await versionExists(existsCache, { shell, name: part, version, registry, force, cacheDir }),
169
+ present: await versionExists(existsCache, { seam, name: part, version, registry, force, cacheDir }),
161
170
  })),
162
171
  );
163
172
  return checked.filter((entry) => !entry.present).map((entry) => entry.part);
@@ -166,7 +175,7 @@ async function missingPartsOf(
166
175
  async function probeOf(
167
176
  latestCache: Map<string, CacheEntry>,
168
177
  options: {
169
- shell: DshShell;
178
+ seam: Seam;
170
179
  package: string;
171
180
  registry?: string | undefined;
172
181
  force?: boolean;
@@ -174,14 +183,18 @@ async function probeOf(
174
183
  cacheDir?: string | undefined;
175
184
  prefix?: string | undefined;
176
185
  }): Promise<PluginRelease | undefined> {
177
- const { shell, package: pkg, registry, force = false, check = true, cacheDir, prefix } = options;
186
+ const { seam, package: pkg, registry, force = false, check = true, cacheDir, prefix } = options;
178
187
  if (pkg === "") return undefined;
179
188
 
180
- const ledger = await runShell(
181
- shell,
182
- `npm ls --prefix ${quoteShellArg(prefix ?? '')} ${quoteShellArg(pkg)} --json --depth=0${cacheFlag(cacheDir)}`,
183
- { timeoutMs: VERSION_CHECK_TIMEOUT_MS },
184
- ).catch(() => undefined);
189
+ const ledger = await seam
190
+ .run({
191
+ command: `npm ls --prefix ${quoteShellArg(prefix ?? '')} ${quoteShellArg(pkg)} --json --depth=0${cacheFlag(cacheDir)}`,
192
+ intent: productWrite(),
193
+ retry: 'safe',
194
+ timeoutMs: VERSION_CHECK_TIMEOUT_MS,
195
+ bestEffort: true,
196
+ })
197
+ .catch(() => undefined);
185
198
  const installed =
186
199
  ledger === undefined ? undefined : parseLedgerVersion(ledger.stdout, pkg);
187
200
  if (installed === undefined) return undefined;
@@ -193,7 +206,7 @@ async function probeOf(
193
206
  const latest =
194
207
  source === undefined || !check
195
208
  ? undefined
196
- : await latestVersionOf(latestCache, { shell, name: pkg, registry: source, force, cacheDir });
209
+ : await latestVersionOf(latestCache, { seam, name: pkg, registry: source, force, cacheDir });
197
210
 
198
211
  return {
199
212
  installed,
@@ -0,0 +1,26 @@
1
+ const PATTERNS: readonly RegExp[] = [
2
+ /npm (?:error|ERR!) path (.+)$/m,
3
+ /(?:EPERM|EACCES):[^,\n]*, (?:open|unlink|mkdir|scandir|rename|access|stat|lstat|read|write|copyfile|rmdir|opendir|utimes|chmod|chown) '([^']+)'/i,
4
+ /(?:Operation not permitted|Permission denied)[^\n]*?'([^']+)'/i,
5
+ /fatal: could not create work tree dir '([^']+)'/i,
6
+ /mkdir: cannot create directory '([^']+)'/i,
7
+ /cannot (?:create|mkdir) (?:directory )?'([^']+)'/i,
8
+ /(?:Operation not permitted|Permission denied):\s*([^\s'\n]+)/i,
9
+ ];
10
+
11
+ function firstMatch(text: string): string | undefined {
12
+ for (const pattern of PATTERNS) {
13
+ const found = pattern.exec(text);
14
+ const value = found?.[1]?.trim();
15
+ if (value !== undefined && value !== '') return value;
16
+ }
17
+ return undefined;
18
+ }
19
+
20
+ export function deniedPathOf(stderr: string, stdout = ''): string | undefined {
21
+ return firstMatch(stderr) ?? firstMatch(stdout);
22
+ }
23
+
24
+ export function sandboxDeniedOf(facts: { readonly denied?: boolean | undefined } | undefined): boolean {
25
+ return facts?.denied === true;
26
+ }
@@ -0,0 +1,23 @@
1
+ import { productEnvOf, unitEnvOf, type ProductEnvInput, type UnitEnvInput } from '../env-policy.js';
2
+
3
+ export type SeamEnvInput = ProductEnvInput;
4
+
5
+ export type SeamUnitEnvInput = UnitEnvInput;
6
+
7
+ /**
8
+ * 产品域(运行时根)的受控 env:隔离 HOME、npm/pnpm 缓存钉在运行时根、真 npmrc/gitconfig 只读指过去。
9
+ * @param input 运行时根、可选的 HOME 与真实用户目录。
10
+ * @returns 产品域 env。
11
+ */
12
+ export function seamProductEnv(input: SeamEnvInput): Record<string, string> {
13
+ return productEnvOf(input);
14
+ }
15
+
16
+ /**
17
+ * 模块单元的受控 env:HOME 与 npm/pnpm 目录钉到单元数据目录。
18
+ * @param input 运行时根、单元数据目录、可选的 TRUE 用户目录。
19
+ * @returns 单元 env。
20
+ */
21
+ export function seamUnitEnv(input: SeamUnitEnvInput): Record<string, string> {
22
+ return unitEnvOf(input);
23
+ }
@@ -0,0 +1,94 @@
1
+ import type { ShellResult } from '../shell.js';
2
+ import type { ShellKind } from '../shell-text.js';
3
+ import type { WriteIntent } from './intent.js';
4
+
5
+ export type SeamShellKind = ShellKind;
6
+
7
+ export interface HostWriteIntent {
8
+ readonly kind: 'host';
9
+ }
10
+
11
+ export type ExecIntent = WriteIntent | HostWriteIntent;
12
+
13
+ export type SeamTarget =
14
+ | { readonly argv: readonly string[] }
15
+ | { readonly line: string; readonly shellKind?: SeamShellKind | undefined };
16
+
17
+ export interface SeamOutcomeWithTarget extends ShellResult {
18
+ readonly outcome?: 'exited' | 'timed-out' | 'spawn-failed';
19
+ }
20
+
21
+ export interface SpawnRequest {
22
+ readonly target: SeamTarget;
23
+ readonly intent: ExecIntent;
24
+ readonly lifetime: 'host';
25
+ readonly cwd?: string | undefined;
26
+ readonly env?: Readonly<Record<string, string | undefined>> | undefined;
27
+ readonly logFile?: string | undefined;
28
+ readonly label?: string | undefined;
29
+ }
30
+
31
+ export interface SpawnExit {
32
+ readonly code: number | null;
33
+ readonly signal: string | null;
34
+ }
35
+
36
+ export interface SpawnHandle {
37
+ readonly pid: number | undefined;
38
+ readonly logFile: string | undefined;
39
+ exit(): Promise<SpawnExit>;
40
+ onError(listener: (error: Error) => void): () => void;
41
+ kill(signal?: string): void;
42
+ }
43
+
44
+ export interface OpenRequest {
45
+ readonly target: SeamTarget;
46
+ readonly intent: ExecIntent;
47
+ readonly cwd?: string | undefined;
48
+ readonly env?: Readonly<Record<string, string | undefined>> | undefined;
49
+ readonly label?: string | undefined;
50
+ }
51
+
52
+ export interface ChannelHandle {
53
+ readonly pid: number | undefined;
54
+ write(chunk: string): void;
55
+ onData(listener: (chunk: string) => void): () => void;
56
+ onExit(listener: (event: SpawnExit) => void): () => void;
57
+ onError(listener: (error: Error) => void): () => void;
58
+ kill(signal?: string): void;
59
+ }
60
+
61
+ export interface ProcessSpawnOptions {
62
+ readonly file: string;
63
+ readonly args: readonly string[];
64
+ readonly env: Readonly<Record<string, string | undefined>>;
65
+ readonly cwd?: string | undefined;
66
+ readonly detached: boolean;
67
+ readonly stdio: 'ignore' | 'pipe' | 'log';
68
+ readonly logFile?: string | undefined;
69
+ readonly windowsHide: boolean;
70
+ }
71
+
72
+ export interface ProcessExitEvent {
73
+ readonly code: number | null;
74
+ readonly signal: string | null;
75
+ }
76
+
77
+ export interface ProcessStream {
78
+ onData(listener: (chunk: string) => void): () => void;
79
+ }
80
+
81
+ export interface ProcessChild {
82
+ readonly pid: number | undefined;
83
+ readonly stdin: { write(chunk: string): void } | null;
84
+ readonly stdout: ProcessStream | null;
85
+ readonly stderr: ProcessStream | null;
86
+ onExit(listener: (event: ProcessExitEvent) => void): () => void;
87
+ onError(listener: (error: Error) => void): () => void;
88
+ unref(): void;
89
+ kill(signal?: string): void;
90
+ }
91
+
92
+ export interface ProcessHost {
93
+ spawn(options: ProcessSpawnOptions): ProcessChild;
94
+ }
@@ -0,0 +1,20 @@
1
+ export function hostEnvOf(
2
+ env: Readonly<Record<string, string | undefined>> | undefined,
3
+ ): Record<string, string | undefined> {
4
+ const merged: Record<string, string | undefined> = { ...process.env };
5
+ for (const [key, value] of Object.entries(env ?? {})) {
6
+ if (value === undefined) delete merged[key];
7
+ else merged[key] = value;
8
+ }
9
+ return merged;
10
+ }
11
+
12
+ export function definedEnvOf(
13
+ env: Readonly<Record<string, string | undefined>> | undefined,
14
+ ): Record<string, string> {
15
+ const out: Record<string, string> = {};
16
+ for (const [key, value] of Object.entries(env ?? {})) {
17
+ if (value !== undefined) out[key] = value;
18
+ }
19
+ return out;
20
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * 执行缝的语义写入域:调用点只声明自己写哪一类树,不传裸根。
3
+ * 策略合成、环境注入与拒绝处置都住在执行缝模块里。
4
+ */
5
+ export type SeamMode = 'read-only' | 'workspace-write' | 'danger-full-access';
6
+
7
+ export interface ProductWriteIntent {
8
+ readonly kind: 'product';
9
+ }
10
+
11
+ export interface WorkspaceWriteIntent {
12
+ readonly kind: 'workspace';
13
+ }
14
+
15
+ export interface PathsWriteIntent {
16
+ readonly kind: 'paths';
17
+ readonly paths: readonly string[];
18
+ }
19
+
20
+ export interface NoWriteIntent {
21
+ readonly kind: 'none';
22
+ }
23
+
24
+ export type WriteIntent = ProductWriteIntent | WorkspaceWriteIntent | PathsWriteIntent | NoWriteIntent;
25
+
26
+ export type DenialScope = 'product' | 'workspace' | 'external' | 'unknown';
27
+
28
+ export interface SeamPolicy {
29
+ readonly mode: SeamMode;
30
+ readonly workspaceRoot: string;
31
+ }
32
+
33
+ export interface SeamDenial {
34
+ readonly path?: string | undefined;
35
+ readonly scope: DenialScope;
36
+ readonly healed?: boolean | undefined;
37
+ }
38
+
39
+ export interface SeamFact {
40
+ readonly at: number;
41
+ readonly verb: 'run' | 'spawn' | 'open';
42
+ readonly command: string;
43
+ readonly intent: WriteIntent['kind'] | 'host';
44
+ readonly mode: SeamMode | 'host';
45
+ readonly workspaceRoot: string;
46
+ readonly exitCode: number | null;
47
+ readonly timedOut: boolean;
48
+ readonly stalled: boolean;
49
+ readonly denial?: SeamDenial | undefined;
50
+ }
51
+
52
+ export type SeamErrorCode =
53
+ | 'workspace-unknown'
54
+ | 'read-only'
55
+ | 'paths-span-trees'
56
+ | 'scope-not-declared'
57
+ | 'sandbox-denied'
58
+ | 'sandbox-unavailable';
59
+
60
+ export class SeamError extends Error {
61
+ readonly code: SeamErrorCode;
62
+ readonly path?: string | undefined;
63
+ readonly scope?: DenialScope | undefined;
64
+
65
+ constructor(code: SeamErrorCode, message: string, extra: { readonly path?: string | undefined; readonly scope?: DenialScope | undefined } = {}) {
66
+ super(message);
67
+ this.name = 'SeamError';
68
+ this.code = code;
69
+ this.path = extra.path;
70
+ this.scope = extra.scope;
71
+ }
72
+ }
73
+
74
+ export type UnitWriteScope = 'unit' | 'workspace';
75
+
76
+ export interface UnitSeamRequest {
77
+ readonly command: string;
78
+ readonly scope: UnitWriteScope;
79
+ readonly workdir?: string | undefined;
80
+ readonly timeoutMs?: number | undefined;
81
+ readonly env?: Readonly<Record<string, string>> | undefined;
82
+ readonly session?: unknown;
83
+ readonly signal?: AbortSignal | undefined;
84
+ readonly retry?: 'safe' | 'never' | undefined;
85
+ }
86
+
87
+ export function productWrite(): WriteIntent {
88
+ return { kind: 'product' };
89
+ }
90
+
91
+ export function workspaceWrite(): WriteIntent {
92
+ return { kind: 'workspace' };
93
+ }
94
+
95
+ export function pathsWrite(paths: readonly string[]): WriteIntent {
96
+ return { kind: 'paths', paths };
97
+ }
98
+
99
+ export function noWrite(): WriteIntent {
100
+ return { kind: 'none' };
101
+ }
@@ -0,0 +1,96 @@
1
+ import { dirname, isAbsolute, parse, resolve, sep } from 'node:path';
2
+ import type { DenialScope, SeamMode, SeamPolicy, WriteIntent } from './intent.js';
3
+ import { SeamError } from './intent.js';
4
+
5
+ export const SEAM_MODES: readonly SeamMode[] = ['read-only', 'workspace-write', 'danger-full-access'];
6
+
7
+ export function seamModeOf(value: string | undefined): SeamMode {
8
+ const text = value?.trim();
9
+ return text === 'read-only' || text === 'workspace-write' || text === 'danger-full-access' ? text : 'workspace-write';
10
+ }
11
+
12
+ function rank(mode: SeamMode): number {
13
+ return mode === 'read-only' ? 0 : mode === 'workspace-write' ? 1 : 2;
14
+ }
15
+
16
+ export function narrowerMode(left: SeamMode, right: SeamMode): SeamMode {
17
+ return rank(left) <= rank(right) ? left : right;
18
+ }
19
+
20
+ export function insideOf(root: string, path: string): boolean {
21
+ const confined = resolve(root);
22
+ const target = resolve(path);
23
+ if (target === confined) return true;
24
+ return target.startsWith(confined.endsWith(sep) ? confined : `${confined}${sep}`);
25
+ }
26
+
27
+ export function productPolicy(ceiling: SeamMode, productRoot: string): SeamPolicy {
28
+ return {
29
+ mode: ceiling === 'read-only' ? 'read-only' : ceiling === 'danger-full-access' ? 'danger-full-access' : 'workspace-write',
30
+ workspaceRoot: resolve(productRoot),
31
+ };
32
+ }
33
+
34
+ export function workspacePolicy(ceiling: SeamMode, grant: SeamPolicy | undefined): SeamPolicy {
35
+ if (grant === undefined) {
36
+ throw new SeamError('workspace-unknown', '这条会话解析不到工作区:先在左栏选一个工作区再跑需要写工作区的命令');
37
+ }
38
+ const mode = narrowerMode(ceiling, grant.mode);
39
+ if (mode === 'read-only') {
40
+ throw new SeamError('read-only', '当前会话是只读模式:要写工作区,先在权限设置里切换模式');
41
+ }
42
+ return { mode, workspaceRoot: resolve(grant.workspaceRoot) };
43
+ }
44
+
45
+ function atFilesystemRoot(path: string): boolean {
46
+ return parse(path).root === path;
47
+ }
48
+
49
+ function commonDirectory(paths: readonly string[]): string | undefined {
50
+ const dirs = paths.map((path) => dirname(resolve(path)));
51
+ let shared = dirs[0];
52
+ if (shared === undefined) return undefined;
53
+ for (const dir of dirs.slice(1)) {
54
+ while (shared !== dirname(shared) && !insideOf(shared, dir)) {
55
+ shared = dirname(shared);
56
+ }
57
+ }
58
+ return shared;
59
+ }
60
+
61
+ export function pathsPolicy(ceiling: SeamMode, paths: readonly string[]): SeamPolicy {
62
+ if (paths.length === 0) {
63
+ throw new SeamError('paths-span-trees', '这次写入没有给出任何路径');
64
+ }
65
+ if (!paths.every((path) => isAbsolute(path))) {
66
+ throw new SeamError('paths-span-trees', '外部路径必须是绝对路径');
67
+ }
68
+ if (ceiling === 'read-only') {
69
+ throw new SeamError('read-only', '当前部署是只读模式:这条命令不能写外部路径');
70
+ }
71
+ const root = commonDirectory(paths);
72
+ if (root === undefined) {
73
+ throw new SeamError('paths-span-trees', '这次写入没有给出任何路径');
74
+ }
75
+ if (atFilesystemRoot(root)) {
76
+ throw new SeamError(
77
+ 'paths-span-trees',
78
+ paths.length > 1 ? '这次写入跨了多个目录:请分开做,或把产物改到工作台目录' : '不能把整个文件系统声明成写入域',
79
+ );
80
+ }
81
+ return { mode: 'workspace-write', workspaceRoot: root };
82
+ }
83
+
84
+ export function denialScopeOf(path: string | undefined, policy: SeamPolicy, productRoot: string, grant: SeamPolicy | undefined): DenialScope {
85
+ if (path === undefined) return 'unknown';
86
+ if (insideOf(productRoot, path)) return 'product';
87
+ if (grant !== undefined && insideOf(grant.workspaceRoot, path)) return 'workspace';
88
+ if (insideOf(policy.workspaceRoot, path)) return policy.workspaceRoot === resolve(productRoot) ? 'product' : 'workspace';
89
+ return 'external';
90
+ }
91
+
92
+ export function policyOf(intent: WriteIntent, ceiling: SeamMode, productRoot: string, grant: SeamPolicy | undefined): SeamPolicy {
93
+ if (intent.kind === 'product' || intent.kind === 'none') return productPolicy(ceiling, productRoot);
94
+ if (intent.kind === 'workspace') return workspacePolicy(ceiling, grant);
95
+ return pathsPolicy(ceiling, intent.paths);
96
+ }
@@ -0,0 +1,90 @@
1
+ import { closeSync, openSync } from 'node:fs';
2
+ import type { Readable, Writable } from 'node:stream';
3
+ import { execa, type Options, type ResultPromise } from 'execa';
4
+ import type {
5
+ ProcessChild,
6
+ ProcessExitEvent,
7
+ ProcessHost,
8
+ ProcessSpawnOptions,
9
+ ProcessStream,
10
+ } from './exec.js';
11
+
12
+ function streamOf(readable: Readable | null | undefined): ProcessStream | null {
13
+ if (readable === null || readable === undefined) return null;
14
+ return {
15
+ onData(listener) {
16
+ const handler = (chunk: unknown): void => listener(String(chunk));
17
+ readable.on('data', handler);
18
+ return () => {
19
+ readable.off('data', handler);
20
+ };
21
+ },
22
+ };
23
+ }
24
+
25
+ export function createNodeProcessHost(): ProcessHost {
26
+ return {
27
+ spawn(options: ProcessSpawnOptions): ProcessChild {
28
+ const logFd =
29
+ options.stdio === 'log' && options.logFile !== undefined ? openSync(options.logFile, 'a') : undefined;
30
+ const stdio: Options['stdio'] =
31
+ options.stdio === 'pipe'
32
+ ? 'pipe'
33
+ : logFd === undefined
34
+ ? 'ignore'
35
+ : (['ignore', logFd, logFd] as unknown as Options['stdio']);
36
+ let logClosed = false;
37
+ const closeLog = (): void => {
38
+ if (logClosed || logFd === undefined) return;
39
+ logClosed = true;
40
+ closeSync(logFd);
41
+ };
42
+ let child: ResultPromise;
43
+ try {
44
+ child = execa(options.file, [...options.args], {
45
+ ...(options.cwd === undefined ? {} : { cwd: options.cwd }),
46
+ env: { ...options.env },
47
+ detached: options.detached,
48
+ stdio,
49
+ windowsHide: options.windowsHide,
50
+ reject: false,
51
+ buffer: false,
52
+ } as Options);
53
+ } catch (error) {
54
+ closeLog();
55
+ throw error;
56
+ }
57
+ if (logFd !== undefined) {
58
+ child.nodeChildProcess.once('spawn', closeLog);
59
+ child.nodeChildProcess.once('error', closeLog);
60
+ }
61
+ const stdin = child.stdin as Writable | null | undefined;
62
+ return {
63
+ pid: child.pid,
64
+ stdin: stdin === null || stdin === undefined ? null : { write: (chunk: string) => stdin.write(chunk) },
65
+ stdout: streamOf(child.stdout as Readable | null | undefined),
66
+ stderr: streamOf(child.stderr as Readable | null | undefined),
67
+ onExit(listener) {
68
+ const handler = (code: number | null, signal: string | null): void =>
69
+ listener({ code, signal } satisfies ProcessExitEvent);
70
+ child.nodeChildProcess.on('exit', handler);
71
+ return () => {
72
+ child.nodeChildProcess.off('exit', handler);
73
+ };
74
+ },
75
+ onError(listener) {
76
+ child.nodeChildProcess.on('error', listener);
77
+ return () => {
78
+ child.nodeChildProcess.off('error', listener);
79
+ };
80
+ },
81
+ unref() {
82
+ child.nodeChildProcess.unref();
83
+ },
84
+ kill(signal) {
85
+ child.kill(signal as NodeJS.Signals | undefined);
86
+ },
87
+ };
88
+ },
89
+ };
90
+ }