cortico 0.1.0

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 (311) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +47 -0
  3. package/package.json +43 -0
  4. package/src/boot.ts +46 -0
  5. package/src/bot.ts +1342 -0
  6. package/src/config-file.ts +41 -0
  7. package/src/core/README.md +92 -0
  8. package/src/core/billing.ts +26 -0
  9. package/src/core/blobs.ts +106 -0
  10. package/src/core/bus.ts +311 -0
  11. package/src/core/config-schema.ts +201 -0
  12. package/src/core/config.ts +262 -0
  13. package/src/core/core.ts +699 -0
  14. package/src/core/cost.ts +244 -0
  15. package/src/core/event-store.ts +304 -0
  16. package/src/core/fork.ts +113 -0
  17. package/src/core/generation.ts +118 -0
  18. package/src/core/instance-lock.ts +215 -0
  19. package/src/core/ipc-logger.ts +105 -0
  20. package/src/core/language.ts +62 -0
  21. package/src/core/log-context.ts +38 -0
  22. package/src/core/loop.ts +1858 -0
  23. package/src/core/markers.ts +48 -0
  24. package/src/core/prefix.ts +121 -0
  25. package/src/core/run.ts +95 -0
  26. package/src/core/secrets.ts +18 -0
  27. package/src/core/session.ts +56 -0
  28. package/src/core/sessions.ts +261 -0
  29. package/src/core/state.ts +104 -0
  30. package/src/core/template.ts +72 -0
  31. package/src/core/timers.ts +129 -0
  32. package/src/core/tool-log.ts +135 -0
  33. package/src/core/transcript.ts +92 -0
  34. package/src/core/truncate.ts +121 -0
  35. package/src/core/types.ts +1140 -0
  36. package/src/core/usage-log.ts +79 -0
  37. package/src/core/util.ts +386 -0
  38. package/src/deploy-listing.ts +160 -0
  39. package/src/deploy.ts +162 -0
  40. package/src/extensions/README.md +72 -0
  41. package/src/extensions/dry-mount.ts +390 -0
  42. package/src/extensions/manifest.ts +153 -0
  43. package/src/extensions/runtime.ts +42 -0
  44. package/src/extensions.ts +519 -0
  45. package/src/launcher.ts +285 -0
  46. package/src/paths.ts +155 -0
  47. package/src/protocol/open-responses/LICENSE +201 -0
  48. package/src/protocol/open-responses/README.md +14 -0
  49. package/src/protocol/open-responses/context-helpers.ts +27 -0
  50. package/src/protocol/open-responses/context-log.ts +59 -0
  51. package/src/protocol/open-responses/context.ts +89 -0
  52. package/src/protocol/open-responses/generated.ts +115 -0
  53. package/src/protocol/open-responses/index.ts +33 -0
  54. package/src/protocol/open-responses/openapi.json +4230 -0
  55. package/src/protocol/open-responses/stream.ts +146 -0
  56. package/src/protocol/open-responses/tokens.ts +31 -0
  57. package/src/providers/README.md +86 -0
  58. package/src/providers/base.ts +111 -0
  59. package/src/providers/configuration.ts +97 -0
  60. package/src/providers/console/config.ts +21 -0
  61. package/src/providers/console/settings.ts +443 -0
  62. package/src/providers/console/strings.ts +74 -0
  63. package/src/providers/console/types.ts +11 -0
  64. package/src/providers/llamacpp/archive.ts +148 -0
  65. package/src/providers/llamacpp/catalog.ts +247 -0
  66. package/src/providers/llamacpp/config.ts +36 -0
  67. package/src/providers/llamacpp/console/client.ts +6 -0
  68. package/src/providers/llamacpp/console/models-panel.ts +120 -0
  69. package/src/providers/llamacpp/console/runtime-panel.ts +173 -0
  70. package/src/providers/llamacpp/console/server.ts +153 -0
  71. package/src/providers/llamacpp/index.ts +95 -0
  72. package/src/providers/llamacpp/native.ts +51 -0
  73. package/src/providers/llamacpp/options.ts +117 -0
  74. package/src/providers/llamacpp/runtime-store.ts +139 -0
  75. package/src/providers/llamacpp/runtime.ts +214 -0
  76. package/src/providers/llamacpp/server.ts +286 -0
  77. package/src/providers/llamacpp/strings.ts +230 -0
  78. package/src/providers/openai-responses-compat/config.ts +20 -0
  79. package/src/providers/openai-responses-compat/index.ts +89 -0
  80. package/src/providers/openai-responses-compat/native.ts +127 -0
  81. package/src/providers/openai-responses-compat/strings.ts +19 -0
  82. package/src/providers/pricebook.ts +75 -0
  83. package/src/providers/registry.ts +137 -0
  84. package/src/providers/strings.ts +71 -0
  85. package/src/providers/transport/chat.ts +156 -0
  86. package/src/providers/transport/errors.ts +48 -0
  87. package/src/providers/transport/history.ts +68 -0
  88. package/src/providers/transport/native-input.ts +83 -0
  89. package/src/providers/transport/native-types.ts +13 -0
  90. package/src/providers/transport/response-assembly.ts +191 -0
  91. package/src/providers/transport/response-http.ts +196 -0
  92. package/src/providers/transport/response-meters.ts +27 -0
  93. package/src/providers/transport/responses-input.ts +56 -0
  94. package/src/web/README.md +84 -0
  95. package/src/web/client/console-pages/builtins/llm-settings/panel.ts +625 -0
  96. package/src/web/client/console-pages/builtins/llm-settings/pricing-panel.ts +168 -0
  97. package/src/web/client/console-pages/builtins/llm-settings/strings.ts +213 -0
  98. package/src/web/client/console-pages/builtins.ts +10 -0
  99. package/src/web/client/console-pages/context.ts +135 -0
  100. package/src/web/client/console-pages/host.ts +633 -0
  101. package/src/web/client/console-pages/loader.ts +132 -0
  102. package/src/web/client/console-pages/strings.ts +90 -0
  103. package/src/web/client/console-pages/tools/strings.ts +49 -0
  104. package/src/web/client/console-pages/tools/view.ts +215 -0
  105. package/src/web/client/core/api.ts +200 -0
  106. package/src/web/client/core/language.ts +56 -0
  107. package/src/web/client/core/lifecycle.ts +132 -0
  108. package/src/web/client/core/router.ts +199 -0
  109. package/src/web/client/core/stream.ts +157 -0
  110. package/src/web/client/core/websocket.ts +82 -0
  111. package/src/web/client/features/appearance/index.ts +333 -0
  112. package/src/web/client/features/appearance/strings.ts +87 -0
  113. package/src/web/client/features/config/index.ts +11 -0
  114. package/src/web/client/features/config/strings.ts +41 -0
  115. package/src/web/client/features/config/view.ts +428 -0
  116. package/src/web/client/features/core/events.ts +197 -0
  117. package/src/web/client/features/core/index.ts +254 -0
  118. package/src/web/client/features/core/run.ts +56 -0
  119. package/src/web/client/features/core/runlog.ts +214 -0
  120. package/src/web/client/features/core/sessions.ts +53 -0
  121. package/src/web/client/features/core/strings.ts +157 -0
  122. package/src/web/client/features/extensions/index.ts +422 -0
  123. package/src/web/client/features/extensions/strings.ts +164 -0
  124. package/src/web/client/features/feature.ts +93 -0
  125. package/src/web/client/features/live/context.ts +280 -0
  126. package/src/web/client/features/live/fork.ts +156 -0
  127. package/src/web/client/features/live/index.ts +392 -0
  128. package/src/web/client/features/live/onboarding.ts +113 -0
  129. package/src/web/client/features/live/protocol.ts +122 -0
  130. package/src/web/client/features/live/sessions.ts +71 -0
  131. package/src/web/client/features/live/status.ts +68 -0
  132. package/src/web/client/features/live/strings.ts +252 -0
  133. package/src/web/client/features/live/timeline.ts +590 -0
  134. package/src/web/client/features/prompts/editor.ts +292 -0
  135. package/src/web/client/features/prompts/index.ts +346 -0
  136. package/src/web/client/features/prompts/strings.ts +136 -0
  137. package/src/web/client/features/prompts/view.ts +256 -0
  138. package/src/web/client/features/providers/index.ts +100 -0
  139. package/src/web/client/features/providers/strings.ts +23 -0
  140. package/src/web/client/features/settings/general.ts +28 -0
  141. package/src/web/client/features/settings/index.ts +82 -0
  142. package/src/web/client/features/settings/strings.ts +33 -0
  143. package/src/web/client/features/storage/index.ts +1 -0
  144. package/src/web/client/features/storage/strings.ts +45 -0
  145. package/src/web/client/features/storage/view.ts +154 -0
  146. package/src/web/client/features/usage/chart.ts +486 -0
  147. package/src/web/client/features/usage/index.ts +407 -0
  148. package/src/web/client/features/usage/labels.ts +46 -0
  149. package/src/web/client/features/usage/range.ts +36 -0
  150. package/src/web/client/features/usage/state.ts +37 -0
  151. package/src/web/client/features/usage/strings.ts +191 -0
  152. package/src/web/client/features/usage/tooltip.ts +86 -0
  153. package/src/web/client/features/usage/types.ts +96 -0
  154. package/src/web/client/features/worlds/index.ts +424 -0
  155. package/src/web/client/features/worlds/strings.ts +121 -0
  156. package/src/web/client/main.ts +273 -0
  157. package/src/web/client/shell/avatar.ts +233 -0
  158. package/src/web/client/shell/index.ts +549 -0
  159. package/src/web/client/shell/strings.ts +89 -0
  160. package/src/web/client/theme/handoff.ts +43 -0
  161. package/src/web/client/theme/palette.ts +109 -0
  162. package/src/web/client/theme/registry.ts +130 -0
  163. package/src/web/client/theme/storage.ts +72 -0
  164. package/src/web/client/theme/strings.ts +123 -0
  165. package/src/web/client/theme/studio.ts +366 -0
  166. package/src/web/client/ui/actions.ts +109 -0
  167. package/src/web/client/ui/data.ts +185 -0
  168. package/src/web/client/ui/dom.ts +23 -0
  169. package/src/web/client/ui/fields.ts +191 -0
  170. package/src/web/client/ui/format.ts +75 -0
  171. package/src/web/client/ui/icons.ts +183 -0
  172. package/src/web/client/ui/images.ts +91 -0
  173. package/src/web/client/ui/index.ts +89 -0
  174. package/src/web/client/ui/lamp.ts +125 -0
  175. package/src/web/client/ui/log.ts +98 -0
  176. package/src/web/client/ui/overlay.ts +184 -0
  177. package/src/web/client/ui/page.ts +8 -0
  178. package/src/web/client/ui/prompt-input.tsx +311 -0
  179. package/src/web/client/ui/sheet.ts +131 -0
  180. package/src/web/client/ui/strings.ts +71 -0
  181. package/src/web/console-pages.ts +367 -0
  182. package/src/web/files.ts +107 -0
  183. package/src/web/path-picker.ts +296 -0
  184. package/src/web/public/index.html +17 -0
  185. package/src/web/public/styles.css +1715 -0
  186. package/src/web/server.ts +1883 -0
  187. package/src/web/shared/client-panel.ts +603 -0
  188. package/src/web/shared/console-protocol.ts +560 -0
  189. package/src/web/shared/css.d.ts +1 -0
  190. package/src/web/shared/path-picker.ts +23 -0
  191. package/src/web/shared/theme.ts +256 -0
  192. package/src/web/theme-store.ts +37 -0
  193. package/src/world.ts +449 -0
  194. package/src/worlds/bilibili/ENV_PROMPT.md +5 -0
  195. package/src/worlds/bilibili/README.md +176 -0
  196. package/src/worlds/bilibili/audience-admission.ts +995 -0
  197. package/src/worlds/bilibili/client.ts +528 -0
  198. package/src/worlds/bilibili/coalescing-buffer.ts +125 -0
  199. package/src/worlds/bilibili/config.ts +185 -0
  200. package/src/worlds/bilibili/console/client.ts +202 -0
  201. package/src/worlds/bilibili/definition.ts +35 -0
  202. package/src/worlds/bilibili/gift-frame.ts +131 -0
  203. package/src/worlds/bilibili/normalize.ts +452 -0
  204. package/src/worlds/bilibili/overlay/announcement.ts +76 -0
  205. package/src/worlds/bilibili/overlay/assets.ts +82 -0
  206. package/src/worlds/bilibili/overlay/model.ts +494 -0
  207. package/src/worlds/bilibili/overlay/project.ts +202 -0
  208. package/src/worlds/bilibili/overlay/server.ts +286 -0
  209. package/src/worlds/bilibili/overlay/types.ts +217 -0
  210. package/src/worlds/bilibili/overlay/web/app.js +690 -0
  211. package/src/worlds/bilibili/overlay/web/editor.css +297 -0
  212. package/src/worlds/bilibili/overlay/web/editor.html +102 -0
  213. package/src/worlds/bilibili/overlay/web/editor.js +2116 -0
  214. package/src/worlds/bilibili/overlay/web/overlay.html +13 -0
  215. package/src/worlds/bilibili/overlay/web/styles.css +258 -0
  216. package/src/worlds/bilibili/protobuf.ts +123 -0
  217. package/src/worlds/bilibili/wire.ts +94 -0
  218. package/src/worlds/bilibili/world.ts +1389 -0
  219. package/src/worlds/console-fixture/ENV_PROMPT.md +1 -0
  220. package/src/worlds/console-fixture/console/client.ts +72 -0
  221. package/src/worlds/console-fixture/world.ts +73 -0
  222. package/src/worlds/index.ts +15 -0
  223. package/src/worlds/minecraft/ENV_PROMPT.md +32 -0
  224. package/src/worlds/minecraft/ENV_PROMPT_CAMERA.md +2 -0
  225. package/src/worlds/minecraft/LICENSE-mineflayer-pathfinder.txt +21 -0
  226. package/src/worlds/minecraft/README.md +917 -0
  227. package/src/worlds/minecraft/blueprint-plan.ts +915 -0
  228. package/src/worlds/minecraft/blueprint-registry.ts +566 -0
  229. package/src/worlds/minecraft/blueprint-repair.ts +236 -0
  230. package/src/worlds/minecraft/blueprint-resource.ts +272 -0
  231. package/src/worlds/minecraft/blueprint.ts +492 -0
  232. package/src/worlds/minecraft/body-lease.ts +340 -0
  233. package/src/worlds/minecraft/bridge.ts +749 -0
  234. package/src/worlds/minecraft/check.ts +651 -0
  235. package/src/worlds/minecraft/chests.ts +292 -0
  236. package/src/worlds/minecraft/client-launch.ts +272 -0
  237. package/src/worlds/minecraft/client-options.ts +85 -0
  238. package/src/worlds/minecraft/client-skins.ts +164 -0
  239. package/src/worlds/minecraft/client-window.ps1 +91 -0
  240. package/src/worlds/minecraft/client.ts +423 -0
  241. package/src/worlds/minecraft/combat-context.ts +109 -0
  242. package/src/worlds/minecraft/combat.ts +1552 -0
  243. package/src/worlds/minecraft/config.ts +499 -0
  244. package/src/worlds/minecraft/console/access.ts +218 -0
  245. package/src/worlds/minecraft/console/client.ts +244 -0
  246. package/src/worlds/minecraft/console/log.ts +110 -0
  247. package/src/worlds/minecraft/console/mount.ts +420 -0
  248. package/src/worlds/minecraft/console/skin.ts +257 -0
  249. package/src/worlds/minecraft/console/style.css +154 -0
  250. package/src/worlds/minecraft/console/world.ts +449 -0
  251. package/src/worlds/minecraft/deaths.ts +135 -0
  252. package/src/worlds/minecraft/definition.ts +18 -0
  253. package/src/worlds/minecraft/engine-child.ts +274 -0
  254. package/src/worlds/minecraft/engine-ipc.ts +98 -0
  255. package/src/worlds/minecraft/entity-facts.ts +132 -0
  256. package/src/worlds/minecraft/escape.ts +407 -0
  257. package/src/worlds/minecraft/executor.ts +14494 -0
  258. package/src/worlds/minecraft/explored.ts +156 -0
  259. package/src/worlds/minecraft/geometry.ts +205 -0
  260. package/src/worlds/minecraft/goal-plan.ts +647 -0
  261. package/src/worlds/minecraft/item-break.ts +126 -0
  262. package/src/worlds/minecraft/item-facts.ts +146 -0
  263. package/src/worlds/minecraft/item-pick.ts +67 -0
  264. package/src/worlds/minecraft/level-dat.ts +176 -0
  265. package/src/worlds/minecraft/log.ts +132 -0
  266. package/src/worlds/minecraft/mineflayer-fixes.ts +1180 -0
  267. package/src/worlds/minecraft/names.ts +377 -0
  268. package/src/worlds/minecraft/pathfinder-lib.d.ts +13 -0
  269. package/src/worlds/minecraft/pathfinder-perf.ts +796 -0
  270. package/src/worlds/minecraft/piglin.ts +49 -0
  271. package/src/worlds/minecraft/policy.ts +547 -0
  272. package/src/worlds/minecraft/precheck.ts +655 -0
  273. package/src/worlds/minecraft/proxy.ts +445 -0
  274. package/src/worlds/minecraft/ranged.ts +635 -0
  275. package/src/worlds/minecraft/readouts.ts +102 -0
  276. package/src/worlds/minecraft/round.ts +26 -0
  277. package/src/worlds/minecraft/search-observation.ts +92 -0
  278. package/src/worlds/minecraft/server-config.ts +467 -0
  279. package/src/worlds/minecraft/server.ts +658 -0
  280. package/src/worlds/minecraft/show.ts +84 -0
  281. package/src/worlds/minecraft/skills.ts +1763 -0
  282. package/src/worlds/minecraft/terrain.ts +1735 -0
  283. package/src/worlds/minecraft/window.ts +96 -0
  284. package/src/worlds/minecraft/works.ts +179 -0
  285. package/src/worlds/minecraft/world.ts +6438 -0
  286. package/src/worlds/qq/ENV_PROMPT.md +5 -0
  287. package/src/worlds/qq/config.ts +108 -0
  288. package/src/worlds/qq/console/client.ts +126 -0
  289. package/src/worlds/qq/console/events.ts +155 -0
  290. package/src/worlds/qq/console/gate.ts +232 -0
  291. package/src/worlds/qq/console/roster.ts +203 -0
  292. package/src/worlds/qq/console/style.css +65 -0
  293. package/src/worlds/qq/conversation.ts +28 -0
  294. package/src/worlds/qq/definition.ts +62 -0
  295. package/src/worlds/qq/driver.ts +375 -0
  296. package/src/worlds/qq/history-tools.ts +209 -0
  297. package/src/worlds/qq/normalize.ts +323 -0
  298. package/src/worlds/qq/qface-map.ts +282 -0
  299. package/src/worlds/qq/vision-prompt.ts +27 -0
  300. package/src/worlds/qq/vision.ts +642 -0
  301. package/src/worlds/qq/vlm.ts +142 -0
  302. package/src/worlds/qq/world.ts +1503 -0
  303. package/src/worlds/terminal/ENV_PROMPT.md +11 -0
  304. package/src/worlds/terminal/config.ts +9 -0
  305. package/src/worlds/terminal/definition.ts +14 -0
  306. package/src/worlds/terminal/world.ts +700 -0
  307. package/src/worlds/websearch/ENV_PROMPT.md +1 -0
  308. package/src/worlds/websearch/brave-client.ts +261 -0
  309. package/src/worlds/websearch/config.ts +75 -0
  310. package/src/worlds/websearch/definition.ts +20 -0
  311. package/src/worlds/websearch/world.ts +171 -0
