@dsh-workbench/shared 1.1.8 → 1.1.9

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 (224) hide show
  1. package/dist/api/blocker-table.d.ts +1 -0
  2. package/dist/api/blocker-table.js +1 -1
  3. package/dist/components/SelectField.css +0 -6
  4. package/dist/components/SelectField.d.ts +2 -3
  5. package/dist/components/SelectField.js +9 -30
  6. package/dist/components/SelectFieldNodes.d.ts +3 -1
  7. package/dist/components/SelectFieldNodes.js +2 -1
  8. package/dist/components/SelectFieldWheel.d.ts +9 -0
  9. package/dist/components/SelectFieldWheel.js +35 -0
  10. package/dist/config/constants.d.ts +4 -4
  11. package/dist/config/constants.js +4 -4
  12. package/dist/contract/capabilities.d.ts +29 -0
  13. package/dist/contract/capabilities.js +1 -0
  14. package/dist/contract/needs.d.ts +3 -3
  15. package/dist/contract/needs.js +1 -1
  16. package/dist/contract/plugin-source.d.ts +19 -0
  17. package/dist/contract/protocol/bundle-install.d.ts +3 -1
  18. package/dist/contract/protocol/bundle-install.js +3 -0
  19. package/dist/contract/protocol/platform.d.ts +1 -1
  20. package/dist/contract/runner.d.ts +6 -0
  21. package/dist/contract/runner.js +1 -0
  22. package/dist/contract/unit.d.ts +4 -4
  23. package/dist/host/adapter-contract.d.ts +3 -0
  24. package/dist/host/adapter-contract.js +93 -0
  25. package/dist/host/adapter-set.js +6 -4
  26. package/dist/host/adapter-types.d.ts +1 -1
  27. package/dist/host/bundle-manager.d.ts +3 -0
  28. package/dist/host/bundle-manager.js +181 -0
  29. package/dist/host/cli.js +3 -1
  30. package/dist/host/config-sync.d.ts +48 -0
  31. package/dist/host/config-sync.js +342 -0
  32. package/dist/host/dsh-plugins.d.ts +5 -0
  33. package/dist/host/dsh-plugins.js +51 -0
  34. package/dist/host/extension.js +1 -1
  35. package/dist/host/flow/check.d.ts +9 -0
  36. package/dist/host/flow/check.js +179 -0
  37. package/dist/host/flow/expr.d.ts +30 -0
  38. package/dist/host/flow/expr.js +99 -0
  39. package/dist/host/flow/kit.d.ts +14 -0
  40. package/dist/host/flow/kit.js +13 -0
  41. package/dist/host/flow/run.d.ts +13 -0
  42. package/dist/host/flow/run.js +287 -0
  43. package/dist/host/flow/types.d.ts +188 -0
  44. package/dist/host/flow/types.js +1 -0
  45. package/dist/host/host-actions.d.ts +2 -0
  46. package/dist/host/host-actions.js +5 -0
  47. package/dist/host/host-kit.d.ts +19 -0
  48. package/dist/host/host-kit.js +14 -0
  49. package/dist/host/http.d.ts +2 -6
  50. package/dist/host/http.js +2 -2
  51. package/dist/host/installed-module.d.ts +23 -0
  52. package/dist/host/installed-module.js +264 -0
  53. package/dist/host/lib-install.d.ts +7 -9
  54. package/dist/host/lib-install.js +9 -12
  55. package/dist/host/lib-runners.d.ts +3 -5
  56. package/dist/host/lib-runners.js +2 -2
  57. package/dist/host/local-declaration.d.ts +7 -0
  58. package/dist/host/local-declaration.js +33 -0
  59. package/dist/host/module-root.d.ts +1 -0
  60. package/dist/host/module-root.js +5 -0
  61. package/dist/host/npm-registry.d.ts +10 -0
  62. package/dist/host/npm-registry.js +16 -0
  63. package/dist/host/opencli-runner.d.ts +2 -1
  64. package/dist/host/package-content.d.ts +7 -0
  65. package/dist/host/package-content.js +69 -0
  66. package/dist/host/package-kind.d.ts +13 -0
  67. package/dist/host/package-kind.js +51 -0
  68. package/dist/host/package-route.d.ts +31 -0
  69. package/dist/host/package-route.js +57 -0
  70. package/dist/host/paths.d.ts +11 -19
  71. package/dist/host/paths.js +16 -43
  72. package/dist/host/platform.js +1 -1
  73. package/dist/host/plugin-ledger.d.ts +8 -8
  74. package/dist/host/plugin-ledger.js +23 -16
  75. package/dist/host/plugin.d.ts +1 -2
  76. package/dist/host/plugin.js +4 -5
  77. package/dist/host/port-types.d.ts +42 -9
  78. package/dist/host/preflight.d.ts +3 -2
  79. package/dist/host/profile-create.d.ts +18 -0
  80. package/dist/host/profile-create.js +118 -0
  81. package/dist/host/profile-registries.d.ts +10 -0
  82. package/dist/host/profile-registries.js +150 -0
  83. package/dist/host/registry-declaration.d.ts +21 -0
  84. package/dist/host/registry-declaration.js +33 -0
  85. package/dist/host/release-probe.d.ts +34 -0
  86. package/dist/host/release-probe.js +88 -0
  87. package/dist/host/runner.d.ts +26 -0
  88. package/dist/host/runner.js +50 -0
  89. package/dist/host/shell.d.ts +0 -2
  90. package/dist/host/shell.js +0 -7
  91. package/dist/host/spec.js +1 -1
  92. package/dist/host/supply-index.d.ts +1 -0
  93. package/dist/host/supply-index.js +8 -7
  94. package/dist/host/supply-port.js +13 -3
  95. package/dist/host/unit-face.d.ts +2 -1
  96. package/dist/host/version.js +11 -6
  97. package/dist/host.d.ts +24 -6
  98. package/dist/host.js +23 -6
  99. package/dist/i18n/en.js +16 -19
  100. package/dist/i18n/index.d.ts +17 -20
  101. package/dist/i18n/runtime-terms.d.ts +15 -0
  102. package/dist/i18n/runtime-terms.js +22 -0
  103. package/dist/i18n/zh.d.ts +16 -19
  104. package/dist/i18n/zh.js +16 -19
  105. package/dist/index.d.ts +4 -0
  106. package/dist/index.js +4 -0
  107. package/dist/testing/fake-adapter-set.d.ts +5 -0
  108. package/dist/testing/fake-adapter-set.js +150 -0
  109. package/dist/testing/needs.js +1 -1
  110. package/dist/testing.d.ts +1 -0
  111. package/dist/testing.js +1 -0
  112. package/package.json +1 -1
  113. package/src/api/__tests__/blocker-table.test.ts +25 -0
  114. package/src/api/{workbench-client.test.ts → __tests__/workbench-client.test.ts} +2 -2
  115. package/src/api/blocker-table.ts +6 -2
  116. package/src/components/SelectField.css +0 -6
  117. package/src/components/SelectField.tsx +7 -36
  118. package/src/components/SelectFieldNodes.tsx +4 -1
  119. package/src/components/{Card.test.tsx → __tests__/Card.test.tsx} +2 -2
  120. package/src/components/{CardAnchor.test.tsx → __tests__/CardAnchor.test.tsx} +3 -3
  121. package/src/components/{CardHead.test.tsx → __tests__/CardHead.test.tsx} +1 -1
  122. package/src/components/{ChoiceGroup.test.tsx → __tests__/ChoiceGroup.test.tsx} +2 -2
  123. package/src/components/{ConfirmBar.test.tsx → __tests__/ConfirmBar.test.tsx} +5 -5
  124. package/src/components/{Field.test.tsx → __tests__/Field.test.tsx} +1 -1
  125. package/src/components/{Form.test.tsx → __tests__/Form.test.tsx} +5 -5
  126. package/src/components/{FormActions.test.tsx → __tests__/FormActions.test.tsx} +3 -3
  127. package/src/components/{FormRows.test.tsx → __tests__/FormRows.test.tsx} +2 -2
  128. package/src/components/{FormTypes.test.ts → __tests__/FormTypes.test.ts} +1 -1
  129. package/src/components/{FormWiring.test.tsx → __tests__/FormWiring.test.tsx} +4 -4
  130. package/src/components/{Link.test.tsx → __tests__/Link.test.tsx} +1 -1
  131. package/src/components/{Rows.test.tsx → __tests__/Rows.test.tsx} +2 -2
  132. package/src/components/{SelectField.test.tsx → __tests__/SelectField.test.tsx} +7 -37
  133. package/src/components/{Spinner.test.tsx → __tests__/Spinner.test.tsx} +4 -4
  134. package/src/config/{constants.test.ts → __tests__/constants.test.ts} +1 -1
  135. package/src/config/{storage.test.ts → __tests__/storage.test.ts} +1 -1
  136. package/src/config/constants.ts +4 -4
  137. package/src/contract/capabilities.ts +30 -0
  138. package/src/contract/needs.ts +4 -4
  139. package/src/contract/plugin-source.ts +24 -0
  140. package/src/contract/protocol/bundle-install.ts +7 -1
  141. package/src/contract/protocol/platform.ts +1 -1
  142. package/src/contract/runner.ts +6 -0
  143. package/src/contract/unit.ts +4 -4
  144. package/src/host/{extension.test.ts → __tests__/extension.test.ts} +4 -4
  145. package/src/host/{install-failure.test.ts → __tests__/install-failure.test.ts} +1 -1
  146. package/src/host/{installed-sources.test.ts → __tests__/installed-sources.test.ts} +1 -1
  147. package/src/host/__tests__/lib-runners.test.ts +11 -2
  148. package/src/host/__tests__/plugin-ledger.test.ts +19 -6
  149. package/src/host/{recipe.test.ts → __tests__/recipe.test.ts} +3 -3
  150. package/src/host/__tests__/runtime-layout.test.ts +14 -0
  151. package/src/host/__tests__/supply-index.test.ts +2 -2
  152. package/src/host/__tests__/supply-port.test.ts +32 -0
  153. package/src/host/adapter-contract.ts +93 -0
  154. package/src/host/adapter-set.ts +6 -4
  155. package/src/host/adapter-types.ts +1 -1
  156. package/src/host/bundle-manager.ts +195 -0
  157. package/src/host/cli.ts +3 -1
  158. package/src/host/config-sync.ts +386 -0
  159. package/src/host/dsh-plugins.ts +60 -0
  160. package/src/host/extension.ts +1 -1
  161. package/src/host/flow/__tests__/advanced.test.ts +302 -0
  162. package/src/host/flow/__tests__/check.test.ts +169 -0
  163. package/src/host/flow/__tests__/run.test.ts +301 -0
  164. package/src/host/flow/check.ts +216 -0
  165. package/src/host/flow/expr.ts +94 -0
  166. package/src/host/flow/kit.ts +30 -0
  167. package/src/host/flow/run.ts +325 -0
  168. package/src/host/flow/types.ts +199 -0
  169. package/src/host/host-actions.ts +7 -0
  170. package/src/host/host-kit.ts +26 -0
  171. package/src/host/http.ts +6 -8
  172. package/src/host/installed-module.ts +272 -0
  173. package/src/host/lib-install.ts +14 -12
  174. package/src/host/lib-runners.ts +6 -8
  175. package/src/host/local-declaration.ts +37 -0
  176. package/src/host/module-root.ts +6 -0
  177. package/src/host/npm-registry.ts +21 -0
  178. package/src/host/package-content.ts +76 -0
  179. package/src/host/package-kind.ts +63 -0
  180. package/src/host/package-route.ts +93 -0
  181. package/src/host/paths.ts +27 -52
  182. package/src/host/platform.ts +1 -1
  183. package/src/host/plugin-ledger.ts +25 -18
  184. package/src/host/plugin.ts +4 -5
  185. package/src/host/port-types.ts +43 -9
  186. package/src/host/preflight.ts +3 -2
  187. package/src/host/profile-create.ts +133 -0
  188. package/src/host/profile-registries.ts +145 -0
  189. package/src/host/registry-declaration.ts +70 -0
  190. package/src/host/release-probe.ts +202 -0
  191. package/src/host/shell.ts +0 -9
  192. package/src/host/spec.ts +1 -1
  193. package/src/host/supply-index.ts +9 -7
  194. package/src/host/supply-port.ts +13 -3
  195. package/src/host/unit-face.ts +2 -1
  196. package/src/host/version.ts +12 -7
  197. package/src/host.ts +34 -6
  198. package/src/i18n/{index.test.ts → __tests__/index.test.ts} +3 -3
  199. package/src/i18n/{merge.test.ts → __tests__/merge.test.ts} +1 -1
  200. package/src/i18n/{zh.test.ts → __tests__/zh.test.ts} +2 -2
  201. package/src/i18n/en.ts +16 -20
  202. package/src/i18n/runtime-terms.ts +26 -0
  203. package/src/i18n/zh.ts +16 -20
  204. package/src/index.ts +4 -0
  205. package/src/lib/{board-fact.test.ts → __tests__/board-fact.test.ts} +1 -1
  206. package/src/lib/{busy.test.ts → __tests__/busy.test.ts} +1 -1
  207. package/src/lib/{form-problem.test.ts → __tests__/form-problem.test.ts} +1 -1
  208. package/src/lib/{git-address.test.ts → __tests__/git-address.test.ts} +1 -1
  209. package/src/lib/{react.test.ts → __tests__/react.test.ts} +1 -1
  210. package/src/lib/{text.test.ts → __tests__/text.test.ts} +1 -1
  211. package/src/lib/{ui.test.ts → __tests__/ui.test.ts} +1 -1
  212. package/src/testing/__tests__/fake-adapter-set.test.ts +28 -0
  213. package/src/testing/fake-adapter-set.ts +173 -0
  214. package/src/testing/needs.ts +1 -1
  215. package/src/testing.ts +1 -0
  216. package/src/host/__tests__/command-name.test.ts +0 -16
  217. package/src/host/__tests__/opencli-runner.test.ts +0 -120
  218. package/src/host/channel-plugin.ts +0 -55
  219. package/src/host/command-name.ts +0 -15
  220. package/src/host/opencli-runner.ts +0 -76
  221. package/src/host/paths.test.ts +0 -26
  222. package/src/host/plugin-source.ts +0 -137
  223. package/src/host/runtime-links.test.ts +0 -31
  224. package/src/host/runtime-links.ts +0 -23
