@dsh-workbench/shared 1.1.4 → 1.1.6

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 (81) hide show
  1. package/dist/api/host-write.d.ts +4 -1
  2. package/dist/api/workbench-client.d.ts +2 -0
  3. package/dist/components/ApprovalPlan.css +7 -8
  4. package/dist/config/constants.d.ts +4 -0
  5. package/dist/config/constants.js +4 -0
  6. package/dist/contract/approval.d.ts +4 -0
  7. package/dist/contract/approval.js +4 -0
  8. package/dist/contract/command.d.ts +33 -0
  9. package/dist/contract/command.js +1 -1
  10. package/dist/contract/needs.d.ts +17 -0
  11. package/dist/contract/needs.js +41 -1
  12. package/dist/contract/protocol/bundle-install.d.ts +2 -0
  13. package/dist/contract/protocol/market-index.d.ts +3 -1
  14. package/dist/contract/protocol/market-index.js +2 -0
  15. package/dist/contract/unit.d.ts +10 -0
  16. package/dist/host/adapter-types.d.ts +2 -0
  17. package/dist/host/data-error.d.ts +2 -0
  18. package/dist/host/data-error.js +3 -0
  19. package/dist/host/http.js +1 -0
  20. package/dist/host/install-failure.d.ts +49 -0
  21. package/dist/host/install-failure.js +167 -0
  22. package/dist/host/installed-sources.d.ts +10 -0
  23. package/dist/host/installed-sources.js +64 -0
  24. package/dist/host/lib-install.d.ts +12 -1
  25. package/dist/host/lib-install.js +33 -1
  26. package/dist/host/market-index-claim.d.ts +2 -0
  27. package/dist/host/market-index-claim.js +7 -0
  28. package/dist/host/market-index-doc.d.ts +56 -0
  29. package/dist/host/market-index-doc.js +1 -0
  30. package/dist/host/market-index-parse.d.ts +23 -0
  31. package/dist/host/market-index-parse.js +485 -0
  32. package/dist/host/market-index.d.ts +13 -21
  33. package/dist/host/market-index.js +120 -368
  34. package/dist/host/paths.d.ts +7 -0
  35. package/dist/host/paths.js +17 -0
  36. package/dist/host/runtime-links.d.ts +7 -0
  37. package/dist/host/runtime-links.js +25 -0
  38. package/dist/host/tool-approval.d.ts +4 -2
  39. package/dist/host/tool-approval.js +4 -2
  40. package/dist/host.d.ts +3 -0
  41. package/dist/host.js +3 -0
  42. package/dist/i18n/en.js +50 -4
  43. package/dist/i18n/index.d.ts +51 -5
  44. package/dist/i18n/zh.d.ts +50 -4
  45. package/dist/i18n/zh.js +50 -4
  46. package/dist/index.d.ts +1 -0
  47. package/dist/index.js +1 -0
  48. package/package.json +1 -1
  49. package/src/api/host-write.ts +5 -1
  50. package/src/api/workbench-client.ts +2 -0
  51. package/src/components/ApprovalPlan.css +7 -8
  52. package/src/config/constants.ts +6 -0
  53. package/src/contract/__tests__/needs.test.ts +53 -0
  54. package/src/contract/approval.ts +4 -0
  55. package/src/contract/command.ts +22 -0
  56. package/src/contract/needs.ts +56 -1
  57. package/src/contract/protocol/bundle-install.ts +3 -0
  58. package/src/contract/protocol/market-index.ts +4 -0
  59. package/src/contract/unit.ts +11 -0
  60. package/src/host/__tests__/market-index.test.ts +114 -0
  61. package/src/host/adapter-types.ts +2 -0
  62. package/src/host/data-error.ts +3 -1
  63. package/src/host/http.ts +1 -0
  64. package/src/host/install-failure.test.ts +121 -0
  65. package/src/host/install-failure.ts +198 -0
  66. package/src/host/installed-sources.test.ts +80 -0
  67. package/src/host/installed-sources.ts +73 -0
  68. package/src/host/lib-install.ts +38 -2
  69. package/src/host/market-index-claim.ts +9 -0
  70. package/src/host/market-index-doc.ts +62 -0
  71. package/src/host/market-index-parse.ts +482 -0
  72. package/src/host/market-index.ts +162 -357
  73. package/src/host/paths.test.ts +26 -0
  74. package/src/host/paths.ts +18 -0
  75. package/src/host/runtime-links.test.ts +31 -0
  76. package/src/host/runtime-links.ts +23 -0
  77. package/src/host/tool-approval.ts +11 -4
  78. package/src/host.ts +3 -0
  79. package/src/i18n/en.ts +51 -4
  80. package/src/i18n/zh.ts +51 -4
  81. package/src/index.ts +1 -0
