@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
@@ -1,9 +1,11 @@
1
+ import type { InstallFailure } from '../contract/command.js';
1
2
  /**
2
3
  * 宿主写口的五条通道(偏好、动作、供给、工坊调试、工坊录制),是写路由的第一段。
3
4
  */
4
5
  export type WriteChannel = 'prefs' | 'actions' | 'provision' | 'diy-debug' | 'diy-record';
5
6
  /**
6
- * 写通道统一的结果:成了带值,没成带一句能给人看的话(可选明细与帮助链接)。
7
+ * 写通道统一的结果:成了带值,没成带一句能给人看的话(可选明细与帮助链接);
8
+ * 安装类的失败多带一份结构化现场,界面据此给修法。
7
9
  */
8
10
  export type WriteOutcome<T> = {
9
11
  readonly ok: true;
@@ -13,4 +15,5 @@ export type WriteOutcome<T> = {
13
15
  readonly message: string;
14
16
  readonly detail?: string;
15
17
  readonly helpUrl?: string;
18
+ readonly failure?: InstallFailure;
16
19
  };
@@ -1,3 +1,4 @@
1
+ import type { InstallFailure } from '../contract/command.js';
1
2
  /**
2
3
  * 宿主读口 / 写口回的错误:code 给判据分档,message 给人看,helpUrl 有就去点。
3
4
  */
@@ -7,6 +8,7 @@ export interface ApiError {
7
8
  readonly detail?: string;
8
9
  readonly hint?: string;
9
10
  readonly helpUrl?: string;
11
+ readonly failure?: InstallFailure;
10
12
  }
11
13
  /**
12
14
  * 读口的统一响应:成了把载荷摊平在顶层,没成只有一条 ApiError。
@@ -10,22 +10,21 @@
10
10
  color: var(--dsw-alias-label-secondary);
11
11
  }
12
12
  .wb-approvalRows {
13
- display: flex;
14
- flex-direction: column;
15
- gap: var(--wb-len-2);
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: flex;
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;
@@ -74,6 +74,10 @@ export declare const STORAGE_KEY_RIGHTBAR = "dsh-workbench.rightbar.expanded";
74
74
  export declare const STORAGE_KEY_RIGHTBAR_COLUMN = "dsh-workbench.rightbar.column";
75
75
  /** 记住右栏宽度(像素数字的字符串)的 localStorage 键。 */
76
76
  export declare const STORAGE_KEY_RIGHTBAR_WIDTH = "dsh-workbench.rightbar.width";
77
+ /** 记住「添加模块」浮层选过的安装源的 localStorage 键。 */
78
+ export declare const STORAGE_KEY_MARKET_REGISTRY = "dsh-workbench.market.registry";
79
+ /** 记住「添加模块」浮层上次填过的自定义源地址的 localStorage 键。 */
80
+ export declare const STORAGE_KEY_MARKET_REGISTRY_CUSTOM = "dsh-workbench.market.registry.custom";
77
81
  /** 右栏宽度达到或超过多少像素就按全屏对待(阈值,像素)。 */
78
82
  export declare const RIGHTBAR_FULLSCREEN_WIDTH = 768;
79
83
  /** dsh i18n 的命名空间:词表都注册在它下面,`ctx.locale.register` / `bind` 按它取。 */
@@ -74,6 +74,10 @@ export const STORAGE_KEY_RIGHTBAR = "dsh-workbench.rightbar.expanded";
74
74
  export const STORAGE_KEY_RIGHTBAR_COLUMN = "dsh-workbench.rightbar.column";
75
75
  /** 记住右栏宽度(像素数字的字符串)的 localStorage 键。 */
76
76
  export const STORAGE_KEY_RIGHTBAR_WIDTH = "dsh-workbench.rightbar.width";
77
+ /** 记住「添加模块」浮层选过的安装源的 localStorage 键。 */
78
+ export const STORAGE_KEY_MARKET_REGISTRY = "dsh-workbench.market.registry";
79
+ /** 记住「添加模块」浮层上次填过的自定义源地址的 localStorage 键。 */
80
+ export const STORAGE_KEY_MARKET_REGISTRY_CUSTOM = "dsh-workbench.market.registry.custom";
77
81
  /** 右栏宽度达到或超过多少像素就按全屏对待(阈值,像素)。 */
78
82
  export const RIGHTBAR_FULLSCREEN_WIDTH = 768;
79
83
  /** dsh i18n 的命名空间:词表都注册在它下面,`ctx.locale.register` / `bind` 按它取。 */
@@ -0,0 +1,4 @@
1
+ /**
2
+ * agent 自己那句"这次想干什么"的参数名:插件命令族必填,审批屏把它画在详情座里,不进标题。
3
+ */
4
+ export declare const APPROVAL_NOTE_ARG = "approval_note";
@@ -0,0 +1,4 @@
1
+ /**
2
+ * agent 自己那句"这次想干什么"的参数名:插件命令族必填,审批屏把它画在详情座里,不进标题。
3
+ */
4
+ export const APPROVAL_NOTE_ARG = 'approval_note';
@@ -20,3 +20,36 @@ export interface CommandResult {
20
20
  export interface CommandReader {
21
21
  run(request: CommandRequest): Promise<CommandResult>;
22
22
  }
23
+ export declare const INSTALL_FAILURE_KINDS: readonly ["permission", "disk-full", "sandbox", "unknown"];
24
+ export type InstallFailureKind = (typeof INSTALL_FAILURE_KINDS)[number];
25
+ export type InstallFix = {
26
+ readonly kind: 'chown';
27
+ readonly path: string;
28
+ readonly uid: number;
29
+ readonly gid: number;
30
+ } | {
31
+ readonly kind: 'chmod';
32
+ readonly path: string;
33
+ } | {
34
+ readonly kind: 'space';
35
+ readonly path: string;
36
+ } | {
37
+ readonly kind: 'handoff';
38
+ };
39
+ export interface InstallFailure {
40
+ readonly kind: InstallFailureKind;
41
+ readonly command: string;
42
+ readonly exitCode: number | null;
43
+ readonly output: string;
44
+ readonly path?: string | undefined;
45
+ readonly mode?: string | undefined;
46
+ readonly owner?: {
47
+ readonly uid: number;
48
+ readonly gid: number;
49
+ } | undefined;
50
+ readonly current: {
51
+ readonly uid: number;
52
+ readonly gid: number;
53
+ };
54
+ readonly fix: InstallFix;
55
+ }
@@ -1 +1 @@
1
- export {};
1
+ export const INSTALL_FAILURE_KINDS = ['permission', 'disk-full', 'sandbox', 'unknown'];
@@ -40,12 +40,28 @@ export interface NeedCommon {
40
40
  readonly required?: boolean;
41
41
  readonly id?: string;
42
42
  }
43
+ /**
44
+ * 一条装这个可执行文件的命令;模块作者写,工作台照抄执行。
45
+ */
46
+ export type BinInstall = {
47
+ readonly kind: 'command';
48
+ readonly line: string;
49
+ readonly platforms?: readonly string[] | undefined;
50
+ } | {
51
+ readonly kind: 'npm';
52
+ readonly package: string;
53
+ readonly registryEnv?: string | undefined;
54
+ readonly registryDefault?: string | undefined;
55
+ readonly platforms?: readonly string[] | undefined;
56
+ };
43
57
  /**
44
58
  * 机器上要有一个可执行文件;探法是 `command -v` / `where`。
45
59
  */
46
60
  export interface BinNeed extends NeedCommon {
47
61
  readonly kind: 'bin';
48
62
  readonly name: string;
63
+ readonly install?: BinInstall | undefined;
64
+ readonly docs?: string | undefined;
49
65
  }
50
66
  /**
51
67
  * 某个运行时上要有一个扩展(opencli 的插件就是这种),并且要提供这些命令。
@@ -111,6 +127,7 @@ export declare function isKnownNeedKind(value: unknown): value is NeedKind;
111
127
  * @returns 标识串。
112
128
  */
113
129
  export declare function needIdOf(need: Need): string;
130
+ export declare function binInstallProblemsOf(raw: unknown, at: string): readonly string[];
114
131
  /**
115
132
  * 一张表说了算:每种 kind 的探法、可用来源与校验。
116
133
  */
@@ -20,6 +20,8 @@ export const NEED_KEYS = [
20
20
  'version',
21
21
  'sources',
22
22
  'secret',
23
+ 'install',
24
+ 'docs',
23
25
  ];
24
26
  const SOURCE_KEYS = ['kind', 'package', 'registryEnv', 'registryDefault', 'url', 'ref', 'path', 'env'];
25
27
  const REGISTRY_PATTERN = /^(?:https?|file):\/\//;
@@ -87,7 +89,7 @@ function sourcesProblemsOf(raw, at, allowed) {
87
89
  if (raw.length === 0)
88
90
  return [`${at}.sources 是空数组 —— 不代装就整条删掉`];
89
91
  if (allowed.length === 0) {
90
- return [`${at} 这种需要不支持 sources —— 它没有「从哪装」这回事`];
92
+ return [`${at} 这种需要不支持 sources —— 要代装就写 install(一条命令)`];
91
93
  }
92
94
  const errors = [];
93
95
  raw.forEach((source, index) => {
@@ -113,12 +115,50 @@ function commonProblemsOf(raw, at) {
113
115
  errors.push(`${at}.id 是空串`);
114
116
  return errors;
115
117
  }
118
+ export function binInstallProblemsOf(raw, at) {
119
+ if (raw === undefined)
120
+ return [];
121
+ if (!isRecord(raw))
122
+ return [`${at}.install 要是对象`];
123
+ const errors = [];
124
+ for (const key of Object.keys(raw)) {
125
+ if (!['kind', 'line', 'platforms', 'package', 'registryEnv', 'registryDefault'].includes(key)) {
126
+ errors.push(`${at}.install 有不认识的字段 ${key}`);
127
+ }
128
+ }
129
+ const kind = text(raw['kind']);
130
+ if (kind !== 'command' && kind !== 'npm') {
131
+ errors.push(`${at}.install.kind 只认 command(一条命令)或 npm(一个包,装进隔离运行时)`);
132
+ }
133
+ if (kind === 'command' && text(raw['line']) === '') {
134
+ errors.push(`${at}.install.line 没写 —— 要一条能直接粘进终端的命令`);
135
+ }
136
+ if (kind === 'npm' && text(raw['package']) === '') {
137
+ errors.push(`${at}.install.package 没写 —— 这个命令由哪个 npm 包提供`);
138
+ }
139
+ if (raw['registryDefault'] !== undefined && !REGISTRY_PATTERN.test(text(raw['registryDefault']))) {
140
+ errors.push(`${at}.install.registryDefault 要是 http(s):// 或 file:// 的源地址`);
141
+ }
142
+ if (raw['platforms'] !== undefined) {
143
+ if (!Array.isArray(raw['platforms']) || raw['platforms'].length === 0) {
144
+ errors.push(`${at}.install.platforms 要是非空数组`);
145
+ }
146
+ else if (raw['platforms'].some((one) => !['darwin', 'linux', 'win32'].includes(text(one)))) {
147
+ errors.push(`${at}.install.platforms 只认 darwin / linux / win32`);
148
+ }
149
+ }
150
+ return errors;
151
+ }
116
152
  function perKindProblemsOf(kind, raw, at) {
117
153
  const errors = [];
118
154
  if (kind === 'bin' || kind === 'lib' || kind === 'env') {
119
155
  if (text(raw['name']) === '')
120
156
  errors.push(`${at}.name 没写`);
121
157
  }
158
+ if (kind === 'bin')
159
+ errors.push(...binInstallProblemsOf(raw['install'], at));
160
+ if (raw['docs'] !== undefined && text(raw['docs']) === '')
161
+ errors.push(`${at}.docs 是空串`);
122
162
  if (kind === 'extension') {
123
163
  if (text(raw['runtime']) === '')
124
164
  errors.push(`${at}.runtime 没写 —— 扩展属于哪个运行时(如 opencli)`);
@@ -117,6 +117,7 @@ export interface BundleCancellationPayload {
117
117
  readonly cancellation: BundleCancellation | null;
118
118
  }
119
119
  export type ModuleUpdateBlock = 'needs-workbench' | 'incompatible';
120
+ export type LatestUnavailable = 'local' | 'no-registry' | 'not-registry' | 'probe-failed';
120
121
  export interface ModuleVersion {
121
122
  readonly unit: string;
122
123
  readonly pkg: string;
@@ -124,6 +125,7 @@ export interface ModuleVersion {
124
125
  readonly title?: UnitTitle;
125
126
  readonly latest?: string;
126
127
  readonly latestBlocked?: ModuleUpdateBlock;
128
+ readonly latestUnavailable?: LatestUnavailable;
127
129
  readonly local?: boolean;
128
130
  readonly refused?: readonly string[];
129
131
  readonly market?: MarketInstalledModule;
@@ -1,4 +1,6 @@
1
1
  export declare const MARKET_INDEX_FORMAT = 1;
2
+ export declare const MARKET_INDEX_DIR = "market";
3
+ export declare const MARKET_ARTIFACTS_DIR = "artifacts";
2
4
  export declare const MARKET_INDEX_PATH = "market.json";
3
5
  export declare const MARKET_DISCOVER_ROUTE = "panels/market-discover";
4
6
  export declare const MARKET_DOC_ROUTE = "panels/market-doc";
@@ -111,7 +113,7 @@ export interface MarketIndex {
111
113
  readonly metadata?: MarketIndexMetadata;
112
114
  readonly plugins: readonly MarketIndexEntry[];
113
115
  }
114
- export type MarketIndexProblemKind = 'not-json' | 'not-object' | 'format' | 'plugins' | 'plugin' | 'name' | 'duplicate-name' | 'versions' | 'version' | 'version-order' | 'source' | 'git-commit' | 'timestamp' | 'icon' | 'owner' | 'unit';
116
+ export type MarketIndexProblemKind = 'not-json' | 'not-object' | 'format' | 'plugins' | 'plugin' | 'entry' | 'name' | 'duplicate-name' | 'versions' | 'version' | 'version-order' | 'duplicate-version' | 'source' | 'git-commit' | 'timestamp' | 'icon' | 'owner' | 'unit';
115
117
  export interface MarketIndexProblem {
116
118
  readonly kind: MarketIndexProblemKind;
117
119
  readonly detail: string;
@@ -1,4 +1,6 @@
1
1
  export const MARKET_INDEX_FORMAT = 1;
2
+ export const MARKET_INDEX_DIR = 'market';
3
+ export const MARKET_ARTIFACTS_DIR = 'artifacts';
2
4
  export const MARKET_INDEX_PATH = 'market.json';
3
5
  export const MARKET_DISCOVER_ROUTE = 'panels/market-discover';
4
6
  export const MARKET_DOC_ROUTE = 'panels/market-doc';
@@ -18,12 +18,22 @@ export declare function unitTitleKey(unit: string): string;
18
18
  export interface UnitCard {
19
19
  readonly order: number;
20
20
  }
21
+ /**
22
+ * 合成工具背后那条 opencli 命令:插件名与命令名都是 opencli 那边的原名,`args` 是命令自己声明的参数名(按声明顺序)。
23
+ */
24
+ export interface UnitToolOpencli {
25
+ readonly plugin: string;
26
+ readonly command: string;
27
+ readonly args: readonly string[];
28
+ }
21
29
  /**
22
30
  * 一条工具在清单里的样子:全名加会不会写;审批按 writes 认领。
31
+ * `opencli` 只有「按已装插件合成」的单元写,手写模块的工具没有这一格。
23
32
  */
24
33
  export interface UnitTool {
25
34
  readonly name: string;
26
35
  readonly writes: boolean;
36
+ readonly opencli?: UnitToolOpencli;
27
37
  }
28
38
  /**
29
39
  * 模块要外壳的哪些服务:inject 缺一个就挂不上,optional 缺了照挂。
@@ -19,6 +19,7 @@ export interface ProfileModule {
19
19
  readonly version: string;
20
20
  readonly registry?: string;
21
21
  readonly local?: boolean;
22
+ readonly fromRegistry?: boolean;
22
23
  }
23
24
  export interface ProfilePackage {
24
25
  readonly pkg: string;
@@ -41,6 +42,7 @@ export interface RegistryEntry {
41
42
  export interface RegistryNotebook {
42
43
  sources(): readonly RegistryEntry[];
43
44
  remember(entries: readonly RegistryEntry[]): readonly string[];
45
+ swapScope?(scope: string, registry: string): () => void;
44
46
  }
45
47
  export interface SelfUpdatePaths {
46
48
  readonly dir: string;
@@ -4,10 +4,12 @@ export declare 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
  constructor(code: string, message: string, extra?: {
8
9
  detail?: string;
9
10
  hint?: string;
10
11
  project?: unknown;
11
12
  helpUrl?: string;
13
+ failure?: unknown;
12
14
  });
13
15
  }
@@ -4,6 +4,7 @@ export class WorkbenchDataError extends Error {
4
4
  hint;
5
5
  project;
6
6
  helpUrl;
7
+ failure;
7
8
  constructor(code, message, extra = {}) {
8
9
  super(message);
9
10
  this.name = 'WorkbenchDataError';
@@ -16,5 +17,7 @@ export class WorkbenchDataError extends Error {
16
17
  this.project = extra.project;
17
18
  if (extra.helpUrl !== undefined)
18
19
  this.helpUrl = extra.helpUrl;
20
+ if (extra.failure !== undefined)
21
+ this.failure = extra.failure;
19
22
  }
20
23
  }
package/dist/host/http.js CHANGED
@@ -33,6 +33,7 @@ function toErrorPayload(error) {
33
33
  ...(data.hint === undefined ? {} : { hint: data.hint }),
34
34
  ...(data.helpUrl === undefined ? {} : { helpUrl: data.helpUrl }),
35
35
  ...(data.project === undefined ? {} : { project: data.project }),
36
+ ...(data.failure === undefined ? {} : { failure: data.failure }),
36
37
  };
37
38
  }
38
39
  return { code: 'internal', message: error instanceof Error ? error.message : String(error) };
@@ -0,0 +1,49 @@
1
+ import type { InstallFailure, InstallFailureKind } from '../contract/command.js';
2
+ export interface DirFacts {
3
+ readonly mode: string;
4
+ readonly owner: {
5
+ readonly uid: number;
6
+ readonly gid: number;
7
+ };
8
+ readonly writable: boolean;
9
+ }
10
+ export declare function commandTokens(command: string): string[];
11
+ export declare function installTargets(command: string): string[];
12
+ export declare function failurePathIn(output: string): string | undefined;
13
+ export declare function nearestDir(path: string): string | undefined;
14
+ export type WriteState = 'ok' | 'denied' | 'unknown';
15
+ export declare function writeStateOf(dir: string): WriteState;
16
+ export declare function dirFacts(dir: string): DirFacts | undefined;
17
+ export declare function currentUser(): {
18
+ readonly uid: number;
19
+ readonly gid: number;
20
+ };
21
+ export declare function classifyInstallFailure(output: string): InstallFailureKind;
22
+ export interface InstallFailureFacts {
23
+ readonly command: string;
24
+ readonly exitCode: number | null;
25
+ readonly output: string;
26
+ readonly kind?: InstallFailureKind | undefined;
27
+ readonly path?: string | undefined;
28
+ readonly mode?: string | undefined;
29
+ readonly owner?: {
30
+ readonly uid: number;
31
+ readonly gid: number;
32
+ } | undefined;
33
+ readonly writable?: boolean | undefined;
34
+ readonly current: {
35
+ readonly uid: number;
36
+ readonly gid: number;
37
+ };
38
+ }
39
+ export declare function installFailureOf(facts: InstallFailureFacts): InstallFailure;
40
+ export declare function diagnoseInstallFailure(input: {
41
+ readonly command: string;
42
+ readonly exitCode: number | null;
43
+ readonly output: string;
44
+ readonly fallback?: string | undefined;
45
+ }): InstallFailure;
46
+ export declare function installWriteProblem(input: {
47
+ readonly command: string;
48
+ readonly fallback?: string | undefined;
49
+ }): InstallFailure | undefined;
@@ -0,0 +1,167 @@
1
+ import { accessSync, constants, statSync } from 'node:fs';
2
+ import { dirname } from 'node:path';
3
+ const TARGET_FLAGS = ['--prefix', '--cache', '--dir', '--store-dir'];
4
+ const INSTALL_VERBS = /^\s*npm\s+(i|install|add)\b/;
5
+ const NPM_PATH = /^npm (?:error|ERR!) path (.+)$/m;
6
+ export function commandTokens(command) {
7
+ const tokens = [];
8
+ const token = /'([^']*)'|"([^"]*)"|(\S+)/g;
9
+ for (let match = token.exec(command); match !== null; match = token.exec(command)) {
10
+ tokens.push(match[1] ?? match[2] ?? match[3] ?? '');
11
+ }
12
+ return tokens;
13
+ }
14
+ export function installTargets(command) {
15
+ const tokens = commandTokens(command);
16
+ const targets = [];
17
+ for (let index = 0; index < tokens.length - 1; index += 1) {
18
+ if (TARGET_FLAGS.includes(tokens[index] ?? ''))
19
+ targets.push(tokens[index + 1] ?? '');
20
+ }
21
+ return targets.filter((target) => target !== '');
22
+ }
23
+ export function failurePathIn(output) {
24
+ const path = NPM_PATH.exec(output)?.[1]?.trim();
25
+ return path === undefined || path === '' ? undefined : path;
26
+ }
27
+ export function nearestDir(path) {
28
+ let at = path;
29
+ for (;;) {
30
+ const here = () => {
31
+ try {
32
+ return statSync(at).isDirectory();
33
+ }
34
+ catch {
35
+ return false;
36
+ }
37
+ };
38
+ if (here())
39
+ return at;
40
+ const up = dirname(at);
41
+ if (up === at)
42
+ return undefined;
43
+ at = up;
44
+ }
45
+ }
46
+ export function writeStateOf(dir) {
47
+ try {
48
+ accessSync(dir, constants.W_OK);
49
+ return 'ok';
50
+ }
51
+ catch (error) {
52
+ return error.code === 'EACCES' ? 'denied' : 'unknown';
53
+ }
54
+ }
55
+ export function dirFacts(dir) {
56
+ try {
57
+ const stats = statSync(dir);
58
+ return {
59
+ mode: (stats.mode & 0o777).toString(8),
60
+ owner: { uid: stats.uid, gid: stats.gid },
61
+ writable: writeStateOf(dir) === 'ok',
62
+ };
63
+ }
64
+ catch {
65
+ return undefined;
66
+ }
67
+ }
68
+ export function currentUser() {
69
+ return { uid: process.getuid?.() ?? -1, gid: process.getgid?.() ?? -1 };
70
+ }
71
+ export function classifyInstallFailure(output) {
72
+ const text = output.toLowerCase();
73
+ if (/enospc|no space left/.test(text))
74
+ return 'disk-full';
75
+ if (/eacces|eperm|permission denied|operation not permitted|access is denied/.test(text))
76
+ return 'permission';
77
+ return 'unknown';
78
+ }
79
+ export function installFailureOf(facts) {
80
+ const kind = facts.kind ?? classifyInstallFailure(facts.output);
81
+ const path = facts.path;
82
+ const base = {
83
+ command: facts.command,
84
+ exitCode: facts.exitCode,
85
+ output: facts.output,
86
+ ...(path === undefined ? {} : { path }),
87
+ ...(facts.mode === undefined ? {} : { mode: facts.mode }),
88
+ ...(facts.owner === undefined ? {} : { owner: facts.owner }),
89
+ current: facts.current,
90
+ };
91
+ if (kind === 'disk-full') {
92
+ return { ...base, kind, fix: path === undefined ? { kind: 'handoff' } : { kind: 'space', path } };
93
+ }
94
+ if (kind === 'permission' && path !== undefined) {
95
+ const owner = facts.owner;
96
+ if (owner !== undefined && owner.uid !== facts.current.uid && facts.current.uid >= 0) {
97
+ return { ...base, kind, fix: { kind: 'chown', path, uid: facts.current.uid, gid: facts.current.gid } };
98
+ }
99
+ if (facts.writable === false) {
100
+ return { ...base, kind, fix: { kind: 'chmod', path } };
101
+ }
102
+ if (facts.writable === true) {
103
+ return { ...base, kind: 'sandbox', fix: { kind: 'handoff' } };
104
+ }
105
+ }
106
+ return { ...base, kind, fix: { kind: 'handoff' } };
107
+ }
108
+ export function diagnoseInstallFailure(input) {
109
+ const current = currentUser();
110
+ const candidates = [
111
+ failurePathIn(input.output),
112
+ ...installTargets(input.command),
113
+ input.fallback,
114
+ ].filter((path) => path !== undefined && path !== '');
115
+ for (const candidate of candidates) {
116
+ const dir = nearestDir(candidate);
117
+ if (dir === undefined)
118
+ continue;
119
+ const facts = dirFacts(dir);
120
+ if (facts === undefined)
121
+ continue;
122
+ return installFailureOf({
123
+ command: input.command,
124
+ exitCode: input.exitCode,
125
+ output: input.output,
126
+ path: dir,
127
+ mode: facts.mode,
128
+ owner: facts.owner,
129
+ writable: facts.writable,
130
+ current,
131
+ });
132
+ }
133
+ return installFailureOf({
134
+ command: input.command,
135
+ exitCode: input.exitCode,
136
+ output: input.output,
137
+ current,
138
+ });
139
+ }
140
+ export function installWriteProblem(input) {
141
+ if (!INSTALL_VERBS.test(input.command))
142
+ return undefined;
143
+ const current = currentUser();
144
+ const candidates = [...installTargets(input.command), input.fallback].filter((path) => path !== undefined && path !== '');
145
+ for (const candidate of candidates) {
146
+ const dir = nearestDir(candidate);
147
+ if (dir === undefined)
148
+ continue;
149
+ if (writeStateOf(dir) !== 'denied')
150
+ continue;
151
+ const facts = dirFacts(dir);
152
+ if (facts === undefined)
153
+ continue;
154
+ return installFailureOf({
155
+ command: input.command,
156
+ exitCode: null,
157
+ output: '',
158
+ kind: 'permission',
159
+ path: dir,
160
+ mode: facts.mode,
161
+ owner: facts.owner,
162
+ writable: facts.writable,
163
+ current,
164
+ });
165
+ }
166
+ return undefined;
167
+ }
@@ -0,0 +1,10 @@
1
+ export interface InstalledSource {
2
+ readonly version: string;
3
+ readonly registry: string;
4
+ }
5
+ export type InstalledSourceMap = Readonly<Record<string, InstalledSource>>;
6
+ export declare function installedSourcesFile(profileDir: string): string;
7
+ export declare function readInstalledSources(profileDir: string | null): InstalledSourceMap;
8
+ export declare function packageSourceOf(profileDir: string | null, pkg: string): InstalledSource | undefined;
9
+ export declare function rememberPackageSource(profileDir: string, pkg: string, source: InstalledSource): void;
10
+ export declare function forgetPackageSource(profileDir: string, pkg: string): void;
@@ -0,0 +1,64 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ const FILE_NAME = '.dsh-workbench-sources.json';
4
+ const FILE_VERSION = 1;
5
+ function asRecord(value) {
6
+ return typeof value === 'object' && value !== null ? value : undefined;
7
+ }
8
+ function asText(value) {
9
+ return typeof value === 'string' ? value.trim() : '';
10
+ }
11
+ export function installedSourcesFile(profileDir) {
12
+ return path.join(profileDir, FILE_NAME);
13
+ }
14
+ export function readInstalledSources(profileDir) {
15
+ if (profileDir === null)
16
+ return {};
17
+ let parsed;
18
+ try {
19
+ parsed = JSON.parse(fs.readFileSync(installedSourcesFile(profileDir), 'utf8'));
20
+ }
21
+ catch {
22
+ return {};
23
+ }
24
+ const packages = asRecord(asRecord(parsed)?.packages);
25
+ if (packages === undefined)
26
+ return {};
27
+ const found = {};
28
+ for (const [pkg, value] of Object.entries(packages)) {
29
+ const record = asRecord(value);
30
+ if (record === undefined)
31
+ continue;
32
+ const version = asText(record.version);
33
+ const registry = asText(record.registry);
34
+ if (pkg === '' || version === '' || registry === '')
35
+ continue;
36
+ found[pkg] = { version, registry };
37
+ }
38
+ return found;
39
+ }
40
+ export function packageSourceOf(profileDir, pkg) {
41
+ return readInstalledSources(profileDir)[pkg];
42
+ }
43
+ function writeInstalledSources(profileDir, sources) {
44
+ const file = installedSourcesFile(profileDir);
45
+ const temp = `${file}.tmp`;
46
+ const text = `${JSON.stringify({ version: FILE_VERSION, packages: sources }, null, 2)}\n`;
47
+ fs.writeFileSync(temp, text, { mode: 0o600 });
48
+ fs.renameSync(temp, file);
49
+ }
50
+ export function rememberPackageSource(profileDir, pkg, source) {
51
+ const trimmed = pkg.trim();
52
+ if (trimmed === '' || source.version.trim() === '' || source.registry.trim() === '')
53
+ return;
54
+ const all = { ...readInstalledSources(profileDir) };
55
+ all[trimmed] = { version: source.version.trim(), registry: source.registry.trim() };
56
+ writeInstalledSources(profileDir, all);
57
+ }
58
+ export function forgetPackageSource(profileDir, pkg) {
59
+ const all = { ...readInstalledSources(profileDir) };
60
+ if (all[pkg] === undefined)
61
+ return;
62
+ delete all[pkg];
63
+ writeInstalledSources(profileDir, all);
64
+ }