@@ -0,0 +1,195 @@
1
+ import type { DshHostContext } from '../api/dsh.js';
2
+ import { RESTART_REASONS } from '../contract/protocol/bundle-install.js';
3
+ import type { BundleManager } from './adapter-types.js';
4
+ import type {
5
+ BundleChange,
6
+ BundleInspection,
7
+ BundleRegistries,
8
+ BundleRegistry,
9
+ } from '../contract/protocol/bundle-install.js';
10
+
11
+ const MANAGER_SERVICE = 'pluginManager';
12
+
13
+ interface PluginManagerFace {
14
+ registries?(): Promise<unknown>;
15
+ inspect?(spec: string, options?: { readonly registry?: BundleRegistry }): Promise<unknown>;
16
+ installBundle?(spec: string, options?: unknown): Promise<unknown>;
17
+ removeBundle?(name: string): Promise<unknown>;
18
+ setBundleEnabled?(name: string, enabled: boolean): Promise<unknown>;
19
+ cancelInstall?(requestId: string): Promise<unknown>;
20
+ }
21
+
22
+ const APPLICATIONS = ['applied', 'restart-required', 'overridden', 'failed', 'cancelled'] as const;
23
+ const STAGES = ['install', 'enable', 'remove'] as const;
24
+ const PROBLEMS = [
25
+ 'invalid-spec',
26
+ 'already-installed',
27
+ 'not-found',
28
+ 'not-a-package',
29
+ 'not-a-bundle',
30
+ 'not-a-workbench-module',
31
+ 'network',
32
+ 'unknown',
33
+ ] as const;
34
+ const SPEC_KINDS = ['registry', 'path', 'git', 'tarball'] as const;
35
+ const FAILURE_KINDS = [
36
+ 'pnpm-missing',
37
+ 'timeout',
38
+ 'not-found',
39
+ 'no-matching-version',
40
+ 'network',
41
+ 'disk-full',
42
+ 'permission',
43
+ 'build-blocked',
44
+ 'integrity',
45
+ 'unknown',
46
+ ] as const;
47
+ const CANCELLATIONS = ['cancelled', 'too-late', 'not-running'] as const;
48
+
49
+ function asRecord(value: unknown): Readonly<Record<string, unknown>> | undefined {
50
+ return typeof value === 'object' && value !== null ? (value as Readonly<Record<string, unknown>>) : undefined;
51
+ }
52
+
53
+ function asText(value: unknown): string {
54
+ return typeof value === 'string' ? value : '';
55
+ }
56
+
57
+ function pick<T extends string>(allowed: readonly T[], value: unknown): T | undefined {
58
+ return typeof value === 'string' && (allowed as readonly string[]).includes(value) ? (value as T) : undefined;
59
+ }
60
+
61
+ function asRegistry(value: unknown): BundleRegistry | undefined {
62
+ if (value === null) return null;
63
+ return typeof value === 'string' ? value : undefined;
64
+ }
65
+
66
+ function asTextArray(value: unknown): string[] {
67
+ return Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : [];
68
+ }
69
+
70
+ function asRegistries(raw: unknown): BundleRegistries | null {
71
+ const record = asRecord(raw);
72
+ if (record === undefined) return null;
73
+ const registry = asRegistry(record.registry);
74
+ if (registry === undefined) return null;
75
+ return {
76
+ registry,
77
+ fallbackRegistries: asTextArray(record.fallbackRegistries),
78
+ resolved: typeof record.resolved === 'string' ? record.resolved : null,
79
+ };
80
+ }
81
+
82
+ function asPackageResult(raw: unknown) {
83
+ const record = asRecord(raw);
84
+ if (record === undefined) return undefined;
85
+ const kind = pick(FAILURE_KINDS, record.kind);
86
+ return {
87
+ exitCode: typeof record.exitCode === 'number' ? record.exitCode : -1,
88
+ output: asText(record.output),
89
+ truncated: record.truncated === true,
90
+ logPath: asText(record.logPath),
91
+ ...(kind === undefined ? {} : { kind }),
92
+ };
93
+ }
94
+
95
+ function asChange(raw: unknown): BundleChange | null {
96
+ const record = asRecord(raw);
97
+ if (record === undefined) return null;
98
+ const application = pick(APPLICATIONS, record.application);
99
+ const stage = pick(STAGES, record.stage);
100
+ if (application === undefined || stage === undefined) return null;
101
+ const error = asRecord(record.error);
102
+ const packageResult = asPackageResult(record.packageResult);
103
+ const pendingBuilds = asTextArray(record.pendingBuilds);
104
+ const restartReason = pick(RESTART_REASONS, record.restartReason);
105
+ const failedAt = pick(['registry', 'spec-host'] as const, record.failedAt);
106
+ const bundle = asText(record.bundle);
107
+ const version = asText(record.version);
108
+ const errorCode = error === undefined ? '' : asText(error.code);
109
+ const errorDetail = error === undefined ? '' : asText(error.diagnostic);
110
+ return {
111
+ application,
112
+ stage,
113
+ changed: record.changed === true,
114
+ target: asText(record.target),
115
+ ...(bundle === '' ? {} : { bundle }),
116
+ ...(version === '' ? {} : { version }),
117
+ ...(errorCode === '' ? {} : { errorCode }),
118
+ ...(errorDetail === '' ? {} : { errorDetail }),
119
+ ...(packageResult === undefined ? {} : { packageResult }),
120
+ ...(pendingBuilds.length === 0 ? {} : { pendingBuilds }),
121
+ ...(restartReason === undefined ? {} : { restartReason }),
122
+ ...(failedAt === undefined ? {} : { failedAt }),
123
+ };
124
+ }
125
+
126
+ function asInspection(raw: unknown): BundleInspection | null {
127
+ const record = asRecord(raw);
128
+ if (record === undefined) return null;
129
+ const status = asText(record.status);
130
+ if (status === 'refused') {
131
+ const problem = pick(PROBLEMS, record.problem) ?? 'unknown';
132
+ const registries = Array.isArray(record.registries)
133
+ ? record.registries.map(asRegistry).filter((item): item is BundleRegistry => item !== undefined)
134
+ : [];
135
+ return {
136
+ status: 'refused',
137
+ problem,
138
+ reason: asText(record.reason),
139
+ ...(registries.length === 0 ? {} : { registries }),
140
+ };
141
+ }
142
+ if (status !== 'accepted') return null;
143
+ const kind = pick(SPEC_KINDS, record.kind);
144
+ const registry = asRegistry(record.registry);
145
+ if (kind === undefined || registry === undefined) return null;
146
+ const name = asText(record.name);
147
+ const version = asText(record.version);
148
+ const description = asText(record.description);
149
+ const host = asText(record.host);
150
+ return {
151
+ status: 'accepted',
152
+ kind,
153
+ bundle: typeof record.bundle === 'boolean' ? record.bundle : null,
154
+ registry,
155
+ ...(name === '' ? {} : { name }),
156
+ ...(version === '' ? {} : { version }),
157
+ ...(description === '' ? {} : { description }),
158
+ ...(host === '' ? {} : { host }),
159
+ };
160
+ }
161
+
162
+ export function readBundleManager(ctx: DshHostContext): BundleManager | undefined {
163
+ const face = ctx.get<PluginManagerFace>(MANAGER_SERVICE);
164
+ if (face === undefined) return undefined;
165
+ if (typeof face.installBundle !== 'function' || typeof face.removeBundle !== 'function') return undefined;
166
+ return {
167
+ async registries() {
168
+ if (typeof face.registries !== 'function') return null;
169
+ return asRegistries(await face.registries());
170
+ },
171
+ async inspect(spec, registry) {
172
+ if (typeof face.inspect !== 'function') return null;
173
+ return asInspection(await face.inspect(spec, registry === undefined ? undefined : { registry }));
174
+ },
175
+ async install(request) {
176
+ const options = {
177
+ ...(request.registry === undefined ? {} : { registry: request.registry }),
178
+ ...(request.requestId === undefined ? {} : { requestId: request.requestId }),
179
+ ...(request.approvedBuilds === undefined ? {} : { approvedBuilds: request.approvedBuilds }),
180
+ };
181
+ return asChange(await face.installBundle?.(request.spec, options));
182
+ },
183
+ async remove(name) {
184
+ return asChange(await face.removeBundle?.(name));
185
+ },
186
+ async setEnabled(name, enabled) {
187
+ if (typeof face.setBundleEnabled !== 'function') return null;
188
+ return asChange(await face.setBundleEnabled(name, enabled));
189
+ },
190
+ async cancel(requestId) {
191
+ if (typeof face.cancelInstall !== 'function') return null;
192
+ return pick(CANCELLATIONS, (asRecord(await face.cancelInstall(requestId)) ?? {}).status) ?? null;
193
+ },
194
+ };
195
+ }
package/src/host/cli.ts CHANGED
@@ -3,7 +3,9 @@ import type { CliPort } from '../contract/cli.js';
3
3
  import { adaptersOf } from './adapters.js';