@@ -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
+ }
@@ -0,0 +1,80 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { afterEach, describe, expect, it } from 'vitest';
5
+ import {
6
+ forgetPackageSource,
7
+ installedSourcesFile,
8
+ packageSourceOf,
9
+ readInstalledSources,
10
+ rememberPackageSource,
11
+ } from './installed-sources.js';
12
+
13
+ const dirs: string[] = [];
14
+
15
+ function profile(): string {
16
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'wb-sources-'));
17
+ dirs.push(dir);
18
+ return dir;
19
+ }
20
+
21
+ afterEach(() => {
22
+ for (const dir of dirs.splice(0)) fs.rmSync(dir, { recursive: true, force: true });
23
+ });
24
+
25
+ describe('按包记安装来源(检查更新只认这一份)', () => {
26
+ it('写一条再读回来:版本与源都在,文件是临时名换过去的', () => {
27
+ const dir = profile();
28
+ rememberPackageSource(dir, '@a/mod', { version: '0.3.1', registry: 'https://r1.example/' });
29
+
30
+ expect(packageSourceOf(dir, '@a/mod')).toEqual({ version: '0.3.1', registry: 'https://r1.example/' });
31
+ expect(fs.existsSync(`${installedSourcesFile(dir)}.tmp`)).toBe(false);
32
+ });
33
+
34
+ it('★ 同一个 scope 的两个包可以各记各的源(.npmrc 做不到这件事)', () => {
35
+ const dir = profile();
36
+ rememberPackageSource(dir, '@x/a', { version: '1.0.0', registry: 'https://r1.example/' });
37
+ rememberPackageSource(dir, '@x/b', { version: '2.0.0', registry: 'https://r2.example/' });
38
+
39
+ expect(packageSourceOf(dir, '@x/a')?.registry).toBe('https://r1.example/');
40
+ expect(packageSourceOf(dir, '@x/b')?.registry).toBe('https://r2.example/');
41
+ expect(readInstalledSources(dir)).toHaveProperty('@x/a');
42
+ });
43
+
44
+ it('再装一次同一个包 → 覆盖成这次记录的版本与源', () => {
45
+ const dir = profile();
46
+ rememberPackageSource(dir, '@x/a', { version: '1.0.0', registry: 'https://r1.example/' });
47
+ rememberPackageSource(dir, '@x/a', { version: '1.1.0', registry: 'https://r2.example/' });
48
+
49
+ expect(packageSourceOf(dir, '@x/a')).toEqual({ version: '1.1.0', registry: 'https://r2.example/' });
50
+ });
51
+
52
+ it('卸载后删掉;没有这条时也不写空文件', () => {
53
+ const dir = profile();
54
+ rememberPackageSource(dir, '@x/a', { version: '1.0.0', registry: 'https://r1.example/' });
55
+ forgetPackageSource(dir, '@x/a');
56
+
57
+ expect(packageSourceOf(dir, '@x/a')).toBeUndefined();
58
+ const before = fs.readFileSync(installedSourcesFile(dir), 'utf8');
59
+ forgetPackageSource(dir, '@x/never');
60
+ expect(fs.readFileSync(installedSourcesFile(dir), 'utf8')).toBe(before);
61
+ });
62
+
63
+ it('文件坏了 / 没有 / profile 不认识 → 一律当没有记录,不抛', () => {
64
+ const dir = profile();
65
+ fs.writeFileSync(installedSourcesFile(dir), '{ 坏的 json', 'utf8');
66
+ expect(readInstalledSources(dir)).toEqual({});
67
+ expect(packageSourceOf(dir, '@x/a')).toBeUndefined();
68
+ expect(readInstalledSources(null)).toEqual({});
69
+ });
70
+
71
+ it('空包名 / 空源 / 空版本不落盘(别把没问到的写进去当事实)', () => {
72
+ const dir = profile();
73
+ rememberPackageSource(dir, ' ', { version: '1.0.0', registry: 'https://r1.example/' });
74
+ rememberPackageSource(dir, '@x/a', { version: '', registry: 'https://r1.example/' });
75
+ rememberPackageSource(dir, '@x/b', { version: '1.0.0', registry: ' ' });
76
+
77
+ expect(readInstalledSources(dir)).toEqual({});
78
+ expect(fs.existsSync(installedSourcesFile(dir))).toBe(false);
79
+ });
80
+ });
@@ -0,0 +1,73 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ export interface InstalledSource {
5
+ readonly version: string;
6
+ readonly registry: string;
7
+ }
8
+
9
+ export type InstalledSourceMap = Readonly<Record<string, InstalledSource>>;
10
+
11
+ const FILE_NAME = '.dsh-workbench-sources.json';
12
+ const FILE_VERSION = 1;
13
+
14
+ function asRecord(value: unknown): Readonly<Record<string, unknown>> | undefined {
15
+ return typeof value === 'object' && value !== null ? (value as Readonly<Record<string, unknown>>) : undefined;
16
+ }
17
+
18
+ function asText(value: unknown): string {
19
+ return typeof value === 'string' ? value.trim() : '';
20
+ }
21
+
22
+ export function installedSourcesFile(profileDir: string): string {
23
+ return path.join(profileDir, FILE_NAME);
24
+ }
25
+
26
+ export function readInstalledSources(profileDir: string | null): InstalledSourceMap {
27
+ if (profileDir === null) return {};
28
+ let parsed: unknown;
29
+ try {
30
+ parsed = JSON.parse(fs.readFileSync(installedSourcesFile(profileDir), 'utf8'));
31
+ } catch {
32
+ return {};
33
+ }
34
+ const packages = asRecord(asRecord(parsed)?.packages);
35
+ if (packages === undefined) return {};
36
+ const found: Record<string, InstalledSource> = {};
37
+ for (const [pkg, value] of Object.entries(packages)) {
38
+ const record = asRecord(value);
39
+ if (record === undefined) continue;
40
+ const version = asText(record.version);
41
+ const registry = asText(record.registry);
42
+ if (pkg === '' || version === '' || registry === '') continue;
43
+ found[pkg] = { version, registry };
44
+ }
45
+ return found;
46
+ }
47
+
48
+ export function packageSourceOf(profileDir: string | null, pkg: string): InstalledSource | undefined {
49
+ return readInstalledSources(profileDir)[pkg];
50
+ }
51
+
52
+ function writeInstalledSources(profileDir: string, sources: InstalledSourceMap): void {
53
+ const file = installedSourcesFile(profileDir);
54
+ const temp = `${file}.tmp`;
55
+ const text = `${JSON.stringify({ version: FILE_VERSION, packages: sources }, null, 2)}\n`;
56
+ fs.writeFileSync(temp, text, { mode: 0o600 });
57
+ fs.renameSync(temp, file);
58
+ }
59
+
60
+ export function rememberPackageSource(profileDir: string, pkg: string, source: InstalledSource): void {
61
+ const trimmed = pkg.trim();
62
+ if (trimmed === '' || source.version.trim() === '' || source.registry.trim() === '') return;
63
+ const all = { ...readInstalledSources(profileDir) };
64
+ all[trimmed] = { version: source.version.trim(), registry: source.registry.trim() };
65
+ writeInstalledSources(profileDir, all);
66
+ }
67
+
68
+ export function forgetPackageSource(profileDir: string, pkg: string): void {
69
+ const all = { ...readInstalledSources(profileDir) };
70
+ if (all[pkg] === undefined) return;
71
+ delete all[pkg];
72
+ writeInstalledSources(profileDir, all);
73
+ }
@@ -1,4 +1,5 @@
1
- import type { LibNeed, SupplySource } from '../contract/needs.js';
1
+ import type { BinNeed, LibNeed, SupplySource } from '../contract/needs.js';
2
+ import type { HostOs } from '../contract/protocol/platform.js';
2
3
  import { npmRegistryOf } from './plugin-source.js';