@@ -0,0 +1,443 @@
1
+ import type {
2
+ CoreConfig,
3
+ LLMProviderEntry,
4
+ ModelSpec,
5
+ ConfigGroup,
6
+ ConfigValues,
7
+ } from '../../core/types.ts';
8
+ import {
9
+ coerceGroupValues,
10
+ getByPath,
11
+ readGroupValues,
12
+ setByPath,
13
+ } from '../../core/config-schema.ts';
14
+ import { existsSync, mkdirSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
15
+ import { join } from 'node:path';
16
+ import { updateJsonObject } from '../../config-file.ts';
17
+ import type { Language } from '../../core/language.ts';
18
+ import type { ConsoleLamp, ConsolePageContribution } from '../../web/shared/console-protocol.ts';
19
+ import type { ConsolePageSource } from '../../web/console-pages.ts';
20
+ import { providerModules, type ProviderRegistry } from '../registry.ts';
21
+ import type { ProviderAvailability, ProviderModule } from '../base.ts';
22
+ import { endpointAvailability, validateEntry } from '../configuration.ts';
23
+ import { quotePrices, validatePrices, type PriceDefinition } from '../pricebook.ts';
24
+ import { GenerationError } from '../../core/generation.ts';
25
+ import { readTextFile } from '../../core/util.ts';
26
+ import { responseRequest } from '../../protocol/open-responses/context-helpers.ts';
27
+ import { record } from '../../protocol/open-responses/context.ts';
28
+ import { text } from './strings.ts';
29
+ import type { ProviderConsoleHost } from './types.ts';
30
+ import { connectionGroup } from './config.ts';
31
+
32
+ export type SecretStatus = 'env' | 'file' | 'none';
33
+
34
+ /** Output token limit used by the connectivity probe. */
35
+ const PROBE_MAX_OUTPUT_TOKENS = 256;
36
+
37
+ /** 从磁盘加载的密钥变量名按字面匹配。 */
38
+ function escapeRegExp(s: string): string {
39
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
40
+ }
41
+
42
+ /** Console entry points receive the request language; callers that omit it use Chinese. */
43
+ export class ProviderSettings {
44
+ constructor(
45
+ private readonly config: CoreConfig,
46
+ private readonly registry: ProviderRegistry,
47
+ /** 当前部署的 config.json,用于保存 activeProvider。 */
48
+ private readonly file: string,
49
+ /** 共享端点目录,配置写入各端点的 config.json。 */
50
+ private readonly providersDir: string,
51
+ private readonly modules: readonly ProviderModule[] = providerModules,
52
+ ) {
53
+ for (const [name, entry] of Object.entries(config.providers))
54
+ config.providers[name] =
55
+ this.modules
56
+ .find((module) => module.id === entry.kind)
57
+ ?.normalize?.(structuredClone(entry)) ?? entry;
58
+ }
59
+
60
+ private module(kind: string): ProviderModule {
61
+ const module = this.modules.find((module) => module.id === kind);
62
+ if (!module) throw new Error(`Unknown provider module: ${kind}`);
63
+ return module;
64
+ }
65
+
66
+ private entries(module: ProviderModule) {
67
+ return Object.entries(this.config.providers)
68
+ .filter(([, entry]) => entry.kind === module.id)
69
+ .map(([name, entry]) => ({ name, entry }));
70
+ }
71
+ private declaredGroups(language: Language) {
72
+ return this.modules.flatMap((module) =>
73
+ this.entries(module).flatMap(({ name, entry }) =>
74
+ [connectionGroup(name, entry, language), ...(module.config?.(name, entry, language) ?? [])]
75
+ .map((group) => ({ name, group })),
76
+ ),
77
+ );
78
+ }
79
+ groups(language: Language = 'zh'): ConfigGroup[] {
80
+ return this.declaredGroups(language).map(({ group }) => group);
81
+ }
82
+
83
+ values(groupId: string, language: Language = 'zh'): ConfigValues {
84
+ const { name, group } = this.declaredGroups(language).find((value) => value.group.id === groupId)!;
85
+ const prefix = `providers.${name}.`;
86
+ return readGroupValues(this.config, group, (path) =>
87
+ getByPath(
88
+ this.config.providers[name] as unknown as Record<string, unknown>,
89
+ path.slice(prefix.length),
90
+ ),
91
+ );
92
+ }
93
+
94
+ setConfig(groupId: string, values: ConfigValues, language: Language = 'zh'): string {
95
+ const S = text(language);
96
+ const declared = this.declaredGroups(language).find((value) => value.group.id === groupId);
97
+ if (!declared) throw new Error(S.unknownGroup);
98
+ const { name, group } = declared;
99
+ const coerced = coerceGroupValues(group, values, language);
100
+ if ('error' in coerced) throw new Error(coerced.error);
101
+ const next = structuredClone(this.config.providers[name]);
102
+ const prefix = `providers.${name}.`;
103
+ for (const [path, value] of Object.entries(coerced.values)) {
104
+ if (!path.startsWith(prefix)) throw new Error(S.groupOutOfScope);
105
+ setByPath(next as unknown as Record<string, unknown>, path.slice(prefix.length), value);
106
+ }
107
+ this.persist(name, validateEntry(this.module(next.kind), next, language), this.config.activeProvider);
108
+ return S.saved;
109
+ }
110
+
111
+ /** 端点配置写入共享目录;activeProvider 写入当前部署的 config.json。 */
112
+ private persist(name: string, entry: LLMProviderEntry, activeProvider: string): void {
113
+ const next = structuredClone(entry);
114
+ updateJsonObject(this.file, (raw) => {
115
+ raw.activeProvider = activeProvider;
116
+ raw.providerSchemaVersion = 3;
117
+ });
118
+ const dir = join(this.providersDir, name);
119
+ mkdirSync(dir, { recursive: true });
120
+ updateJsonObject(join(dir, 'config.json'), (raw) => {
121
+ for (const key of Object.keys(raw)) delete raw[key];
122
+ for (const [key, value] of Object.entries(next)) raw[key] = value;
123
+ });
124
+ this.config.providers = { ...this.config.providers, [name]: next };
125
+ this.config.activeProvider = activeProvider;
126
+ this.config.providerSchemaVersion = 3;
127
+ // 端点 .env 的内容按 provider 实例缓存。
128
+ this.registry.invalidate(name);
129
+ }
130
+
131
+ save(name: string, entry: LLMProviderEntry, language: Language = 'zh'): void {
132
+ const module = this.module(entry.kind);
133
+ const prior = this.config.providers[name];
134
+ if (prior && prior.kind !== entry.kind && this.modules.some((m) => m.id === prior.kind))
135
+ throw new Error(text(language).kindChange);
136
+ this.persist(name, validateEntry(module, entry, language), this.config.activeProvider);
137
+ }
138
+
139
+ /** 设为当前端点前,要求端点具有有效的模型配置。 */
140
+ activate(name: string, spec?: ModelSpec, language: Language = 'zh'): void {
141
+ const S = text(language);
142
+ const entry = this.config.providers[name];
143
+ if (!entry) throw new Error(S.unknownInstance);
144
+ const requested = { ...entry, ...(spec ? { spec } : {}) };
145
+ if (!requested.spec) throw new Error(S.specRequired);
146
+ const next = validateEntry(this.module(entry.kind), requested, language);
147
+ this.persist(name, next, name);
148
+ }
149
+
150
+ /** 删除端点及其整个目录;当前活跃端点不能删除。 */
151
+ delete(name: string, language: Language = 'zh'): void {
152
+ const S = text(language);
153
+ if (!this.config.providers[name]) throw new Error(S.unknownInstance);
154
+ if (name === this.config.activeProvider) throw new Error(S.deleteActive);
155
+ this.registry.invalidate(name);
156
+ const { [name]: _dropped, ...rest } = this.config.providers;
157
+ this.config.providers = rest;
158
+ rmSync(join(this.providersDir, name), { recursive: true, force: true });
159
+ }
160
+
161
+ /** 密钥值优先来自进程环境,其次为端点 .env;未配置变量名时没有来源。 */
162
+ secretStatus(name: string, entry: LLMProviderEntry): SecretStatus {
163
+ if (!entry.secret) return 'none';
164
+ if (process.env[entry.secret]) return 'env';
165
+ const file = join(this.providersDir, name, '.env');
166
+ if (!existsSync(file)) return 'none';
167
+ return new RegExp(`^\\s*${escapeRegExp(entry.secret)}\\s*=\\s*\\S+`, 'm').test(readTextFile(file)) ? 'file' : 'none';
168
+ }
169
+
170
+ /** 把密钥值写进端点目录的 `.env`(同名行覆盖),并让实例重建以读到它。 */
171
+ setSecret(name: string, value: string, language: Language = 'zh'): SecretStatus {
172
+ const S = text(language);
173
+ const entry = this.config.providers[name];
174
+ if (!entry) throw new Error(S.unknownInstance);
175
+ if (!entry.secret) throw new Error(S.secretNameRequired);
176
+ if (!value.trim() || /\s/.test(value)) throw new Error(S.secretValueInvalid);
177
+ const dir = join(this.providersDir, name);
178
+ mkdirSync(dir, { recursive: true });
179
+ const file = join(dir, '.env');
180
+ const line = `${entry.secret}=${value.trim()}`;
181
+ const current = existsSync(file) ? readTextFile(file) : '';
182
+ const pattern = new RegExp(`^\\s*${escapeRegExp(entry.secret)}\\s*=.*$`, 'm');
183
+ const next = pattern.test(current)
184
+ ? current.replace(pattern, line)
185
+ : current + (current && !current.endsWith('\n') ? '\n' : '') + line + '\n';
186
+ writeFileSync(file, next, 'utf8');
187
+ this.registry.invalidate(name);
188
+ return this.secretStatus(name, entry);
189
+ }
190
+
191
+ private assertNewName(name: string, language: Language): void {
192
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(name)) throw new Error(text(language).nameFormat);
193
+ const existing = this.config.providers[name];
194
+ // kind 未注册的条目可被同名新端点覆盖,沿用现有目录和密钥。
195
+ if (existing && this.modules.some((module) => module.id === existing.kind)) throw new Error(text(language).nameTaken);
196
+ }
197
+
198
+ /** 端点目录里除 `config.json` 外还有什么(密钥、授权状态),删前给操作者看。 */
199
+ private directoryExtras(name: string): string[] {
200
+ const dir = join(this.providersDir, name);
201
+ if (!existsSync(dir)) return [];
202
+ return readdirSync(dir).filter((file) => file !== 'config.json');
203
+ }
204
+
205
+ private async probe(name: string, language: Language) {
206
+ const S = text(language);
207
+ const entry = this.config.providers[name];
208
+ if (!entry) throw new Error(S.unknownInstance);
209
+ if (!entry.spec) throw new Error(S.specRequired);
210
+ const request = {
211
+ ...responseRequest(entry.spec, [record({ type: 'message', role: 'user', content: 'ping' })]),
212
+ max_output_tokens: Math.min(entry.spec.maxTokens ?? PROBE_MAX_OUTPUT_TOKENS, PROBE_MAX_OUTPUT_TOKENS),
213
+ };
214
+ const started = Date.now();
215
+ try {
216
+ const generation = await this.registry.bind(name).respond(request, { diagnostic: true, nativeSpec: entry.spec, role: 'probe' });
217
+ const attempt = generation.attempts.at(-1);
218
+ return {
219
+ ok: true,
220
+ status: attempt?.status ?? null,
221
+ elapsedMs: attempt?.elapsedMs ?? Date.now() - started,
222
+ model: generation.response.model,
223
+ usage: attempt ? {
224
+ input: attempt.meters.input, cachedInput: attempt.meters.cachedInput,
225
+ output: attempt.meters.output, reasoning: attempt.meters.reasoning,
226
+ } : undefined,
227
+ encryptedReasoning: generation.response.output.some((item) => item.type === 'reasoning' && Boolean(item.encrypted_content)),
228
+ charges: (attempt?.charges ?? []).map((charge) => ({ currency: charge.quote.currency, amount: charge.amount })),
229
+ };
230
+ } catch (error) {
231
+ const status = error instanceof GenerationError ? error.status : null;
232
+ const hint = status === 404 ? S.probeNoResponses
233
+ : status === 401 || status === 403 ? S.probeAuth
234
+ : status === 0 || status === null ? S.probeUnreachable
235
+ : undefined;
236
+ return {
237
+ ok: false,
238
+ status,
239
+ elapsedMs: Date.now() - started,
240
+ error: error instanceof Error ? error.message : String(error),
241
+ ...(hint ? { hint } : {}),
242
+ };
243
+ }
244
+ }
245
+
246
+ sources() {
247
+ return this.modules.map((module) => ({
248
+ id: `llm:${module.id}`,
249
+ contribute: (language) => this.contribute(module, language),
250
+ })) satisfies ConsolePageSource[];
251
+ }
252
+
253
+ availability(name: string, language: Language = 'zh'): ProviderAvailability {
254
+ const entry = this.config.providers[name];
255
+ if (!entry) return { ready: false, reason: text(language).unknownInstance };
256
+ return endpointAvailability(
257
+ this.module(entry.kind),
258
+ name,
259
+ entry,
260
+ this.secretStatus(name, entry) !== 'none',
261
+ language,
262
+ );
263
+ }
264
+
265
+ /**
266
+ * 汇总端点的本地可用性,附第一个可用端点或最后一个不可用原因。
267
+ */
268
+ private availableLamp(
269
+ entries: ReadonlyArray<{ name: string }>,
270
+ language: Language,
271
+ ): ConsoleLamp {
272
+ const S = text(language);
273
+ let reason = S.availableLampNone;
274
+ for (const { name } of entries) {
275
+ const state = this.availability(name, language);
276
+ if (state.ready) {
277
+ return { label: S.availableLamp, state: 'online', hint: S.availableLampReady(name) };
278
+ }
279
+ if (state.reason) reason = `${name}: ${state.reason}`;
280
+ }
281
+ return { label: S.availableLamp, state: 'offline', hint: reason };
282
+ }
283
+
284
+ /** 汇总所有模块的端点可用性。 */
285
+ providersLamp(language: Language = 'zh'): ConsoleLamp {
286
+ return this.availableLamp(
287
+ this.modules.flatMap((module) => this.entries(module)),
288
+ language,
289
+ );
290
+ }
291
+
292
+ private contribute(module: ProviderModule, language: Language): ConsolePageContribution {
293
+ const S = text(language);
294
+ const entries = this.entries(module);
295
+ const host: ProviderConsoleHost = {
296
+ language,
297
+ entries: () => this.entries(module),
298
+ instance: (name) => {
299
+ if (!this.entries(module).some((value) => value.name === name))
300
+ throw new Error(S.foreignInstance);
301
+ return this.registry.resolve(name);
302
+ },
303
+ save: (name, entry) => {
304
+ if (entry.kind !== module.id) throw new Error(S.foreignInstance);
305
+ this.save(name, entry, language);
306
+ },
307
+ };
308
+ const extra = module.console?.(host) ?? {};
309
+ return {
310
+ ...extra,
311
+ id: `llm:${module.id}`,
312
+ kind: 'llm',
313
+ label: module.title,
314
+ availability: 'active',
315
+ lamps: [
316
+ {
317
+ label: S.activeInstanceLamp,
318
+ state: entries.some((value) => value.name === this.config.activeProvider)
319
+ ? 'online'
320
+ : 'offline',
321
+ },
322
+ this.availableLamp(this.entries(module), language),
323
+ ...(extra.lamps ?? []),
324
+ ],
325
+ badges: [{ label: S.instancesBadge, value: String(entries.length) }, ...(extra.badges ?? [])],
326
+ panels: [
327
+ {
328
+ id: 'settings',
329
+ title: S.settingsPanel,
330
+ description: S.settingsPanelDescription,
331
+ getMethods: ['state'],
332
+ // 使用控制台内建端点面板,操作由下方 invoke 提供。
333
+ builtin: 'llm-settings',
334
+ },
335
+ ...(extra.panels ?? []),
336
+ ],
337
+ config: extra.config ?? entries.flatMap(
338
+ ({ name, entry }) => module.config?.(name, entry, language) ?? [],
339
+ ),
340
+ invoke: async (panel, method, args) => {
341
+ if (panel !== 'settings') {
342
+ if (!extra.invoke) throw new Error(S.unknownPanel);
343
+ return extra.invoke(panel, method, args);
344
+ }
345
+ const at = {
346
+ startedAt: new Date().toISOString(),
347
+ requestedServiceTier: null as string | null,
348
+ };
349
+ if (method === 'state') {
350
+ const localized = module.localize?.(language) ?? {};
351
+ return {
352
+ active: this.config.activeProvider,
353
+ reasoningTiers: localized.reasoningTiers ?? module.reasoningTiers,
354
+ serviceTiers: localized.serviceTiers ?? module.serviceTiers,
355
+ temperatureNote: localized.temperatureNote ?? module.temperatureNote,
356
+ baseUrlSuggestions: module.baseUrlSuggestions ?? [],
357
+ effortSuggestions: module.reasoningTiers.length ? [] : module.effortSuggestions ?? [],
358
+ instances: this.entries(module).map(({ name, entry }) => ({
359
+ name,
360
+ entry,
361
+ config: this.declaredGroups(language).filter((item) => item.name === name)
362
+ .map(({ group }) => ({ group, values: this.values(group.id, language) })),
363
+ secretConfigured: this.secretStatus(name, entry),
364
+ quotes: (entry.spec ? [entry.spec] : []).map((spec) => ({
365
+ model: spec.model,
366
+ quotes: quotePrices(
367
+ entry,
368
+ { model: spec.model },
369
+ { ...at, requestedServiceTier: entry.serviceTier ?? null },
370
+ module.prices?.(
371
+ entry,
372
+ { model: spec.model },
373
+ { ...at, requestedServiceTier: entry.serviceTier ?? null },
374
+ ) ?? [],
375
+ ),
376
+ })),
377
+ })),
378
+ };
379
+ }
380
+ const [raw] = args;
381
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw))
382
+ throw new Error(S.bodyRequired);
383
+ const body = raw as Record<string, unknown>;
384
+ if (typeof body.name !== 'string') throw new Error(S.nameRequired);
385
+ const name = body.name;
386
+ if (method === 'create') {
387
+ this.assertNewName(name, language);
388
+ // 未设置端点价目覆盖,报价仍可来自模块。
389
+ this.save(name, {
390
+ kind: module.id,
391
+ baseUrl: String(body.baseUrl || module.defaultBaseUrl || ''),
392
+ pricing: [],
393
+ }, language);
394
+ return { ok: true };
395
+ }
396
+ const entry = this.config.providers[name];
397
+ if (!entry || entry.kind !== module.id) throw new Error(S.foreignInstance);
398
+ if (method === 'activate') this.activate(name, body.spec as ModelSpec | undefined, language);
399
+ else if (method === 'save') {
400
+ // 面板一格一存,所以给到哪几个键就只并哪几个。
401
+ const next: LLMProviderEntry = { ...entry };
402
+ if (body.spec !== undefined) {
403
+ if (!body.spec || typeof body.spec !== 'object' || Array.isArray(body.spec))
404
+ throw new Error(S.specRequired);
405
+ next.spec = body.spec as ModelSpec;
406
+ }
407
+ if (body.pricing !== undefined) next.pricing = validatePrices(body.pricing, language);
408
+ if (typeof body.serviceTier === 'string') next.serviceTier = body.serviceTier;
409
+ if (typeof body.baseUrl === 'string') next.baseUrl = body.baseUrl.trim();
410
+ if (typeof body.secret === 'string') {
411
+ if (body.secret.trim()) next.secret = body.secret.trim();
412
+ else delete next.secret;
413
+ }
414
+ if (typeof body.multimodal === 'boolean') next.multimodal = body.multimodal;
415
+ if (body.options !== undefined) {
416
+ if (!body.options || typeof body.options !== 'object' || Array.isArray(body.options))
417
+ throw new Error(S.optionsObject);
418
+ next.options = body.options as Record<string, unknown>;
419
+ }
420
+ this.save(name, next, language);
421
+ } else if (method === 'delete') {
422
+ this.delete(name, language);
423
+ } else if (method === 'duplicate') {
424
+ if (typeof body.as !== 'string') throw new Error(S.nameRequired);
425
+ this.assertNewName(body.as, language);
426
+ this.save(body.as, structuredClone(entry), language);
427
+ } else if (method === 'setSecret') {
428
+ if (typeof body.value !== 'string') throw new Error(S.secretValueInvalid);
429
+ return { secretConfigured: this.setSecret(name, body.value, language) };
430
+ } else if (method === 'models') {
431
+ const instance = this.registry.resolve(name);
432
+ if (!instance.listModels) throw new Error(S.modelsUnsupported);
433
+ return { models: await instance.listModels() };
434
+ } else if (method === 'probe') {
435
+ return this.probe(name, language);
436
+ } else if (method === 'extras') {
437
+ return { files: this.directoryExtras(name) };
438
+ } else throw new Error(S.unknownMethod);
439
+ return { ok: true };
440
+ },
441
+ };
442
+ }
443
+ }
@@ -0,0 +1,74 @@
1
+ import { pick, type Language } from '../../core/language.ts';
2
+
3
+ /** Server side: ConfigGroups, manifest labels and receipts built by `ProviderSettings`. */
4
+ const zh = {
5
+ connectionDescription: '请求绑定在开始时固定;修改后从下一次请求生效。',
6
+ baseUrl: '供应地址',
7
+ secret: '密钥环境变量',
8
+ secretDescription: '填写变量名;密钥内容由部署环境提供。',
9
+ multimodal: '启用图像输入',
10
+ activeInstanceLamp: '当前供应实例',
11
+ availableLamp: '可用端点',
12
+ availableLampNone: '没有可用端点',
13
+ availableLampReady: (name: string) => `${name} 可用`,
14
+ instancesBadge: '实例',
15
+ settingsPanel: '实例与模型',
16
+ settingsPanelDescription: "保存端点模型、价目及当前部署使用的端点。",
17
+ saved: '参数已保存;下一次请求生效。',
18
+ unknownGroup: '未知 Provider 参数组',
19
+ groupOutOfScope: 'Provider 参数组只能修改所属实例',
20
+ kindChange: '不能改变已有实例的模块类型',
21
+ unknownInstance: '未知供应实例',
22
+ foreignInstance: '供应实例不属于此模块',
23
+ unknownPanel: '未知 Provider 面板',
24
+ bodyRequired: '需要实例配置对象',
25
+ nameRequired: '需要实例名',
26
+ nameFormat: '实例名只能包含字母、数字、下划线与连字符',
27
+ nameTaken: '实例名已存在',
28
+ specRequired: '需要模型档对象',
29
+ unknownMethod: '未知 Provider 操作',
30
+ optionsObject: '原生参数必须是对象',
31
+ deleteActive: '当前供应实例不能删除;先启用别的实例',
32
+ secretNameRequired: '先填写密钥环境变量名,再写入密钥值',
33
+ secretValueInvalid: '密钥值不能为空或含空白',
34
+ modelsUnsupported: '此模块不提供模型列表',
35
+ probeNoResponses: "请求返回 404;请检查供应地址、端点路径及服务支持的 API。",
36
+ probeAuth: '鉴权失败:检查密钥变量名与密钥值',
37
+ probeUnreachable: "连接或响应流失败;请检查网络、供应地址与代理。",
38
+ };
39
+ const en: typeof zh = {
40
+ connectionDescription: 'Requests bind at start; changes apply from the next request.',
41
+ baseUrl: 'Provider URL',
42
+ secret: 'Secret environment variable',
43
+ secretDescription: 'Enter the variable name; the deployment environment supplies the secret.',
44
+ multimodal: 'Enable image input',
45
+ activeInstanceLamp: 'Active provider instance',
46
+ availableLamp: 'Usable endpoint',
47
+ availableLampNone: 'No usable endpoint',
48
+ availableLampReady: (name: string) => `${name} is usable`,
49
+ instancesBadge: 'Instances',
50
+ settingsPanel: 'Instances and models',
51
+ settingsPanelDescription: "Save the endpoint model, pricing and the active endpoint for this deployment.",
52
+ saved: 'Parameters saved; they apply from the next request.',
53
+ unknownGroup: 'Unknown provider parameter group',
54
+ groupOutOfScope: 'A provider parameter group can only modify its own instance',
55
+ kindChange: 'The module type of an existing instance cannot be changed',
56
+ unknownInstance: 'Unknown provider instance',
57
+ foreignInstance: 'The provider instance does not belong to this module',
58
+ unknownPanel: 'Unknown provider panel',
59
+ bodyRequired: 'An instance configuration object is required',
60
+ nameRequired: 'An instance name is required',
61
+ nameFormat: 'Instance names may only contain letters, digits, underscores and hyphens',
62
+ nameTaken: 'Instance name already exists',
63
+ specRequired: 'A model spec object is required',
64
+ unknownMethod: 'Unknown provider action',
65
+ optionsObject: 'Native options must be an object',
66
+ deleteActive: 'The active provider instance cannot be deleted; activate another instance first',
67
+ secretNameRequired: 'Set the secret environment variable name before writing a key value',
68
+ secretValueInvalid: 'The key value cannot be empty or contain whitespace',
69
+ modelsUnsupported: 'This module does not list models',
70
+ probeNoResponses: "The request returned 404; check the provider URL, endpoint path and supported APIs.",
71
+ probeAuth: 'Authentication failed: check the secret variable name and the key value',
72
+ probeUnreachable: "The connection or response stream failed; check the network, provider URL and proxy.",
73
+ };
74
+ export const text = (language: Language) => pick(language, { zh, en });
@@ -0,0 +1,11 @@
1
+ import type { LLMProviderEntry } from '../../core/types.ts';
2
+ import type { Language } from '../../core/language.ts';
3
+ import type { ProviderInstance } from '../base.ts';
4
+
5
+ export interface ProviderConsoleHost {
6
+ /** Console language for panel titles, receipts and error texts. */
7
+ readonly language: Language;
8
+ entries(): Array<{ name: string; entry: LLMProviderEntry }>;
9
+ instance(name: string): ProviderInstance;
10
+ save(name: string, entry: LLMProviderEntry): void;
11
+ }
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Download and unpack a release archive. ZIP input waits for pending output writes
3
+ * after each chunk; tar archives use a stream pipeline.
4
+ */
5
+ import { createReadStream, createWriteStream, mkdirSync, type WriteStream } from 'node:fs';
6
+ import { chmod, open, symlink } from 'node:fs/promises';
7
+ import { dirname, isAbsolute, join, normalize, sep } from 'node:path';
8
+ import { Readable, Transform } from 'node:stream';
9
+ import { pipeline } from 'node:stream/promises';
10
+ import { createGunzip } from 'node:zlib';
11
+ import { Unzip, UnzipInflate } from 'fflate';
12
+ import { extract } from 'tar-stream';
13
+
14
+ export interface DownloadOptions {
15
+ fetchImpl?: typeof fetch;
16
+ onProgress?: (done: number, total: number | null) => void;
17
+ signal?: AbortSignal;
18
+ }
19
+
20
+ export async function downloadFile(url: string, dest: string, options: DownloadOptions = {}): Promise<void> {
21
+ const fetchImpl = options.fetchImpl ?? fetch;
22
+ const res = await fetchImpl(url, { signal: options.signal, redirect: 'follow' });
23
+ if (!res.ok || !res.body) throw new Error(`HTTP ${res.status}`);
24
+ const length = Number(res.headers.get('content-length'));
25
+ const total = Number.isFinite(length) && length > 0 ? length : null;
26
+ let done = 0;
27
+ const counter = new Transform({
28
+ transform(chunk: Buffer, _encoding, callback) {
29
+ done += chunk.length;
30
+ options.onProgress?.(done, total);
31
+ callback(null, chunk);
32
+ },
33
+ });
34
+ mkdirSync(dirname(dest), { recursive: true });
35
+ await pipeline(Readable.fromWeb(res.body as import('node:stream/web').ReadableStream), counter, createWriteStream(dest));
36
+ }
37
+
38
+ /** Rejects paths that would escape `destDir`; returns the absolute target path. */
39
+ function safeTarget(destDir: string, entryName: string, stripComponents: number): string | null {
40
+ const parts = entryName.split(/[\\/]+/).filter((part) => part.length > 0);
41
+ if (parts.some((part) => part === '..') || isAbsolute(entryName)) throw new Error(`unsafe archive entry: ${entryName}`);
42
+ const kept = parts.slice(stripComponents);
43
+ if (kept.length === 0) return null;
44
+ const target = normalize(join(destDir, ...kept));
45
+ if (!target.startsWith(normalize(destDir) + sep) && target !== normalize(destDir)) throw new Error(`unsafe archive entry: ${entryName}`);
46
+ return target;
47
+ }
48
+
49
+ export async function extractArchive(
50
+ file: string,
51
+ format: 'zip' | 'tgz',
52
+ destDir: string,
53
+ stripComponents: number,
54
+ ): Promise<void> {
55
+ mkdirSync(destDir, { recursive: true });
56
+ if (format === 'zip') await extractZip(file, destDir, stripComponents);
57
+ else await extractTgz(file, destDir, stripComponents);
58
+ }
59
+
60
+ async function extractZip(file: string, destDir: string, stripComponents: number): Promise<void> {
61
+ const unzip = new Unzip();
62
+ unzip.register(UnzipInflate);
63
+ let failure: Error | null = null;
64
+ /** Output files with bytes still in flight; the read loop waits for them after every chunk. */
65
+ const active = new Set<{ out: WriteStream; done: Promise<void> }>();
66
+ unzip.onfile = (entry) => {
67
+ if (entry.name.endsWith('/')) return;
68
+ const target = safeTarget(destDir, entry.name, stripComponents);
69
+ if (!target) return;
70
+ mkdirSync(dirname(target), { recursive: true });
71
+ const out = createWriteStream(target);
72
+ const file = {
73
+ out,
74
+ done: new Promise<void>((resolve, reject) => out.once('finish', resolve).once('error', reject)),
75
+ };
76
+ active.add(file);
77
+ entry.ondata = (err, data, final) => {
78
+ if (err) {
79
+ failure = err;
80
+ out.destroy(err);
81
+ return;
82
+ }
83
+ if (data.length) out.write(Buffer.from(data.buffer, data.byteOffset, data.byteLength));
84
+ if (final) out.end();
85
+ };
86
+ entry.start();
87
+ };
88
+ /**
89
+ * Check current writableNeedDrain before waiting for drain.
90
+ * An ended stream is awaited through its completion promise, since drain may no longer fire.
91
+ */
92
+ const settle = async (): Promise<void> => {
93
+ for (const file of [...active]) {
94
+ if (file.out.writableEnded) {
95
+ await file.done;
96
+ active.delete(file);
97
+ } else if (file.out.writableNeedDrain) {
98
+ await new Promise<void>((resolve) => file.out.once('drain', () => resolve()));
99
+ }
100
+ }
101
+ };
102
+ const handle = await open(file, 'r');
103
+ try {
104
+ const chunk = Buffer.allocUnsafe(256 * 1024);
105
+ for (;;) {
106
+ const { bytesRead } = await handle.read(chunk, 0, chunk.length, null);
107
+ if (bytesRead === 0) break;
108
+ unzip.push(new Uint8Array(chunk.subarray(0, bytesRead)), false);
109
+ if (failure) throw failure;
110
+ await settle();
111
+ }
112
+ unzip.push(new Uint8Array(0), true);
113
+ } finally {
114
+ await handle.close();
115
+ }
116
+ if (failure) throw failure;
117
+ await Promise.all([...active].map((file) => file.done));
118
+ }
119
+
120
+ async function extractTgz(file: string, destDir: string, stripComponents: number): Promise<void> {
121
+ const tar = extract();
122
+ const links: Array<{ target: string; linkname: string }> = [];
123
+ tar.on('entry', (header, stream, next) => {
124
+ void (async () => {
125
+ const target = safeTarget(destDir, header.name, stripComponents);
126
+ if (!target) {
127
+ stream.resume();
128
+ return;
129
+ }
130
+ if (header.type === 'directory') {
131
+ mkdirSync(target, { recursive: true });
132
+ stream.resume();
133
+ } else if (header.type === 'symlink' && header.linkname) {
134
+ links.push({ target, linkname: header.linkname });
135
+ stream.resume();
136
+ } else if (header.type === 'file') {
137
+ mkdirSync(dirname(target), { recursive: true });
138
+ await pipeline(stream, createWriteStream(target));
139
+ if (header.mode !== undefined && process.platform !== 'win32') await chmod(target, header.mode & 0o777);
140
+ } else stream.resume();
141
+ })().then(() => next(), next);
142
+ });
143
+ await pipeline(createReadStream(file), createGunzip(), tar);
144
+ for (const link of links) {
145
+ mkdirSync(dirname(link.target), { recursive: true });
146
+ await symlink(link.linkname, link.target).catch(() => undefined);
147
+ }
148
+ }