4
4
 
5
5
  export function cliPortOf(ctx: DshHostContext): CliPort {
6
- return { command: adaptersOf(ctx).runtime.opencliCommand(ctx).command };
6
+ const commands = adaptersOf(ctx).runtime.commands(ctx);
7
+ const first = Object.values(commands)[0];
8
+ return { command: first ?? '' };
7
9
  }
8
10
 
9
11
  export function cliCommandOf(ctx: DshHostContext): string {
@@ -0,0 +1,386 @@
1
+ import { existsSync, readFileSync, readdirSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import type { PostResource, RouteHandler } from './http.js';
4
+ import type { ConfigSyncProfile, ConfigSyncRow, ConfigSyncSnapshot } from '../contract/protocol/config-sync.js';
5
+ import { CONFIG_SYNC_RESOURCE } from '../contract/protocol/config-sync.js';
6
+
7
+ export interface PatchBlock {
8
+ readonly key: string;
9
+ readonly id: string;
10
+ readonly insert: boolean;
11
+ readonly body: string;
12
+ readonly names: readonly string[];
13
+ }
14
+
15
+ interface SpannedBlock extends PatchBlock {
16
+ readonly from: number;
17
+ readonly to: number;
18
+ }
19
+
20
+ const ID_LINE = /^- id:[ \t]*"?([^"\s#]+)/;
21
+ const INSERT_LINE = /^- insert:[ \t]*$/;
22
+ const NESTED_ID = /^[ \t]+-[ \t]*id:[ \t]*"?([^"\s#]+)/;
23
+ const NAME_LINE = /^[ \t]*-?[ \t]*name:[ \t]*['"]?([^'"\s#]+)/;
24
+ const COMMENT_LINE = /^[ \t]*#/;
25
+ const ISOLATION_IDS = new Set([
26
+ 'session-persistence-jsonl',
27
+ 'storage-json',
28
+ 'attachment-local',
29
+ 'sandbox-policy',
30
+ ]);
31
+
32
+ function trimmedTail(lines: readonly string[]): readonly string[] {
33
+ let end = lines.length;
34
+ while (end > 0 && (lines[end - 1] ?? '').trim() === '') end -= 1;
35
+ return lines.slice(0, end);
36
+ }
37
+
38
+ function matched(text: string, pattern: RegExp): string | undefined {
39
+ const found = pattern.exec(text);
40
+ return found === null ? undefined : found[1];
41
+ }
42
+
43
+ function blockOf(body: string, index: number): PatchBlock {
44
+ const lines = body.split('\n');
45
+ const first = lines[0] ?? '';
46
+ const id = matched(first, ID_LINE);
47
+ const nested = lines.flatMap((line) => {
48
+ const one = matched(line, NESTED_ID);
49
+ return one === undefined ? [] : [one];
50
+ });
51
+ const insert = id === undefined && INSERT_LINE.test(first);
52
+ const label = id ?? nested.join(', ');
53
+ const key = id !== undefined ? `id:${id}` : insert ? `insert:${nested.join(', ')}` : `block:${String(index)}`;
54
+ const names = lines.flatMap((line) => {
55
+ const one = matched(line, NAME_LINE);
56
+ return one === undefined ? [] : [one];
57
+ });
58
+ return { key, id: label === '' ? key : label, insert, body, names };
59
+ }
60
+
61
+ function spannedBlocks(text: string): SpannedBlock[] {
62
+ const lines = text.split('\n');
63
+ const starts: number[] = [];
64
+ lines.forEach((line, index) => {
65
+ if (/^- /.test(line) || line === '-') starts.push(index);
66
+ });
67
+ const blocks: SpannedBlock[] = [];
68
+ for (let i = 0; i < starts.length; i += 1) {
69
+ const from = starts[i] ?? 0;
70
+ const to = i + 1 < starts.length ? (starts[i + 1] ?? lines.length) : lines.length;
71
+ const body = trimmedTail(lines.slice(from, to)).join('\n');
72
+ if (body.trim() === '') continue;
73
+ blocks.push({ ...blockOf(body, blocks.length), from, to });
74
+ }
75
+ return blocks;
76
+ }
77
+
78
+ export function patchBlocks(text: string): PatchBlock[] {
79
+ return spannedBlocks(text).map(({ from: _from, to: _to, ...block }) => block);
80
+ }
81
+
82
+ export function normalizeBlock(body: string): string {
83
+ return body
84
+ .split('\n')
85
+ .map((line) => line.replace(/\s+$/, ''))
86
+ .filter((line) => line.trim() !== '' && !COMMENT_LINE.test(line))
87
+ .join('\n');
88
+ }
89
+
90
+ function rewriteForTarget(body: string, self: string): string {
91
+ if (self === '' || self === 'workbench') return body;
92
+ return body.replace(/(dshHomePath\(\s*['"])workbench(?=[/'"])/g, `$1${self}`);
93
+ }
94
+
95
+ function syncable(block: PatchBlock): boolean {
96
+ return !block.insert && !ISOLATION_IDS.has(block.id);
97
+ }
98
+
99
+ export function syncRows(
100
+ source: readonly PatchBlock[],
101
+ own: readonly PatchBlock[],
102
+ missing: (block: PatchBlock) => readonly string[],
103
+ ): ConfigSyncRow[] {
104
+ const mine = new Map(own.map((block) => [block.key, block]));
105
+ const rows: ConfigSyncRow[] = [];
106
+ for (const block of source) {
107
+ if (!syncable(block)) continue;
108
+ const here = mine.get(block.key);
109
+ if (here !== undefined && normalizeBlock(here.body) === normalizeBlock(block.body)) continue;
110
+ rows.push({
111
+ key: block.key,
112
+ id: block.id,
113
+ state: here === undefined ? 'new' : 'differs',
114
+ body: block.body,
115
+ ownBody: here === undefined ? null : here.body,
116
+ missing: missing(block),
117
+ });
118
+ }
119
+ return rows;
120
+ }
121
+
122
+ function replaceBlocks(text: string, blocks: readonly PatchBlock[]): string {
123
+ const wanted = new Map(blocks.map((block) => [block.key, block]));
124
+ const spans = spannedBlocks(text).filter((span) => wanted.has(span.key));
125
+ const lines = text.split('\n');
126
+ for (const span of [...spans].reverse()) {
127
+ const replacement = (wanted.get(span.key)?.body ?? '').split('\n');
128
+ lines.splice(span.from, span.to - span.from, ...replacement);
129
+ }
130
+ return lines.join('\n');
131
+ }
132
+
133
+ function appendBlocks(text: string, blocks: readonly PatchBlock[]): string {
134
+ const kept = text
135
+ .split('\n')
136
+ .filter((line) => line.trim() !== '[]')
137
+ .join('\n')
138
+ .replace(/\s+$/, '');
139
+ let out = kept === '' ? '' : `${kept}\n`;
140
+ for (const block of blocks) out += `\n${block.body}\n`;
141
+ return out;
142
+ }
143
+
144
+ export function mergeBlocks(
145
+ ownText: string,
146
+ incoming: readonly PatchBlock[],
147
+ overwrite: ReadonlySet<string>,
148
+ ): { ok: true; text: string; written: string[]; skipped: string[] } | { ok: false; reason: string } {
149
+ const own = patchBlocks(ownText);
150
+ const mine = new Set(own.map((block) => block.key));
151
+ const written: string[] = [];
152
+ const skipped: string[] = [];
153
+ const take: PatchBlock[] = [];
154
+ for (const block of incoming) {
155
+ if (!mine.has(block.key) || overwrite.has(block.key)) {
156
+ take.push(block);
157
+ written.push(block.id);
158
+ continue;
159
+ }
160
+ skipped.push(block.id);
161
+ }
162
+ if (take.length === 0) return { ok: true, text: ownText, written, skipped };
163
+ const replaced = replaceBlocks(ownText, take.filter((block) => mine.has(block.key)));
164
+ const text = appendBlocks(
165
+ replaced,
166
+ take.filter((block) => !mine.has(block.key)),
167
+ );
168
+ const check = patchBlocks(text);
169
+ const expected = new Set([...mine, ...take.map((block) => block.key)]);
170
+ if (check.length !== expected.size || new Set(check.map((block) => block.key)).size !== expected.size) {
171
+ return { ok: false, reason: '写入前的自检没过:行的 id 会重复或丢行' };
172
+ }
173
+ return { ok: true, text, written, skipped };
174
+ }
175
+
176
+ export function readPatchBlocks(
177
+ path: string,
178
+ ): { ok: true; text: string; blocks: PatchBlock[] } | { ok: false; reason: string } {
179
+ try {
180
+ const text = readFileSync(path, 'utf8');
181
+ return { ok: true, text, blocks: patchBlocks(text) };
182
+ } catch (error) {
183
+ return { ok: false, reason: error instanceof Error ? error.message : String(error) };
184
+ }
185
+ }
186
+
187
+ function patchMode(path: string): number {
188
+ try {
189
+ return statSync(path).mode & 0o777;
190
+ } catch {
191
+ return 0o644;
192
+ }
193
+ }
194
+
195
+ export function writePatchFile(path: string, text: string): void {
196
+ const mode = patchMode(path);
197
+ const tmp = `${path}.wb-sync-${String(process.pid)}`;
198
+ writeFileSync(tmp, text, { mode });
199
+ try {
200
+ renameSync(tmp, path);
201
+ } catch (error) {
202
+ try {
203
+ unlinkSync(tmp);
204
+ } catch {
205
+ throw error;
206
+ }
207
+ throw error;
208
+ }
209
+ }
210
+
211
+ export interface ConfigSyncDeps {
212
+ readonly home: string;
213
+ readonly self: string;
214
+ readonly profileDir: string | null;
215
+ readonly reconcile?: (() => Promise<boolean | void>) | undefined;
216
+ }
217
+
218
+ export function configSyncHandler(deps: ConfigSyncDeps): RouteHandler {
219
+ return (search) => ({ ok: true, ...configSyncSnapshot(deps, search.get('source') ?? undefined) });
220
+ }
221
+
222
+ function textOf(value: unknown): string {
223
+ return typeof value === 'string' ? value : '';
224
+ }
225
+
226
+ function profileDependencies(dir: string): ReadonlySet<string> {
227
+ try {
228
+ const manifest = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')) as {
229
+ dependencies?: Record<string, unknown>;
230
+ };
231
+ return new Set(Object.keys(manifest.dependencies ?? {}));
232
+ } catch {
233
+ return new Set();
234
+ }
235
+ }
236
+
237
+ function profileNames(profilesDir: string): readonly string[] {
238
+ try {
239
+ return readdirSync(profilesDir, { withFileTypes: true })
240
+ .filter((entry) => entry.isDirectory() || entry.isSymbolicLink())
241
+ .map((entry) => entry.name)
242
+ .filter((name) => existsSync(join(profilesDir, name, 'package.json')))
243
+ .sort((a, b) => a.localeCompare(b));
244
+ } catch {
245
+ return [];
246
+ }
247
+ }
248
+
249
+ export function configSyncSnapshot(deps: ConfigSyncDeps, asked?: string): ConfigSyncSnapshot {
250
+ const profilesDir = join(deps.home, 'profiles');
251
+ const names = profileNames(profilesDir);
252
+ const others = names.filter((name) => name !== deps.self);
253
+ const base = {
254
+ self: deps.self,
255
+ home: deps.home,
256
+ };
257
+ if (deps.profileDir === null) {
258
+ return {
259
+ ...base,
260
+ source: '',
261
+ profiles: [],
262
+ rows: [],
263
+ emptyProviders: false,
264
+ reason: '拿不到当前 profile 目录',
265
+ };
266
+ }
267
+ const own = readPatchBlocks(join(deps.profileDir, 'cordis.patch.yml'));
268
+ const emptyProviders = own.ok && !own.blocks.some((block) => block.key.startsWith('id:llm-'));
269
+ const ownBlocks = own.ok ? own.blocks : [];
270
+ const profiles: ConfigSyncProfile[] = others.map((name) => {
271
+ const read = readPatchBlocks(join(profilesDir, name, 'cordis.patch.yml'));
272
+ const blocks = read.ok
273
+ ? read.blocks
274
+ .filter(syncable)
275
+ .map((block) => ({ ...block, body: rewriteForTarget(block.body, deps.self) }))
276
+ : [];
277
+ return {
278
+ name,
279
+ readable: read.ok,
280
+ candidates: read.ok ? syncRows(blocks, ownBlocks, () => []).length : 0,
281
+ };
282
+ });
283
+ if (others.length === 0) {
284
+ return {
285
+ ...base,
286
+ source: '',
287
+ profiles,
288
+ rows: [],
289
+ emptyProviders,
290
+ reason: '这台机器上只有当前这个 profile,没有可同步的来源',
291
+ };
292
+ }
293
+ const source = others.includes(asked ?? '') ? (asked ?? '') : others.includes('web') ? 'web' : (others[0] ?? '');
294
+ const sourceDir = join(profilesDir, source);
295
+ const read = readPatchBlocks(join(sourceDir, 'cordis.patch.yml'));
296
+ if (!read.ok) {
297
+ return { ...base, source, profiles, rows: [], emptyProviders, reason: read.reason };
298
+ }
299
+ if (!own.ok) {
300
+ return { ...base, source, profiles, rows: [], emptyProviders, reason: own.reason };
301
+ }
302
+ const declared = profileDependencies(sourceDir);
303
+ const modulesDir = join(deps.profileDir, 'node_modules');
304
+ const sourceBlocks = read.blocks
305
+ .filter(syncable)
306
+ .map((block) => ({ ...block, body: rewriteForTarget(block.body, deps.self) }));
307
+ const rows = syncRows(sourceBlocks, own.blocks, (block) =>
308
+ block.names.filter((name) => declared.has(name) && !existsSync(join(modulesDir, name))),
309
+ );
310
+ return { ...base, source, profiles, rows, emptyProviders };
311
+ }
312
+
313
+ export async function configSyncApply(
314
+ deps: ConfigSyncDeps,
315
+ payload: unknown,
316
+ ): Promise<{ ok: true; written: string[]; skipped: string[]; verified: boolean } | { ok: false; text: string }> {
317
+ if (deps.profileDir === null) return { ok: false, text: '拿不到当前 profile 目录,这次没写任何东西' };
318
+ const body = typeof payload === 'object' && payload !== null ? (payload as Record<string, unknown>) : {};
319
+ const source = textOf(body['source']);
320
+ const keys = new Set(
321
+ (Array.isArray(body['keys']) ? body['keys'] : []).map((item) => textOf(item)).filter((item) => item !== ''),
322
+ );
323
+ const overwrite = new Set(
324
+ (Array.isArray(body['overwrite']) ? body['overwrite'] : []).map((item) => textOf(item)),
325
+ );
326
+ if (source === '' || keys.size === 0) {
327
+ return { ok: false, text: '没说清从哪个 profile 同步哪几行' };
328
+ }
329
+ const profilesDir = join(deps.home, 'profiles');
330
+ if (!profileNames(profilesDir).includes(source)) return { ok: false, text: `没有这个 profile:${source}` };
331
+ if (source === deps.self) return { ok: false, text: '来源不能是工作台自己' };
332
+ const sourceDir = join(profilesDir, source);
333
+ const read = readPatchBlocks(join(sourceDir, 'cordis.patch.yml'));
334
+ if (!read.ok) return { ok: false, text: `读不到 ${source} 的配置:${read.reason}` };
335
+ const own = readPatchBlocks(join(deps.profileDir, 'cordis.patch.yml'));
336
+ if (!own.ok) return { ok: false, text: `读不到工作台自己的配置:${own.reason}` };
337
+ if (read.blocks.some((block) => block.insert && keys.has(block.key))) {
338
+ return { ok: false, text: '插件行不同步:先把插件装到工作台这边,再同步它的设置' };
339
+ }
340
+ const picked = read.blocks
341
+ .filter((block) => keys.has(block.key) && syncable(block))
342
+ .map((block) => ({ ...block, body: rewriteForTarget(block.body, deps.self) }));
343
+ if (picked.length === 0) {
344
+ return { ok: false, text: '选中的行在来源里已经找不到了,重新读一遍再试' };
345
+ }
346
+ const merged = mergeBlocks(own.text, picked, overwrite);
347
+ if (!merged.ok) return { ok: false, text: merged.reason };
348
+ const patchPath = join(deps.profileDir, 'cordis.patch.yml');
349
+ const patchChanged = merged.text !== own.text;
350
+ try {
351
+ if (patchChanged) writePatchFile(patchPath, merged.text);
352
+ } catch (error) {
353
+ return { ok: false, text: `写不进去:${error instanceof Error ? error.message : String(error)}` };
354
+ }
355
+ if (deps.reconcile === undefined) {
356
+ return { ok: true, written: merged.written, skipped: merged.skipped, verified: false };
357
+ }
358
+ try {
359
+ const verified = await deps.reconcile();
360
+ if (verified === false) {
361
+ return { ok: true, written: merged.written, skipped: merged.skipped, verified: false };
362
+ }
363
+ } catch (error) {
364
+ if (patchChanged) {
365
+ try {
366
+ writePatchFile(patchPath, own.text);
367
+ } catch {
368
+ return { ok: false, text: `写进去了,但启动检查没通过:${error instanceof Error ? error.message : String(error)}。原来的设置没能恢复,手动检查一下` };
369
+ }
370
+ }
371
+ try {
372
+ await deps.reconcile();
373
+ } catch {
374
+ return { ok: false, text: `写进去了,但启动检查没通过:${error instanceof Error ? error.message : String(error)}。已恢复原来的设置` };
375
+ }
376
+ return { ok: false, text: `写进去了,但启动检查没通过:${error instanceof Error ? error.message : String(error)}。已恢复原来的设置` };
377
+ }
378
+ return { ok: true, written: merged.written, skipped: merged.skipped, verified: true };
379
+ }
380
+
381
+ export function configSyncAction(deps: ConfigSyncDeps): PostResource {
382
+ return {
383
+ resource: CONFIG_SYNC_RESOURCE,
384
+ handle: (payload: unknown) => configSyncApply(deps, payload),
385
+ };
386
+ }
@@ -0,0 +1,60 @@
1
+ import type { DshHostContext } from '../api/dsh.js';
2
+ import type { RouteHandler } from './http.js';
3
+ import type { DshPluginRow, DshPluginsSnapshot } from '../contract/protocol/dsh-plugins.js';
4
+
5
+ const MANAGER_SERVICE = 'pluginManager';
6
+
7
+ interface DshPluginManagerFace {
8
+ listBundles?(): Promise<unknown>;
9
+ listPlugins?(): Promise<unknown>;
10
+ }
11
+
12
+ function asRecord(value: unknown): Readonly<Record<string, unknown>> | undefined {
13
+ return typeof value === 'object' && value !== null ? (value as Readonly<Record<string, unknown>>) : undefined;
14
+ }
15
+
16
+ function asArray(value: unknown): readonly unknown[] {
17
+ if (Array.isArray(value)) return value;
18
+ const record = asRecord(value);
19
+ if (record === undefined) return [];
20
+ for (const key of ['rows', 'items', 'list', 'bundles', 'plugins']) {
21
+ const inner = record[key];
22
+ if (Array.isArray(inner)) return inner;
23
+ }
24
+ return [];
25
+ }
26
+
27
+ function asText(value: unknown): string {
28
+ return typeof value === 'string' ? value : '';
29
+ }
30
+
31
+ function rowOf(value: unknown): DshPluginRow | undefined {
32
+ const record = asRecord(value);
33
+ if (record === undefined) return undefined;
34
+ const name = asText(record.name) || asText(record.id);
35
+ if (name === '') return undefined;
36
+ return { name, version: asText(record.version), enabled: record.disabled !== true && record.enabled !== false };
37
+ }
38
+
39
+ function rowsOf(value: unknown): readonly DshPluginRow[] {
40
+ const rows: DshPluginRow[] = [];
41
+ for (const item of asArray(value)) {
42
+ const row = rowOf(item);
43
+ if (row !== undefined) rows.push(row);
44
+ }
45
+ return rows;
46
+ }
47
+
48
+ export async function readDshPlugins(ctx: DshHostContext): Promise<DshPluginsSnapshot> {
49
+ const manager = ctx.get<DshPluginManagerFace>(MANAGER_SERVICE);
50
+ if (manager === undefined) return { available: false, bundles: [], plugins: [] };
51
+ const [bundles, plugins] = await Promise.all([
52
+ manager.listBundles === undefined ? undefined : manager.listBundles(),
53
+ manager.listPlugins === undefined ? undefined : manager.listPlugins(),
54
+ ]);
55
+ return { available: true, bundles: rowsOf(bundles), plugins: rowsOf(plugins) };
56
+ }
57
+
58
+ export function dshPluginsHandler(ctx: DshHostContext): RouteHandler {
59
+ return () => readDshPlugins(ctx);
60
+ }
@@ -35,7 +35,7 @@ export function extensionCallerOf(
35
35
  const command = [need.name, ...args].join(' ');
36
36
  const advice =
37
37
  code === 'cli-unclassified'
38
- ? `这条命令(${command})在这份 ${need.name} 插件里可能不存在(插件版本旧就会缺):用 workbench_modules_update(id=${callerOptions.suiteId ?? need.name})升级,或在面板的模块状态里点更新,再重试。终端确认:opencli ${need.name} ${args[0] ?? ''} --help`
38
+ ? `这条命令(${command})在这份 ${need.name} 插件里可能不存在(插件版本旧就会缺):用 workbench_modules_update(id=${callerOptions.suiteId ?? need.name})升级,或在面板的模块状态里点更新,再重试。终端确认:${need.name} ${args[0] ?? ''} --help`
39
39
  : undefined;
40
40
  const hint =
41
41
  advice === undefined