3
4
  import { quoteShellArg } from './shell-text.js';
4
5
  import { libsPrefix, npmCacheDir } from './paths.js';
@@ -9,10 +10,45 @@ export function libInstallCommand(options: {
9
10
  readonly need: LibNeed;
10
11
  readonly source: NpmSource;
11
12
  readonly version?: string | undefined;
13
+ readonly env?: NodeJS.ProcessEnv | undefined;
12
14
  }): string {
13
15
  const { need, source, version } = options;
14
- const registry = npmRegistryOf(source, process.env);
16
+ const registry = npmRegistryOf(source, options.env ?? process.env);
15
17
  const spec = version ?? need.version ?? 'latest';
16
18
  const registryFlag = registry.ok ? ` --registry ${quoteShellArg(registry.value)}` : '';
17
19
  return `npm i --prefix ${quoteShellArg(libsPrefix())} ${quoteShellArg(`${need.name}@${spec}`)}${registryFlag} --cache ${quoteShellArg(npmCacheDir())}`;
18
20
  }
21
+
22
+ /**
23
+ * 这个 bin 在这台机器上怎么装:作者写了 `install` 且平台对得上才给出那条命令。
24
+ * `command` 原样照抄;`npm` 装进隔离运行时的共用依赖前缀(那份包里的 bin 会自动上 PATH)。
25
+ * @param need 一条 bin 需要。
26
+ * @param os 宿主平台(由平台适配器给出)。
27
+ * @param env 取 registry 环境变量的地方。
28
+ * @returns 一条能直接粘进终端的命令;没声明或平台不对就是 undefined。
29
+ */
30
+ export function binInstallCommand(
31
+ need: BinNeed,
32
+ os: HostOs,
33
+ env: NodeJS.ProcessEnv = process.env,
34
+ ): string | undefined {
35
+ const install = need.install;
36
+ if (install === undefined) return undefined;
37
+ if (install.platforms !== undefined && !install.platforms.includes(os)) return undefined;
38
+ if (install.kind === 'command') {
39
+ const line = install.line.trim();
40
+ return line === '' ? undefined : line;
41
+ }
42
+ const packageName = install.package.trim();
43
+ if (packageName === '') return undefined;
44
+ return libInstallCommand({
45
+ need: { kind: 'lib', name: packageName, purpose: need.purpose },
46
+ source: {
47
+ kind: 'npm',
48
+ package: packageName,
49
+ ...(install.registryEnv === undefined ? {} : { registryEnv: install.registryEnv }),
50
+ ...(install.registryDefault === undefined ? {} : { registryDefault: install.registryDefault }),
51
+ },
52
+ env,
53
+ });
54
+ }
@@ -1,4 +1,5 @@
1
1
  import type { MarketClaim, MarketIndex, MarketIndexEntry } from '../contract/protocol/market-index.js';
