@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.
- package/README.md +1 -1
- package/dist/api/dsh.d.ts +12 -0
- package/dist/components/SelectFieldWheel.d.ts +9 -0
- package/dist/components/SelectFieldWheel.js +35 -0
- package/dist/contract/unit-declaration.js +15 -1
- package/dist/contract/unit.d.ts +3 -0
- package/dist/host/__scratch-guard.d.ts +1 -0
- package/dist/host/__scratch-guard.js +1 -0
- package/dist/host/adapter-set.js +0 -1
- package/dist/host/changelog.js +2 -1
- package/dist/host/channel-plugin.d.ts +15 -0
- package/dist/host/channel-plugin.js +42 -0
- package/dist/host/command-name.d.ts +1 -0
- package/dist/host/command-name.js +14 -0
- package/dist/host/config-sync.d.ts +1 -9
- package/dist/host/config-sync.js +3 -78
- package/dist/host/env-policy.d.ts +75 -0
- package/dist/host/env-policy.js +161 -0
- package/dist/host/extension.js +2 -1
- package/dist/host/flow/run.js +2 -1
- package/dist/host/fs-write.d.ts +68 -0
- package/dist/host/fs-write.js +121 -0
- package/dist/host/host-kit.d.ts +6 -2
- package/dist/host/host-kit.js +3 -1
- package/dist/host/http.js +11 -5
- package/dist/host/installed-sources.js +4 -5
- package/dist/host/long-task.js +3 -3
- package/dist/host/opencli-runner.d.ts +26 -0
- package/dist/host/opencli-runner.js +50 -0
- package/dist/host/package-content.d.ts +2 -2
- package/dist/host/package-content.js +20 -6
- package/dist/host/package-route.d.ts +3 -3
- package/dist/host/package-route.js +7 -6
- package/dist/host/panel-state.js +2 -1
- package/dist/host/patch-blocks.d.ts +13 -0
- package/dist/host/patch-blocks.js +52 -0
- package/dist/host/patch.d.ts +3 -8
- package/dist/host/patch.js +5 -76
- package/dist/host/plugin-source.d.ts +33 -0
- package/dist/host/plugin-source.js +91 -0
- package/dist/host/port-types.d.ts +9 -9
- package/dist/host/profile-registries.js +2 -3
- package/dist/host/registry-declaration.d.ts +2 -2
- package/dist/host/registry-declaration.js +14 -5
- package/dist/host/release-probe.d.ts +4 -4
- package/dist/host/release-probe.js +39 -14
- package/dist/host/runner.d.ts +26 -0
- package/dist/host/runner.js +50 -0
- package/dist/host/runtime-links.d.ts +7 -0
- package/dist/host/runtime-links.js +25 -0
- package/dist/host/seam/denial.d.ts +4 -0
- package/dist/host/seam/denial.js +24 -0
- package/dist/host/seam/env.d.ts +15 -0
- package/dist/host/seam/env.js +17 -0
- package/dist/host/seam/exec.d.ts +84 -0
- package/dist/host/seam/exec.js +1 -0
- package/dist/host/seam/host-env.d.ts +2 -0
- package/dist/host/seam/host-env.js +18 -0
- package/dist/host/seam/intent.d.ts +66 -0
- package/dist/host/seam/intent.js +24 -0
- package/dist/host/seam/policy.d.ts +10 -0
- package/dist/host/seam/policy.js +88 -0
- package/dist/host/seam/process.d.ts +2 -0
- package/dist/host/seam/process.js +80 -0
- package/dist/host/seam/run.d.ts +44 -0
- package/dist/host/seam/run.js +415 -0
- package/dist/host/seam/service.d.ts +4 -0
- package/dist/host/seam/service.js +4 -0
- package/dist/host/seam/unit-seam.d.ts +14 -0
- package/dist/host/seam/unit-seam.js +43 -0
- package/dist/host/shell-render.d.ts +9 -0
- package/dist/host/shell-render.js +18 -0
- package/dist/host/shell.d.ts +12 -0
- package/dist/host/shell.js +3 -1
- package/dist/host/time.d.ts +10 -0
- package/dist/host/time.js +14 -0
- package/dist/host/value-handoff.js +3 -2
- package/dist/host/workspace-store.js +2 -8
- package/dist/host.d.ts +12 -0
- package/dist/host.js +12 -0
- package/dist/i18n/en.js +3 -3
- package/dist/i18n/index.d.ts +4 -4
- package/dist/i18n/zh.d.ts +3 -3
- package/dist/i18n/zh.js +3 -3
- package/dist/lib/version.d.ts +4 -0
- package/dist/lib/version.js +21 -0
- package/dist/testing/fake-adapter-set.js +0 -1
- package/dist/testing/git.d.ts +3 -0
- package/dist/testing/git.js +36 -0
- package/dist/testing/host-fakes.d.ts +30 -0
- package/dist/testing/host-fakes.js +104 -0
- package/package.json +5 -2
- package/src/api/dsh.ts +8 -1
- package/src/contract/unit-declaration.ts +20 -1
- package/src/contract/unit.ts +3 -0
- package/src/host/__tests__/env-policy.test.ts +114 -0
- package/src/host/__tests__/fs-write.test.ts +96 -0
- package/src/host/__tests__/preflight-route.test.ts +29 -0
- package/src/host/__tests__/seam.test.ts +354 -0
- package/src/host/__tests__/shell-render.test.ts +58 -0
- package/src/host/__tests__/shell.test.ts +24 -0
- package/src/host/__tests__/time.test.ts +20 -0
- package/src/host/adapter-set.ts +0 -1
- package/src/host/changelog.ts +2 -1
- package/src/host/config-sync.ts +3 -90
- package/src/host/env-policy.ts +192 -0
- package/src/host/extension.ts +2 -1
- package/src/host/flow/run.ts +2 -1
- package/src/host/fs-write.ts +147 -0
- package/src/host/host-kit.ts +7 -2
- package/src/host/http.ts +11 -5
- package/src/host/installed-sources.ts +4 -5
- package/src/host/long-task.ts +3 -3
- package/src/host/package-content.ts +23 -13
- package/src/host/package-route.ts +10 -9
- package/src/host/panel-state.ts +2 -1
- package/src/host/patch-blocks.ts +67 -0
- package/src/host/patch.ts +5 -89
- package/src/host/port-types.ts +9 -9
- package/src/host/profile-registries.ts +2 -3
- package/src/host/registry-declaration.ts +17 -12
- package/src/host/release-probe.ts +47 -34
- package/src/host/seam/denial.ts +26 -0
- package/src/host/seam/env.ts +23 -0
- package/src/host/seam/exec.ts +94 -0
- package/src/host/seam/host-env.ts +20 -0
- package/src/host/seam/intent.ts +101 -0
- package/src/host/seam/policy.ts +96 -0
- package/src/host/seam/process.ts +90 -0
- package/src/host/seam/run.ts +478 -0
- package/src/host/seam/service.ts +8 -0
- package/src/host/seam/unit-seam.ts +62 -0
- package/src/host/shell-render.ts +28 -0
- package/src/host/shell.ts +17 -1
- package/src/host/time.ts +16 -0
- package/src/host/value-handoff.ts +3 -2
- package/src/host/workspace-store.ts +2 -8
- package/src/host.ts +12 -0
- package/src/i18n/en.ts +3 -3
- package/src/i18n/zh.ts +3 -3
- package/src/testing/fake-adapter-set.ts +0 -1
- package/src/testing/host-fakes.ts +126 -0
- package/dist/lib/unit-plugin.d.ts +0 -28
- package/dist/lib/unit-plugin.js +0 -23
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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 {
|
|
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 &&
|
|
101
|
+
if (!force && hit !== undefined && now() - hit.at < VERSION_CHECK_TTL_MS)
|
|
101
102
|
return hit.value;
|
|
102
103
|
|
|
103
|
-
const result = await
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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:
|
|
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
|
-
|
|
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 {
|
|
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 ||
|
|
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
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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:
|
|
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
|
-
|
|
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 {
|
|
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, {
|
|
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
|
-
|
|
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 {
|
|
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
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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, {
|
|
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
|
+
}
|