@dsh-workbench/shared 1.1.4 → 1.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api/host-write.d.ts +4 -1
- package/dist/api/workbench-client.d.ts +2 -0
- package/dist/components/ApprovalPlan.css +7 -8
- package/dist/config/constants.d.ts +4 -0
- package/dist/config/constants.js +4 -0
- package/dist/contract/approval.d.ts +4 -0
- package/dist/contract/approval.js +4 -0
- package/dist/contract/command.d.ts +33 -0
- package/dist/contract/command.js +1 -1
- package/dist/contract/needs.d.ts +17 -0
- package/dist/contract/needs.js +41 -1
- package/dist/contract/protocol/bundle-install.d.ts +2 -0
- package/dist/contract/unit.d.ts +10 -0
- package/dist/host/adapter-types.d.ts +2 -0
- package/dist/host/data-error.d.ts +2 -0
- package/dist/host/data-error.js +3 -0
- package/dist/host/http.js +1 -0
- package/dist/host/install-failure.d.ts +49 -0
- package/dist/host/install-failure.js +167 -0
- package/dist/host/installed-sources.d.ts +10 -0
- package/dist/host/installed-sources.js +64 -0
- package/dist/host/lib-install.d.ts +12 -1
- package/dist/host/lib-install.js +33 -1
- package/dist/host/paths.d.ts +7 -0
- package/dist/host/paths.js +17 -0
- package/dist/host/runtime-links.d.ts +7 -0
- package/dist/host/runtime-links.js +25 -0
- package/dist/host/tool-approval.d.ts +4 -2
- package/dist/host/tool-approval.js +4 -2
- package/dist/host.d.ts +3 -0
- package/dist/host.js +3 -0
- package/dist/i18n/en.js +40 -0
- package/dist/i18n/index.d.ts +41 -1
- package/dist/i18n/zh.d.ts +40 -0
- package/dist/i18n/zh.js +40 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/package.json +1 -1
- package/src/api/host-write.ts +5 -1
- package/src/api/workbench-client.ts +2 -0
- package/src/components/ApprovalPlan.css +7 -8
- package/src/config/constants.ts +6 -0
- package/src/contract/__tests__/needs.test.ts +53 -0
- package/src/contract/approval.ts +4 -0
- package/src/contract/command.ts +22 -0
- package/src/contract/needs.ts +56 -1
- package/src/contract/protocol/bundle-install.ts +3 -0
- package/src/contract/unit.ts +11 -0
- package/src/host/adapter-types.ts +2 -0
- package/src/host/data-error.ts +3 -1
- package/src/host/http.ts +1 -0
- package/src/host/install-failure.test.ts +121 -0
- package/src/host/install-failure.ts +198 -0
- package/src/host/installed-sources.test.ts +80 -0
- package/src/host/installed-sources.ts +73 -0
- package/src/host/lib-install.ts +38 -2
- package/src/host/paths.test.ts +26 -0
- package/src/host/paths.ts +18 -0
- package/src/host/runtime-links.test.ts +31 -0
- package/src/host/runtime-links.ts +23 -0
- package/src/host/tool-approval.ts +11 -4
- package/src/host.ts +3 -0
- package/src/i18n/en.ts +41 -0
- package/src/i18n/zh.ts +41 -0
- package/src/index.ts +1 -0
package/src/api/host-write.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { InstallFailure } from '../contract/command.js';
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* 宿主写口的五条通道(偏好、动作、供给、工坊调试、工坊录制),是写路由的第一段。
|
|
3
5
|
*/
|
|
@@ -9,7 +11,8 @@ export type WriteChannel =
|
|
|
9
11
|
| 'diy-record';
|
|
10
12
|
|
|
11
13
|
/**
|
|
12
|
-
*
|
|
14
|
+
* 写通道统一的结果:成了带值,没成带一句能给人看的话(可选明细与帮助链接);
|
|
15
|
+
* 安装类的失败多带一份结构化现场,界面据此给修法。
|
|
13
16
|
*/
|
|
14
17
|
export type WriteOutcome<T> =
|
|
15
18
|
| { readonly ok: true; readonly value: T }
|
|
@@ -18,4 +21,5 @@ export type WriteOutcome<T> =
|
|
|
18
21
|
readonly message: string;
|
|
19
22
|
readonly detail?: string;
|
|
20
23
|
readonly helpUrl?: string;
|
|
24
|
+
readonly failure?: InstallFailure;
|
|
21
25
|
};
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { API_PREFIX } from '../config/constants.js';
|
|
2
|
+
import type { InstallFailure } from '../contract/command.js';
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* 宿主读口 / 写口回的错误:code 给判据分档,message 给人看,helpUrl 有就去点。
|
|
@@ -9,6 +10,7 @@ export interface ApiError {
|
|
|
9
10
|
readonly detail?: string;
|
|
10
11
|
readonly hint?: string;
|
|
11
12
|
readonly helpUrl?: string;
|
|
13
|
+
readonly failure?: InstallFailure;
|
|
12
14
|
}
|
|
13
15
|
|
|
14
16
|
/**
|
|
@@ -10,22 +10,21 @@
|
|
|
10
10
|
color: var(--dsw-alias-label-secondary);
|
|
11
11
|
}
|
|
12
12
|
.wb-approvalRows {
|
|
13
|
-
display:
|
|
14
|
-
|
|
15
|
-
gap: var(--wb-len-
|
|
13
|
+
display: grid;
|
|
14
|
+
grid-template-columns: minmax(var(--wb-len-56), auto) minmax(0, 1fr);
|
|
15
|
+
column-gap: var(--wb-len-8);
|
|
16
|
+
row-gap: var(--wb-len-2);
|
|
17
|
+
align-items: baseline;
|
|
16
18
|
margin: 0;
|
|
17
19
|
}
|
|
18
20
|
.wb-approvalRow {
|
|
19
|
-
display:
|
|
20
|
-
align-items: baseline;
|
|
21
|
-
gap: var(--wb-len-8);
|
|
21
|
+
display: contents;
|
|
22
22
|
}
|
|
23
23
|
.wb-approvalLabel {
|
|
24
|
-
flex: none;
|
|
25
|
-
width: var(--wb-len-56);
|
|
26
24
|
font-size: var(--dsw-font-xxs-12-font-size);
|
|
27
25
|
line-height: var(--wb-line-18);
|
|
28
26
|
color: var(--dsw-alias-label-tertiary);
|
|
27
|
+
overflow-wrap: anywhere;
|
|
29
28
|
}
|
|
30
29
|
.wb-approvalValue {
|
|
31
30
|
margin: 0;
|
package/src/config/constants.ts
CHANGED
|
@@ -109,6 +109,12 @@ export const STORAGE_KEY_RIGHTBAR_COLUMN = "dsh-workbench.rightbar.column";
|
|
|
109
109
|
/** 记住右栏宽度(像素数字的字符串)的 localStorage 键。 */
|
|
110
110
|
export const STORAGE_KEY_RIGHTBAR_WIDTH = "dsh-workbench.rightbar.width";
|
|
111
111
|
|
|
112
|
+
/** 记住「添加模块」浮层选过的安装源的 localStorage 键。 */
|
|
113
|
+
export const STORAGE_KEY_MARKET_REGISTRY = "dsh-workbench.market.registry";
|
|
114
|
+
|
|
115
|
+
/** 记住「添加模块」浮层上次填过的自定义源地址的 localStorage 键。 */
|
|
116
|
+
export const STORAGE_KEY_MARKET_REGISTRY_CUSTOM = "dsh-workbench.market.registry.custom";
|
|
117
|
+
|
|
112
118
|
/** 右栏宽度达到或超过多少像素就按全屏对待(阈值,像素)。 */
|
|
113
119
|
export const RIGHTBAR_FULLSCREEN_WIDTH = 768;
|
|
114
120
|
|
|
@@ -37,6 +37,59 @@ describe('needs 校验矩阵', () => {
|
|
|
37
37
|
);
|
|
38
38
|
});
|
|
39
39
|
|
|
40
|
+
it('bin 声明 install → 过;install 写歪了要判红并点名', () => {
|
|
41
|
+
expect(
|
|
42
|
+
errorsOf({
|
|
43
|
+
kind: 'bin',
|
|
44
|
+
name: 'gitops',
|
|
45
|
+
purpose: '平台命令',
|
|
46
|
+
install: { kind: 'command', line: 'brew install pagoda/tap/gitops', platforms: ['darwin'] },
|
|
47
|
+
docs: 'https://internal/gitops',
|
|
48
|
+
}),
|
|
49
|
+
).toEqual([]);
|
|
50
|
+
|
|
51
|
+
expect(errorsOf({ kind: 'bin', name: 'x', purpose: 'p', install: { kind: 'brew', line: 'x' } }).join(' ')).toContain(
|
|
52
|
+
'install.kind 只认 command',
|
|
53
|
+
);
|
|
54
|
+
expect(errorsOf({ kind: 'bin', name: 'x', purpose: 'p', install: { kind: 'command', line: '' } }).join(' ')).toContain(
|
|
55
|
+
'install.line 没写',
|
|
56
|
+
);
|
|
57
|
+
expect(
|
|
58
|
+
errorsOf({ kind: 'bin', name: 'x', purpose: 'p', install: { kind: 'command', line: 'x', platforms: ['mac'] } }).join(' '),
|
|
59
|
+
).toContain('darwin / linux / win32');
|
|
60
|
+
expect(errorsOf({ kind: 'bin', name: 'x', purpose: 'p', install: { kind: 'command', line: 'x', url: 'y' } }).join(' ')).toContain(
|
|
61
|
+
'install 有不认识的字段 url',
|
|
62
|
+
);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('bin 的 install 也可以是一个 npm 包(命令由包提供,装进隔离运行时)', () => {
|
|
66
|
+
expect(
|
|
67
|
+
errorsOf({
|
|
68
|
+
kind: 'bin',
|
|
69
|
+
name: 'gitops',
|
|
70
|
+
purpose: '平台命令',
|
|
71
|
+
install: {
|
|
72
|
+
kind: 'npm',
|
|
73
|
+
package: 'opencli-workflow',
|
|
74
|
+
registryEnv: 'ACME_REGISTRY',
|
|
75
|
+
registryDefault: 'https://registry.test/',
|
|
76
|
+
},
|
|
77
|
+
}),
|
|
78
|
+
).toEqual([]);
|
|
79
|
+
|
|
80
|
+
expect(errorsOf({ kind: 'bin', name: 'x', purpose: 'p', install: { kind: 'npm' } }).join(' ')).toContain(
|
|
81
|
+
'install.package 没写',
|
|
82
|
+
);
|
|
83
|
+
expect(
|
|
84
|
+
errorsOf({
|
|
85
|
+
kind: 'bin',
|
|
86
|
+
name: 'x',
|
|
87
|
+
purpose: 'p',
|
|
88
|
+
install: { kind: 'npm', package: 'x', registryDefault: 'ftp://x' },
|
|
89
|
+
}).join(' '),
|
|
90
|
+
).toContain('registryDefault 要是 http(s):// 或 file:// 的源地址');
|
|
91
|
+
});
|
|
92
|
+
|
|
40
93
|
it('bin / env 写了 sources → 红(没有安装路径)', () => {
|
|
41
94
|
expect(errorsOf({ kind: 'bin', name: 'x', purpose: '测试', sources: [npm] }).join(' ')).toContain(
|
|
42
95
|
'不支持 sources',
|
package/src/contract/command.ts
CHANGED
|
@@ -23,3 +23,25 @@ export interface CommandResult {
|
|
|
23
23
|
export interface CommandReader {
|
|
24
24
|
run(request: CommandRequest): Promise<CommandResult>;
|
|
25
25
|
}
|
|
26
|
+
|
|
27
|
+
export const INSTALL_FAILURE_KINDS = ['permission', 'disk-full', 'sandbox', 'unknown'] as const;
|
|
28
|
+
|
|
29
|
+
export type InstallFailureKind = (typeof INSTALL_FAILURE_KINDS)[number];
|
|
30
|
+
|
|
31
|
+
export type InstallFix =
|
|
32
|
+
| { readonly kind: 'chown'; readonly path: string; readonly uid: number; readonly gid: number }
|
|
33
|
+
| { readonly kind: 'chmod'; readonly path: string }
|
|
34
|
+
| { readonly kind: 'space'; readonly path: string }
|
|
35
|
+
| { readonly kind: 'handoff' };
|
|
36
|
+
|
|
37
|
+
export interface InstallFailure {
|
|
38
|
+
readonly kind: InstallFailureKind;
|
|
39
|
+
readonly command: string;
|
|
40
|
+
readonly exitCode: number | null;
|
|
41
|
+
readonly output: string;
|
|
42
|
+
readonly path?: string | undefined;
|
|
43
|
+
readonly mode?: string | undefined;
|
|
44
|
+
readonly owner?: { readonly uid: number; readonly gid: number } | undefined;
|
|
45
|
+
readonly current: { readonly uid: number; readonly gid: number };
|
|
46
|
+
readonly fix: InstallFix;
|
|
47
|
+
}
|
package/src/contract/needs.ts
CHANGED
|
@@ -41,12 +41,31 @@ export interface NeedCommon {
|
|
|
41
41
|
readonly id?: string;
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
/**
|
|
45
|
+
* 一条装这个可执行文件的命令;模块作者写,工作台照抄执行。
|
|
46
|
+
*/
|
|
47
|
+
export type BinInstall =
|
|
48
|
+
| {
|
|
49
|
+
readonly kind: 'command';
|
|
50
|
+
readonly line: string;
|
|
51
|
+
readonly platforms?: readonly string[] | undefined;
|
|
52
|
+
}
|
|
53
|
+
| {
|
|
54
|
+
readonly kind: 'npm';
|
|
55
|
+
readonly package: string;
|
|
56
|
+
readonly registryEnv?: string | undefined;
|
|
57
|
+
readonly registryDefault?: string | undefined;
|
|
58
|
+
readonly platforms?: readonly string[] | undefined;
|
|
59
|
+
};
|
|
60
|
+
|
|
44
61
|
/**
|
|
45
62
|
* 机器上要有一个可执行文件;探法是 `command -v` / `where`。
|
|
46
63
|
*/
|
|
47
64
|
export interface BinNeed extends NeedCommon {
|
|
48
65
|
readonly kind: 'bin';
|
|
49
66
|
readonly name: string;
|
|
67
|
+
readonly install?: BinInstall | undefined;
|
|
68
|
+
readonly docs?: string | undefined;
|
|
50
69
|
}
|
|
51
70
|
|
|
52
71
|
/**
|
|
@@ -107,6 +126,8 @@ export const NEED_KEYS: readonly string[] = [
|
|
|
107
126
|
'version',
|
|
108
127
|
'sources',
|
|
109
128
|
'secret',
|
|
129
|
+
'install',
|
|
130
|
+
'docs',
|
|
110
131
|
];
|
|
111
132
|
|
|
112
133
|
/**
|
|
@@ -188,7 +209,7 @@ function sourcesProblemsOf(
|
|
|
188
209
|
if (!Array.isArray(raw)) return [`${at}.sources 要是数组`];
|
|
189
210
|
if (raw.length === 0) return [`${at}.sources 是空数组 —— 不代装就整条删掉`];
|
|
190
211
|
if (allowed.length === 0) {
|
|
191
|
-
return [`${at} 这种需要不支持 sources ——
|
|
212
|
+
return [`${at} 这种需要不支持 sources —— 要代装就写 install(一条命令)`];
|
|
192
213
|
}
|
|
193
214
|
const errors: string[] = [];
|
|
194
215
|
raw.forEach((source, index) => {
|
|
@@ -214,6 +235,38 @@ function commonProblemsOf(raw: Readonly<Record<string, unknown>>, at: string): r
|
|
|
214
235
|
return errors;
|
|
215
236
|
}
|
|
216
237
|
|
|
238
|
+
export function binInstallProblemsOf(raw: unknown, at: string): readonly string[] {
|
|
239
|
+
if (raw === undefined) return [];
|
|
240
|
+
if (!isRecord(raw)) return [`${at}.install 要是对象`];
|
|
241
|
+
const errors: string[] = [];
|
|
242
|
+
for (const key of Object.keys(raw)) {
|
|
243
|
+
if (!['kind', 'line', 'platforms', 'package', 'registryEnv', 'registryDefault'].includes(key)) {
|
|
244
|
+
errors.push(`${at}.install 有不认识的字段 ${key}`);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
const kind = text(raw['kind']);
|
|
248
|
+
if (kind !== 'command' && kind !== 'npm') {
|
|
249
|
+
errors.push(`${at}.install.kind 只认 command(一条命令)或 npm(一个包,装进隔离运行时)`);
|
|
250
|
+
}
|
|
251
|
+
if (kind === 'command' && text(raw['line']) === '') {
|
|
252
|
+
errors.push(`${at}.install.line 没写 —— 要一条能直接粘进终端的命令`);
|
|
253
|
+
}
|
|
254
|
+
if (kind === 'npm' && text(raw['package']) === '') {
|
|
255
|
+
errors.push(`${at}.install.package 没写 —— 这个命令由哪个 npm 包提供`);
|
|
256
|
+
}
|
|
257
|
+
if (raw['registryDefault'] !== undefined && !REGISTRY_PATTERN.test(text(raw['registryDefault']))) {
|
|
258
|
+
errors.push(`${at}.install.registryDefault 要是 http(s):// 或 file:// 的源地址`);
|
|
259
|
+
}
|
|
260
|
+
if (raw['platforms'] !== undefined) {
|
|
261
|
+
if (!Array.isArray(raw['platforms']) || raw['platforms'].length === 0) {
|
|
262
|
+
errors.push(`${at}.install.platforms 要是非空数组`);
|
|
263
|
+
} else if (raw['platforms'].some((one) => !['darwin', 'linux', 'win32'].includes(text(one)))) {
|
|
264
|
+
errors.push(`${at}.install.platforms 只认 darwin / linux / win32`);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
return errors;
|
|
268
|
+
}
|
|
269
|
+
|
|
217
270
|
function perKindProblemsOf(
|
|
218
271
|
kind: NeedKind,
|
|
219
272
|
raw: Readonly<Record<string, unknown>>,
|
|
@@ -223,6 +276,8 @@ function perKindProblemsOf(
|
|
|
223
276
|
if (kind === 'bin' || kind === 'lib' || kind === 'env') {
|
|
224
277
|
if (text(raw['name']) === '') errors.push(`${at}.name 没写`);
|
|
225
278
|
}
|
|
279
|
+
if (kind === 'bin') errors.push(...binInstallProblemsOf(raw['install'], at));
|
|
280
|
+
if (raw['docs'] !== undefined && text(raw['docs']) === '') errors.push(`${at}.docs 是空串`);
|
|
226
281
|
if (kind === 'extension') {
|
|
227
282
|
if (text(raw['runtime']) === '') errors.push(`${at}.runtime 没写 —— 扩展属于哪个运行时(如 opencli)`);
|
|
228
283
|
if (text(raw['name']) === '') errors.push(`${at}.name 没写`);
|
|
@@ -179,6 +179,8 @@ export interface BundleCancellationPayload {
|
|
|
179
179
|
|
|
180
180
|
export type ModuleUpdateBlock = 'needs-workbench' | 'incompatible';
|
|
181
181
|
|
|
182
|
+
export type LatestUnavailable = 'local' | 'no-registry' | 'not-registry' | 'probe-failed';
|
|
183
|
+
|
|
182
184
|
export interface ModuleVersion {
|
|
183
185
|
readonly unit: string;
|
|
184
186
|
readonly pkg: string;
|
|
@@ -186,6 +188,7 @@ export interface ModuleVersion {
|
|
|
186
188
|
readonly title?: UnitTitle;
|
|
187
189
|
readonly latest?: string;
|
|
188
190
|
readonly latestBlocked?: ModuleUpdateBlock;
|
|
191
|
+
readonly latestUnavailable?: LatestUnavailable;
|
|
189
192
|
readonly local?: boolean;
|
|
190
193
|
readonly refused?: readonly string[];
|
|
191
194
|
readonly market?: MarketInstalledModule;
|
package/src/contract/unit.ts
CHANGED
|
@@ -24,12 +24,23 @@ export interface UnitCard {
|
|
|
24
24
|
readonly order: number;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
/**
|
|
28
|
+
* 合成工具背后那条 opencli 命令:插件名与命令名都是 opencli 那边的原名,`args` 是命令自己声明的参数名(按声明顺序)。
|
|
29
|
+
*/
|
|
30
|
+
export interface UnitToolOpencli {
|
|
31
|
+
readonly plugin: string;
|
|
32
|
+
readonly command: string;
|
|
33
|
+
readonly args: readonly string[];
|
|
34
|
+
}
|
|
35
|
+
|
|
27
36
|
/**
|
|
28
37
|
* 一条工具在清单里的样子:全名加会不会写;审批按 writes 认领。
|
|
38
|
+
* `opencli` 只有「按已装插件合成」的单元写,手写模块的工具没有这一格。
|
|
29
39
|
*/
|
|
30
40
|
export interface UnitTool {
|
|
31
41
|
readonly name: string;
|
|
32
42
|
readonly writes: boolean;
|
|
43
|
+
readonly opencli?: UnitToolOpencli;
|
|
33
44
|
}
|
|
34
45
|
|
|
35
46
|
/**
|
|
@@ -25,6 +25,7 @@ export interface ProfileModule {
|
|
|
25
25
|
readonly version: string;
|
|
26
26
|
readonly registry?: string;
|
|
27
27
|
readonly local?: boolean;
|
|
28
|
+
readonly fromRegistry?: boolean;
|
|
28
29
|
}
|
|
29
30
|
|
|
30
31
|
export interface ProfilePackage {
|
|
@@ -51,6 +52,7 @@ export interface RegistryEntry {
|
|
|
51
52
|
export interface RegistryNotebook {
|
|
52
53
|
sources(): readonly RegistryEntry[];
|
|
53
54
|
remember(entries: readonly RegistryEntry[]): readonly string[];
|
|
55
|
+
swapScope?(scope: string, registry: string): () => void;
|
|
54
56
|
}
|
|
55
57
|
|
|
56
58
|
export interface SelfUpdatePaths {
|
package/src/host/data-error.ts
CHANGED
|
@@ -4,11 +4,12 @@ export class WorkbenchDataError extends Error {
|
|
|
4
4
|
readonly hint?: string;
|
|
5
5
|
readonly project?: unknown;
|
|
6
6
|
readonly helpUrl?: string;
|
|
7
|
+
readonly failure?: unknown;
|
|
7
8
|
|
|
8
9
|
constructor(
|
|
9
10
|
code: string,
|
|
10
11
|
message: string,
|
|
11
|
-
extra: { detail?: string; hint?: string; project?: unknown; helpUrl?: string } = {},
|
|
12
|
+
extra: { detail?: string; hint?: string; project?: unknown; helpUrl?: string; failure?: unknown } = {},
|
|
12
13
|
) {
|
|
13
14
|
super(message);
|
|
14
15
|
this.name = 'WorkbenchDataError';
|
|
@@ -17,5 +18,6 @@ export class WorkbenchDataError extends Error {
|
|
|
17
18
|
if (extra.hint !== undefined) this.hint = extra.hint;
|
|
18
19
|
if (extra.project !== undefined) this.project = extra.project;
|
|
19
20
|
if (extra.helpUrl !== undefined) this.helpUrl = extra.helpUrl;
|
|
21
|
+
if (extra.failure !== undefined) this.failure = extra.failure;
|
|
20
22
|
}
|
|
21
23
|
}
|
package/src/host/http.ts
CHANGED
|
@@ -49,6 +49,7 @@ function toErrorPayload(error: unknown): Record<string, unknown> {
|
|
|
49
49
|
...(data.hint === undefined ? {} : { hint: data.hint }),
|
|
50
50
|
...(data.helpUrl === undefined ? {} : { helpUrl: data.helpUrl }),
|
|
51
51
|
...(data.project === undefined ? {} : { project: data.project }),
|
|
52
|
+
...(data.failure === undefined ? {} : { failure: data.failure }),
|
|
52
53
|
};
|
|
53
54
|
}
|
|
54
55
|
return { code: 'internal', message: error instanceof Error ? error.message : String(error) };
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
classifyInstallFailure,
|
|
4
|
+
installFailureOf,
|
|
5
|
+
installTargets,
|
|
6
|
+
installWriteProblem,
|
|
7
|
+
} from './install-failure.js';
|
|
8
|
+
|
|
9
|
+
const CURRENT = { uid: 502, gid: 20 };
|
|
10
|
+
|
|
11
|
+
describe('installTargets(从我们自己的安装命令里挑出写点)', () => {
|
|
12
|
+
it('挑出 --prefix 与原样带空格的路径', () => {
|
|
13
|
+
expect(installTargets(`npm i --prefix '/Users/a b/x' foo@1 --cache /tmp/c`)).toEqual([
|
|
14
|
+
'/Users/a b/x',
|
|
15
|
+
'/tmp/c',
|
|
16
|
+
]);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('没有 --prefix / --cache 就没有写点', () => {
|
|
20
|
+
expect(installTargets('npm ls --json --depth 0')).toEqual([]);
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe('classifyInstallFailure', () => {
|
|
25
|
+
it('EPERM / EACCES 都算权限', () => {
|
|
26
|
+
expect(classifyInstallFailure('npm error code EACCES')).toBe('permission');
|
|
27
|
+
expect(classifyInstallFailure('npm error syscall open errno EPERM')).toBe('permission');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('ENOSPC 是盘满', () => {
|
|
31
|
+
expect(classifyInstallFailure('ENOSPC: no space left on device')).toBe('disk-full');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('认不出来就说认不出来', () => {
|
|
35
|
+
expect(classifyInstallFailure('')).toBe('unknown');
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe('installFailureOf(从现场推到修法)', () => {
|
|
40
|
+
it('属主不是当前账号 → chown 回自己', () => {
|
|
41
|
+
const failure = installFailureOf({
|
|
42
|
+
command: 'npm i --prefix /x foo@1',
|
|
43
|
+
exitCode: 1,
|
|
44
|
+
output: 'EACCES: permission denied',
|
|
45
|
+
path: '/x',
|
|
46
|
+
owner: { uid: 0, gid: 0 },
|
|
47
|
+
mode: '755',
|
|
48
|
+
writable: false,
|
|
49
|
+
current: CURRENT,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
expect(failure.kind).toBe('permission');
|
|
53
|
+
expect(failure.fix).toEqual({ kind: 'chown', path: '/x', uid: 502, gid: 20 });
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('属主是自己、只是没写位 → chmod,不给 sudo', () => {
|
|
57
|
+
const failure = installFailureOf({
|
|
58
|
+
command: 'npm i --prefix /x foo@1',
|
|
59
|
+
exitCode: 1,
|
|
60
|
+
output: 'EACCES: permission denied',
|
|
61
|
+
path: '/x',
|
|
62
|
+
owner: { uid: 502, gid: 20 },
|
|
63
|
+
mode: '555',
|
|
64
|
+
writable: false,
|
|
65
|
+
current: CURRENT,
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
expect(failure.fix).toEqual({ kind: 'chmod', path: '/x' });
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('属主是自己、明明可写却报 EPERM → 是被别的沙箱拦的,不给一条没用的 sudo', () => {
|
|
72
|
+
const failure = installFailureOf({
|
|
73
|
+
command: 'npm i --prefix /x foo@1',
|
|
74
|
+
exitCode: 1,
|
|
75
|
+
output: 'EPERM: operation not permitted',
|
|
76
|
+
path: '/x',
|
|
77
|
+
owner: { uid: 502, gid: 20 },
|
|
78
|
+
mode: '755',
|
|
79
|
+
writable: true,
|
|
80
|
+
current: CURRENT,
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
expect(failure.kind).toBe('sandbox');
|
|
84
|
+
expect(failure.fix).toEqual({ kind: 'handoff' });
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('盘满 → 指向精简的命令,而不是 chown', () => {
|
|
88
|
+
const failure = installFailureOf({
|
|
89
|
+
command: 'npm i --prefix /x foo@1',
|
|
90
|
+
exitCode: 1,
|
|
91
|
+
output: 'ENOSPC: no space left on device',
|
|
92
|
+
path: '/x',
|
|
93
|
+
current: CURRENT,
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
expect(failure.kind).toBe('disk-full');
|
|
97
|
+
expect(failure.fix).toEqual({ kind: 'space', path: '/x' });
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('认不出来 → 交接给 agent,不硬猜修法', () => {
|
|
101
|
+
const failure = installFailureOf({
|
|
102
|
+
command: 'npm i --prefix /x foo@1',
|
|
103
|
+
exitCode: 255,
|
|
104
|
+
output: 'something odd',
|
|
105
|
+
current: CURRENT,
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
expect(failure.kind).toBe('unknown');
|
|
109
|
+
expect(failure.fix).toEqual({ kind: 'handoff' });
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe('installWriteProblem(装之前先看一眼,省得白跑)', () => {
|
|
114
|
+
it('只盯安装动词:npm ls 这种读命令不拦', () => {
|
|
115
|
+
expect(installWriteProblem({ command: 'npm ls --prefix /x foo --json' })).toBeUndefined();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('目录不存在 → 退到最近的祖先,不报假问题', () => {
|
|
119
|
+
expect(installWriteProblem({ command: `npm i --prefix ${process.cwd()}/definitely-missing-dir foo` })).toBeUndefined();
|
|
120
|
+
});
|
|
121
|
+
});
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { accessSync, constants, statSync } from 'node:fs';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
3
|
+
import type { InstallFailure, InstallFailureKind } from '../contract/command.js';
|
|
4
|
+
|
|
5
|
+
const TARGET_FLAGS = ['--prefix', '--cache', '--dir', '--store-dir'];
|
|
6
|
+
|
|
7
|
+
const INSTALL_VERBS = /^\s*npm\s+(i|install|add)\b/;
|
|
8
|
+
|
|
9
|
+
const NPM_PATH = /^npm (?:error|ERR!) path (.+)$/m;
|
|
10
|
+
|
|
11
|
+
export interface DirFacts {
|
|
12
|
+
readonly mode: string;
|
|
13
|
+
readonly owner: { readonly uid: number; readonly gid: number };
|
|
14
|
+
readonly writable: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function commandTokens(command: string): string[] {
|
|
18
|
+
const tokens: string[] = [];
|
|
19
|
+
const token = /'([^']*)'|"([^"]*)"|(\S+)/g;
|
|
20
|
+
for (let match = token.exec(command); match !== null; match = token.exec(command)) {
|
|
21
|
+
tokens.push(match[1] ?? match[2] ?? match[3] ?? '');
|
|
22
|
+
}
|
|
23
|
+
return tokens;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function installTargets(command: string): string[] {
|
|
27
|
+
const tokens = commandTokens(command);
|
|
28
|
+
const targets: string[] = [];
|
|
29
|
+
for (let index = 0; index < tokens.length - 1; index += 1) {
|
|
30
|
+
if (TARGET_FLAGS.includes(tokens[index] ?? '')) targets.push(tokens[index + 1] ?? '');
|
|
31
|
+
}
|
|
32
|
+
return targets.filter((target) => target !== '');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function failurePathIn(output: string): string | undefined {
|
|
36
|
+
const path = NPM_PATH.exec(output)?.[1]?.trim();
|
|
37
|
+
return path === undefined || path === '' ? undefined : path;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function nearestDir(path: string): string | undefined {
|
|
41
|
+
let at = path;
|
|
42
|
+
for (;;) {
|
|
43
|
+
const here = (): boolean => {
|
|
44
|
+
try {
|
|
45
|
+
return statSync(at).isDirectory();
|
|
46
|
+
} catch {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
if (here()) return at;
|
|
51
|
+
const up = dirname(at);
|
|
52
|
+
if (up === at) return undefined;
|
|
53
|
+
at = up;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export type WriteState = 'ok' | 'denied' | 'unknown';
|
|
58
|
+
|
|
59
|
+
export function writeStateOf(dir: string): WriteState {
|
|
60
|
+
try {
|
|
61
|
+
accessSync(dir, constants.W_OK);
|
|
62
|
+
return 'ok';
|
|
63
|
+
} catch (error) {
|
|
64
|
+
return (error as { code?: string }).code === 'EACCES' ? 'denied' : 'unknown';
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function dirFacts(dir: string): DirFacts | undefined {
|
|
69
|
+
try {
|
|
70
|
+
const stats = statSync(dir);
|
|
71
|
+
return {
|
|
72
|
+
mode: (stats.mode & 0o777).toString(8),
|
|
73
|
+
owner: { uid: stats.uid, gid: stats.gid },
|
|
74
|
+
writable: writeStateOf(dir) === 'ok',
|
|
75
|
+
};
|
|
76
|
+
} catch {
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function currentUser(): { readonly uid: number; readonly gid: number } {
|
|
82
|
+
return { uid: process.getuid?.() ?? -1, gid: process.getgid?.() ?? -1 };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function classifyInstallFailure(output: string): InstallFailureKind {
|
|
86
|
+
const text = output.toLowerCase();
|
|
87
|
+
if (/enospc|no space left/.test(text)) return 'disk-full';
|
|
88
|
+
if (/eacces|eperm|permission denied|operation not permitted|access is denied/.test(text)) return 'permission';
|
|
89
|
+
return 'unknown';
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface InstallFailureFacts {
|
|
93
|
+
readonly command: string;
|
|
94
|
+
readonly exitCode: number | null;
|
|
95
|
+
readonly output: string;
|
|
96
|
+
readonly kind?: InstallFailureKind | undefined;
|
|
97
|
+
readonly path?: string | undefined;
|
|
98
|
+
readonly mode?: string | undefined;
|
|
99
|
+
readonly owner?: { readonly uid: number; readonly gid: number } | undefined;
|
|
100
|
+
readonly writable?: boolean | undefined;
|
|
101
|
+
readonly current: { readonly uid: number; readonly gid: number };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function installFailureOf(facts: InstallFailureFacts): InstallFailure {
|
|
105
|
+
const kind = facts.kind ?? classifyInstallFailure(facts.output);
|
|
106
|
+
const path = facts.path;
|
|
107
|
+
const base = {
|
|
108
|
+
command: facts.command,
|
|
109
|
+
exitCode: facts.exitCode,
|
|
110
|
+
output: facts.output,
|
|
111
|
+
...(path === undefined ? {} : { path }),
|
|
112
|
+
...(facts.mode === undefined ? {} : { mode: facts.mode }),
|
|
113
|
+
...(facts.owner === undefined ? {} : { owner: facts.owner }),
|
|
114
|
+
current: facts.current,
|
|
115
|
+
};
|
|
116
|
+
if (kind === 'disk-full') {
|
|
117
|
+
return { ...base, kind, fix: path === undefined ? { kind: 'handoff' } : { kind: 'space', path } };
|
|
118
|
+
}
|
|
119
|
+
if (kind === 'permission' && path !== undefined) {
|
|
120
|
+
const owner = facts.owner;
|
|
121
|
+
if (owner !== undefined && owner.uid !== facts.current.uid && facts.current.uid >= 0) {
|
|
122
|
+
return { ...base, kind, fix: { kind: 'chown', path, uid: facts.current.uid, gid: facts.current.gid } };
|
|
123
|
+
}
|
|
124
|
+
if (facts.writable === false) {
|
|
125
|
+
return { ...base, kind, fix: { kind: 'chmod', path } };
|
|
126
|
+
}
|
|
127
|
+
if (facts.writable === true) {
|
|
128
|
+
return { ...base, kind: 'sandbox', fix: { kind: 'handoff' } };
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return { ...base, kind, fix: { kind: 'handoff' } };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function diagnoseInstallFailure(input: {
|
|
135
|
+
readonly command: string;
|
|
136
|
+
readonly exitCode: number | null;
|
|
137
|
+
readonly output: string;
|
|
138
|
+
readonly fallback?: string | undefined;
|
|
139
|
+
}): InstallFailure {
|
|
140
|
+
const current = currentUser();
|
|
141
|
+
const candidates = [
|
|
142
|
+
failurePathIn(input.output),
|
|
143
|
+
...installTargets(input.command),
|
|
144
|
+
input.fallback,
|
|
145
|
+
].filter((path): path is string => path !== undefined && path !== '');
|
|
146
|
+
for (const candidate of candidates) {
|
|
147
|
+
const dir = nearestDir(candidate);
|
|
148
|
+
if (dir === undefined) continue;
|
|
149
|
+
const facts = dirFacts(dir);
|
|
150
|
+
if (facts === undefined) continue;
|
|
151
|
+
return installFailureOf({
|
|
152
|
+
command: input.command,
|
|
153
|
+
exitCode: input.exitCode,
|
|
154
|
+
output: input.output,
|
|
155
|
+
path: dir,
|
|
156
|
+
mode: facts.mode,
|
|
157
|
+
owner: facts.owner,
|
|
158
|
+
writable: facts.writable,
|
|
159
|
+
current,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
return installFailureOf({
|
|
163
|
+
command: input.command,
|
|
164
|
+
exitCode: input.exitCode,
|
|
165
|
+
output: input.output,
|
|
166
|
+
current,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function installWriteProblem(input: {
|
|
171
|
+
readonly command: string;
|
|
172
|
+
readonly fallback?: string | undefined;
|
|
173
|
+
}): InstallFailure | undefined {
|
|
174
|
+
if (!INSTALL_VERBS.test(input.command)) return undefined;
|
|
175
|
+
const current = currentUser();
|
|
176
|
+
const candidates = [...installTargets(input.command), input.fallback].filter(
|
|
177
|
+
(path): path is string => path !== undefined && path !== '',
|
|
178
|
+
);
|
|
179
|
+
for (const candidate of candidates) {
|
|
180
|
+
const dir = nearestDir(candidate);
|
|
181
|
+
if (dir === undefined) continue;
|
|
182
|
+
if (writeStateOf(dir) !== 'denied') continue;
|
|
183
|
+
const facts = dirFacts(dir);
|
|
184
|
+
if (facts === undefined) continue;
|
|
185
|
+
return installFailureOf({
|
|
186
|
+
command: input.command,
|
|
187
|
+
exitCode: null,
|
|
188
|
+
output: '',
|
|
189
|
+
kind: 'permission',
|
|
190
|
+
path: dir,
|
|
191
|
+
mode: facts.mode,
|
|
192
|
+
owner: facts.owner,
|
|
193
|
+
writable: facts.writable,
|
|
194
|
+
current,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
return undefined;
|
|
198
|
+
}
|