2
+ import { MARKET_ARTIFACTS_DIR } from '../contract/protocol/market-index.js';
2
3
 
3
4
  export interface PublisherIdentity {
4
5
  readonly name: string;
@@ -53,6 +54,14 @@ export function marketArtifactPath(name: string): string {
53
54
  return `packages/${name}`;
54
55
  }
55
56
 
57
+ export function marketArtifactRoot(name: string): string {
58
+ return `${MARKET_ARTIFACTS_DIR}/${name}`;
59
+ }
60
+
61
+ export function marketArtifactPathFor(name: string, version: string): string {
62
+ return `${MARKET_ARTIFACTS_DIR}/${name}/${version}`;
63
+ }
64
+
56
65
  export function entryUnitOf(entry: MarketIndexEntry): string | undefined {
57
66
  const unit = entry.metadata?.unit?.trim();
58
67
  if (unit !== undefined && unit !== '') return unit;
@@ -0,0 +1,62 @@
1
+ import type {
2
+ MarketIndex,
3
+ MarketIndexEngines,
4
+ MarketIndexEntryMetadata,
5
+ MarketIndexProblem,
6
+ MarketIndexSource,
7
+ MarketLocalizedText,
8
+ } from '../contract/protocol/market-index.js';
9
+
10
+ export interface MarketVersionDoc {
11
+ readonly name: string;
12
+ readonly version: string;
13
+ readonly source: MarketIndexSource;
14
+ readonly publishTimestamp?: number;
15
+ readonly engines?: MarketIndexEngines;
16
+ readonly title?: MarketLocalizedText;
17
+ readonly description?: MarketLocalizedText;
18
+ readonly author?: string;
19
+ readonly owner?: string;
20
+ readonly homepage?: string;
21
+ readonly icon?: string;
22
+ readonly metadata?: MarketIndexEntryMetadata;
23
+ }
24
+
25
+ export interface MarketMetaDoc {
26
+ readonly name: string;
27
+ readonly epoch?: number;
28
+ readonly publishTimestamp: number;
29
+ readonly title?: MarketLocalizedText;
30
+ readonly description?: MarketLocalizedText;
31
+ readonly author?: string;
32
+ readonly owner?: string;
33
+ readonly homepage?: string;
34
+ readonly icon?: string;
35
+ readonly metadata?: MarketIndexEntryMetadata;
36
+ }
37
+
38
+ export type MarketEntryDoc = MarketVersionDoc | MarketMetaDoc;
39
+
40
+ export type MarketEntryDocParse =
41
+ | { readonly ok: true; readonly doc: MarketEntryDoc }
42
+ | { readonly ok: false; readonly problems: readonly MarketIndexProblem[] };
43
+
44
+ export interface MarketEntryFile {
45
+ readonly path: string;
46
+ readonly text: string;
47
+ }
48
+
49
+ export type MarketIndexAssemble =
50
+ | { readonly ok: true; readonly index: MarketIndex }
51
+ | { readonly ok: false; readonly problems: readonly MarketIndexProblem[] };
52
+
53
+ export interface MarketDocFields {
54
+ readonly name: string;
55
+ readonly title?: MarketLocalizedText;
56
+ readonly description?: MarketLocalizedText;
57
+ readonly author?: string;
58
+ readonly owner?: string;
59
+ readonly homepage?: string;
60
+ readonly icon?: string;
61
+ readonly metadata?: MarketIndexEntryMetadata;
62
+ }