dsh-horon 0.0.0-stage → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 guicaiyue
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,171 @@
1
- # Temporary Holding Version
1
+ # dsh-horon
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ 把 [Horon](https://github.com/Dataojitori/horon) 记忆图谱接进 **DeepSeek Harness(dsh)** 的插件:一个包同时提供宿主侧(`horon` 工具、生命周期钩子、控制台反向代理、设置页接口)和客户端侧(侧栏面板、设置页区块)。
4
+
5
+ > 本仓库原名 `horon-web`(包名 `@local/horon-web`),2026-11-06 改名为 `dsh-horon`。改名只改插件的标识、路由和包名;`horon` 工具名与 `horon-cli` / `horon-circuit` / `horon-plugin` 三个 skill 保持不变,已有记忆里的调用方式不受影响。
6
+
7
+ ## 它做了什么
8
+
9
+ 一行 loader 配置(`dsh-horon`)挂四件事:
10
+
11
+ 1. **`horon` 工具**(`harness.js`)—— 参数就是 Horon CLI 的参数列表,等价于在 Horon 仓库根目录跑 `python frontend/cli.py <args>`。命令和开关写在 `horon-cli` skill 里,这里不重复一遍 schema。
12
+ 2. **生命周期钩子** —— dsh 事件转成 Horon 的 `UserPromptSubmit` / `PreToolUse` / `PostToolUse` / `Stop`。**这里带的是通道,不是规则**:工具拦不拦由 Horon 库里的 guard 电路决定,见下面「拦截(guard)随安装生效吗」。
13
+ 3. **控制台**(`index.js` + `client.js`)—— dsh web 服务器上的 `/dsh-horon/` 反向代理,把 Horon 控制台塞进侧栏 iframe;它和 dsh 自己的路由走同一道 Host/Origin + 登录栅栏,所以跨机器打开 dsh 也能用。`/dsh-horon/_dsh/*` 是设置页用的状态、配置、预览、部署、日志接口。
14
+ 4. **skills 注册**(可选)—— 宿主读不到 Horon 仓库时(`artifact` / `docker` / `url` 模式),从本包的 `skills/` 注册 `horon-cli` / `horon-circuit` / `horon-plugin`;`artifact` 模式解包后也有 `app/.agents/skills`,两处内容同源(都来自锁定的 commit)。
15
+
16
+ ## 四种服务运行方式
17
+
18
+ `config.mode` 决定 Horon 服务从哪来(不写 `mode` 时:有 `url` 就是 `url`,否则 `spawn`):
19
+
20
+ | mode | 做法 | 关键配置 |
21
+ | --- | --- | --- |
22
+ | `artifact` | **推荐**。按 `horon.lock.json` 下载 CI 发布的构建物(后端 + 打包好的控制台 + 依赖轮子),校验 sha256、解包、离线装运行时,再当本机进程跑。不需要 Horon 仓库,也不需要 git / npm / Docker,只要 Python 3.12 | `artifactDir`、`artifactUrl`、`dataDir` |
23
+ | `docker` | **推荐**。服务是本机上的容器,镜像由 CI 按同一个 commit 构建并推到 Docker Hub;插件只 `docker pull`,不构建 | `dataDir`、`dbFile`、`envFile`、`tagPluginsDir`、`dockerBind`、`dockerPort`、`image`、`container` |
24
+ | `spawn` | 开发用。插件自己拉起本机克隆里的 `python -m uvicorn horon_web_app:app` | `root`、`python`、`host`、`port` |
25
+ | `url` | 服务跑在别处,插件只记地址,不启动也不部署 | `url` |
26
+
27
+ `artifact` 与 `docker` 共用 `dataDir`(默认 `~/.horon/docker`):数据库、`.env`、tag 插件都在那里,所以换方式、换版本都不丢记忆;构建物按 `<commit8>-<platform>` 解包,回滚就是把锁文件的 commit 改回去。
28
+
29
+ 服务本身没有鉴权,默认只绑回环地址;要跨机器用请自行加一层可信网络。
30
+
31
+ **首次安装没有 `mode`**:插件不启动任何东西,Horon 面板会先问「下载构建物 / 拉取 Docker 镜像」。
32
+ 本机已有 `~/horon` 克隆的机器视为已选择 `spawn`,升级不会被重新询问。
33
+
34
+ ## 安装
35
+
36
+ 上架后走市场或 npm:
37
+
38
+ ```bash
39
+ dsh plugin add dsh-horon # 或在 Web 插件市场里点安装
40
+ ```
41
+
42
+ 开发时仍可挂本地目录(`link:` 依赖,不进 npm):
43
+
44
+ ```jsonc
45
+ // ~/.dsh/profiles/web/package.json
46
+ {
47
+ "dependencies": {
48
+ "dsh-horon": "link:D:/projects/blog/dsh-plugins/dsh-horon"
49
+ },
50
+ "dsh": {
51
+ "profile": {
52
+ "bundles": ["dsh-horon"] // 必须列出,才会作为 bundle 加载
53
+ }
54
+ }
55
+ }
56
+ ```
57
+
58
+ `cordis.patch.yml` 里这一行不必写 `mode`(面板会问);想直接定死就写(设置页「设置 → Horon 服务」写的是同一个位置):
59
+
60
+ ```yaml
61
+ - id: dsh-horon
62
+ name: 'dsh-horon'
63
+ config:
64
+ mode: artifact # 或 docker / spawn / url
65
+ autostart: true
66
+ hooks: true
67
+ ```
68
+
69
+ 改完 profile 配置 dsh 会热重载;改本仓库的宿主侧代码(`index.js` / `harness.js` / `docker.js` / `artifact.js`)需要重启 dsh。
70
+
71
+ `artifact` 模式下的 `root` / `python` 不用写:解包目录和它自己的 venv 由插件决定。
72
+ `spawn` 模式的 `root` 默认 `~/horon`,解释器默认取 `<root>/.venv/...`。
73
+
74
+ ## 拦截(guard)随安装生效吗
75
+
76
+ **不。装上的是通道,不是策略。**
77
+
78
+ - 插件这一侧(`harness.js` 的 `tools/pre-execute`)只做一件事:把每次工具调用的名字和参数交给 Horon 的 `PreToolUse`,Horon 回 `deny` 才拦。
79
+ - 拦不拦由**数据库**决定:`tool_guards` 表里的规则(`tool` 正则 + `args_pattern` + `adapter`)指向一个 `guard` 概念,guard 的输入电路通电才放行,未通电或被抑制就拦。这些行是使用者在自己的库里一条条 `set <guard> tool_guard ...` 建出来的,**不在代码里、也不随包发布**。
80
+ - 同类还有两件也不随包走:个人 tag 插件(写时门禁,如「记忆入库」)放在 `tagPluginsDir`,组装器(`scripts/assemble.mjs`)的白名单只带 `state.py / action.py / result.py / exit.py` 四个系统插件;任何 `*.db` 也不进 git、不进镜像、不进构建物。
81
+ - 所以别人装完的默认状态是:hooks 开着,每次工具调用都问一次 Horon,库里没有规则 → `guard()` 直接放行,**零拦截**。工具名还是 dsh 的原样(`pwsh`、`edit`、`read`、`horon`),照着 Claude Code 写的规则在这里不匹配。
82
+
83
+ **想让规则跟着走**,把库里的东西显式带过去(读 `horon-circuit` / `horon-plugin` 说明书):
84
+
85
+ ```bash
86
+ horon list_concepts | grep -E '\((guard|logic|sensor)' # 看这个库里到底有几条电路
87
+ horon read_hook <guard 名或 ID> # 看它的 tool_guard 规则、on_fire 文本
88
+ horon set <guard 名> tool_guard 'pwsh' --args-pattern 'git\s+push' # 在目标库里重建同一条规则
89
+ ```
90
+
91
+ 电路之间的依赖(`AND` / `OR` / `CHAIN` / 抑制边)不会被一条 `tool_guard` 带过来,得按 `compile --target <guard>` 的依赖树逐个补;组装器也不复制 `tag_plugins` 里的个人插件,需要就手工拷进 `tagPluginsDir`(构建物模式下是 `<artifactDir>/<commit8>-<platform>/app/backend/tag_plugins`)。
92
+
93
+ **怎么当场确认它在不在跑**:设置页「Horon 服务」那行现在多一个「拦截链」读数——`已挂载 · 本次共拒绝 N · 网关失败 M`,鼠标悬停看说明;`/dsh-horon/_dsh/status` 的 `hooks` 字段是同一份数据。
94
+
95
+ **唯一无条件生效的**是系统提示里那段记忆策略(`MEMORY_POLICY`,`harness.js`,主会话注入、子代理不注入),它管的是「怎么写记忆」,不拦工具。
96
+
97
+ 拦截链是 **fail-open** 的:Horon 服务不可达时插件不会卡住工具(否则一次服务崩溃就把 dsh 锁死),但会把这次会话标记为降级,在会话里播一条「拦截链未生效」的 banner 并计入上面的「网关失败」,不让「没拦」被误读成「规则放行」。
98
+
99
+ ### 从 horon-web 迁移
100
+
101
+ 1. 目录 `horon-web/` → `dsh-horon/`,包名 `@local/horon-web` → `dsh-horon`。
102
+ 2. profile `package.json`:依赖键和 `bundles` 里的 `@local/horon-web` 换成 `dsh-horon`,重装一次 `link:`。
103
+ 3. profile `cordis.patch.yml`:`- id: horon-web` 那一行改成 `- id: dsh-horon`,`name` 改成 `'dsh-horon'`(`config` 整段照抄)。
104
+ 4. 控制台路由从 `/horon-web/` 变成 `/dsh-horon/`,旧书签失效。
105
+
106
+ ## 目录结构
107
+
108
+ ```
109
+ index.js 宿主侧入口:四种模式的服务生命周期、/dsh-horon/ 路由与设置页接口
110
+ harness.js horon 工具、记忆策略提示词段落、dsh 生命周期钩子(含工具门禁通道)
111
+ artifact.js 构建物模式:下载 → 校验 sha256 → 解包 → bootstrap → 交给同一个 spawn 路径
112
+ docker.js docker 模式的配置解析、compose/命令渲染、pull + run 部署与日志
113
+ horon.lock.json 唯一事实源:锁定 commit、镜像名与 tag、构建物 URL 与期望摘要
114
+ scripts/assemble.mjs 组装器:从一个 Horon 检出生成镜像上下文或某平台的构建物(CI 与本地共用)
115
+ scripts/bootstrap.py 构建物内的运行时安装器:建 venv + 离线装轮子(幂等)
116
+ test/host-smoke.mjs 宿主侧冒烟测试(假 cordis 上下文,不需要 dsh:node test/host-smoke.mjs)
117
+ test/artifact-smoke.mjs 构建物端到端冒烟:本地起静态服务 → 下载 → 安装 → 起服务 → 打两个接口
118
+ client.js 客户端 bundle:侧栏面板(含首次选择运行方式的引导)+ 设置页区块
119
+ cordis.patch.yml 本包自带的 loader 行(id/name/config 默认值,不带 mode)
120
+ horon_web_app.py 容器/本机进程里的 FastAPI 入口:/api 路由 + 打包好的前端
121
+ locale/ zh/en 文案
122
+ skills/ 三份说明书;git 忽略,发布前由组装器从锁定 commit 同步
123
+ docker/ Dockerfile(按 SHA 自取源库)、入口脚本、锁定依赖、compose
124
+ docs/deployment.md 发布链路、构建物内容与升级流程
125
+ ```
126
+
127
+ ## 发布与部署(维护者)
128
+
129
+ 用户不克隆源库、不构建:`artifact` 模式下载 GitHub Release 里的构建物,`docker` 模式拉 Docker Hub
130
+ 上的镜像,两者都由 CI 从 `horon.lock.json` 里锁定的同一个 commit 生成。
131
+
132
+ ```bash
133
+ # 本地先自检(等价于 CI 做的事)
134
+ node scripts/assemble.mjs --repo ~/horon --out .build/a2 --platform win-x64 --wheels
135
+ node test/host-smoke.mjs && node test/artifact-smoke.mjs # 只跑纯函数检查
136
+ ```
137
+
138
+ 完整链路(镜像工作流、构建物工作流、npm 发布、升级步骤、许可义务)见 [docs/deployment.md](docs/deployment.md)。
139
+
140
+ ## 配置项一览
141
+
142
+ | key | 默认 | 说明 |
143
+ | --- | --- | --- |
144
+ | `mode` | 由 `url` 推断,否则 `spawn`(首次安装面板会问) | `artifact` / `docker` / `spawn` / `url` |
145
+ | `url` | — | 运行中的 Horon 服务地址,例如 `http://127.0.0.1:8710` |
146
+ | `artifactDir` | `~/.horon/runtime` | 构建物解包目录,每个版本一个 `<commit8>-<platform>` 子目录 |
147
+ | `artifactUrl` / `artifactBaseUrl` / `artifactSha256` | 取自 `horon.lock.json` | 换镜像站或本地路径时用;后两者一般不用写 |
148
+ | `horonCommit` | `horon.lock.json` 的 commit | 临时钉到别的上游 commit(高级) |
149
+ | `root` | `~/horon` | `spawn` 模式的 Horon 仓库根目录 |
150
+ | `python` | `<root>/.venv/...`,没有 venv 时 `python` | 解释器;`artifact` 模式下是 bootstrap 的基础解释器 |
151
+ | `host` / `port` | `127.0.0.1` / `8710` | `artifact` / `spawn` 模式的绑定地址 |
152
+ | `autostart` | `true` | `false` 只挂插件,不拉起服务/容器 |
153
+ | `hooks` | `true` | `false` 只留 `horon` 工具,不挂钩子(也可用环境变量 `HORON_HOOKS=off`) |
154
+ | `skills` | `spawn` / `artifact` 模式外默认开 | 是否注册三个 horon skill |
155
+ | `dataDir` | `~/.horon/docker` | `artifact` 与 `docker` 共用的宿主数据目录(库、`.env`、tag 插件) |
156
+ | `dbFile` | `<dataDir>/data/horon.db` | SQLite 库;`docker` 模式挂到容器 `/data`,`artifact` 模式写进 `app/.env` 的 `HORON_DB`。从 docker 切过来时**显式写这一行**,否则会指向另一个库 |
157
+ | `envFile` | `<dataDir>/.env` | API key / 代理;`docker` 模式缺失时从 `docker/env.example` 复制,`artifact` 模式作为 `app/.env` 的种子(`HORON_DB` 由 `dbFile` 覆盖) |
158
+ | `tagPluginsDir` | `<dataDir>/tag_plugins` | tag 插件目录(`docker` 模式挂到 `/app/backend/tag_plugins`) |
159
+ | `dockerBind` / `dockerPort` | `127.0.0.1` / `8710` | 容器发布地址 |
160
+ | `image` / `container` | `<horon.lock.json image>` / `horon` | 镜像与容器名 |
161
+
162
+ ## License
163
+
164
+ 插件本身 MIT。它下载的 Horon 服务是 Apache-2.0 **加一条附加条件**:向组织以外的人提供产品或服务
165
+ 时必须显眼展示
166
+
167
+ ```
168
+ This product uses Horon (https://github.com/Dataojitori/horon)
169
+ ```
170
+
171
+ 镜像与构建物里带 `app/NOTICE`,别删。
package/artifact.js ADDED
@@ -0,0 +1,318 @@
1
+ // Build artifacts of the Horon service, released by CI: download, verify, unpack, bootstrap.
2
+ //
3
+ // This is the `artifact` mode's whole job. Nothing here clones a repository or builds anything:
4
+ // the release already contains the backend, the prebuilt console and the pinned wheels, so a host
5
+ // with Python 3.12 but no Docker, no git and no npm can still run the service.
6
+ //
7
+ // <artifactDir>/<commit8>-<platform>/ one directory per pinned release, never shared
8
+ // app/ HORON_ROOT: backend, frontend, web/dist, .venv
9
+ // wheels/ bootstrap.py requirements.lock.txt manifest.json
10
+ // .installed.json what the plugin did here, and when
11
+ //
12
+ // The version directory is what makes upgrades safe: a new commit unpacks next to the old one,
13
+ // the database and .env stay in dataDir, and rolling back is switching the lock back.
14
+
15
+ import { createHash } from 'node:crypto'
16
+ import { spawn, spawnSync } from 'node:child_process'
17
+ import { createWriteStream, existsSync, readFileSync } from 'node:fs'
18
+ import fs from 'node:fs/promises'
19
+ import http from 'node:http'
20
+ import https from 'node:https'
21
+ import os from 'node:os'
22
+ import path from 'node:path'
23
+ import { fileURLToPath } from 'node:url'
24
+
25
+ const HERE = path.dirname(fileURLToPath(import.meta.url))
26
+ export const LOCK_FILE = path.join(HERE, 'horon.lock.json')
27
+ const INSTALLED = '.installed.json'
28
+ const READY = path.join('app', '.ready.json')
29
+
30
+ const text = (value) => (typeof value === 'string' && value.trim() ? value.trim() : undefined)
31
+
32
+ function expandHome(value) {
33
+ return value === '~' || /^~[\\/]/.test(value) ? path.join(os.homedir(), value.slice(1)) : value
34
+ }
35
+
36
+ /** Bumped by CI; read at runtime so one package version can describe several deployments. */
37
+ export function readLock() {
38
+ try {
39
+ return JSON.parse(readFileSync(LOCK_FILE, 'utf8'))
40
+ } catch {
41
+ return undefined
42
+ }
43
+ }
44
+
45
+ /** The release asset platform key for this machine, e.g. `linux-x64`. */
46
+ export function platformKey(platform = process.platform, arch = process.arch) {
47
+ if (platform === 'win32') return arch === 'arm64' ? 'win-arm64' : 'win-x64'
48
+ if (platform === 'darwin') return arch === 'arm64' ? 'macos-arm64' : 'macos-x64'
49
+ if (platform === 'linux') return arch === 'arm64' ? 'linux-arm64' : 'linux-x64'
50
+ return `${platform}-${arch}`
51
+ }
52
+
53
+ export function defaultArtifactDir() {
54
+ return path.join(os.homedir(), '.horon', 'runtime')
55
+ }
56
+
57
+ function venvPython(appDir) {
58
+ return process.platform === 'win32'
59
+ ? path.join(appDir, '.venv', 'Scripts', 'python.exe')
60
+ : path.join(appDir, '.venv', 'bin', 'python')
61
+ }
62
+
63
+ /** Artifact settings with every default filled in from horon.lock.json. */
64
+ export function artifactSettings(config) {
65
+ const c = config ?? {}
66
+ const lock = readLock()
67
+ const commit = text(c.horonCommit) ?? lock?.horon?.commit ?? ''
68
+ const platform = platformKey()
69
+ const dir = path.resolve(expandHome(text(c.artifactDir) ?? defaultArtifactDir()))
70
+ const staged = path.join(dir, `${commit.slice(0, 8)}-${platform}`)
71
+ const short = commit.slice(0, 8)
72
+ // `{commit8}` / `{platform}` keep one commit bump from scattering stale names over the release.
73
+ const fill = (value) => String(value).replaceAll('{platform}', platform).replaceAll('{commit8}', short)
74
+ const given = text(c.artifactUrl)
75
+ const name = fill(given ?? lock?.artifact?.asset ?? 'horon-app-{platform}-{commit8}.tar.gz')
76
+ const baseUrl = fill(text(c.artifactBaseUrl) ?? lock?.artifact?.baseUrl ?? '')
77
+ const url = /^[a-z][a-z0-9+.-]*:\/\//i.test(name) ? name : `${baseUrl.replace(/\/+$/, '')}/${name}`
78
+ return {
79
+ platform,
80
+ commit,
81
+ commit8: commit.slice(0, 8),
82
+ dir,
83
+ root: staged,
84
+ appDir: path.join(staged, 'app'),
85
+ asset: path.basename(new URL(url || 'file:///x').pathname) || name,
86
+ url,
87
+ sha256: lock?.artifact?.sha256?.[platform] ?? text(c.artifactSha256) ?? '',
88
+ // The interpreter bootstrap runs with; the venv it creates is what the service runs on.
89
+ basePython: text(c.python) ?? (process.platform === 'win32' ? 'python' : 'python3'),
90
+ // Shared with docker mode on purpose: switching modes keeps the database, .env and tag plugins.
91
+ dataDir: path.resolve(expandHome(text(c.dataDir) ?? path.join(os.homedir(), '.horon', 'docker'))),
92
+ // An explicit dbFile / envFile wins, so a user who ran docker mode keeps the very same memory
93
+ // file and API keys after switching. "My notes are gone" must not be the first report.
94
+ dbFile: text(c.dbFile)
95
+ ? path.resolve(expandHome(text(c.dbFile)))
96
+ : path.join(path.resolve(expandHome(text(c.dataDir) ?? path.join(os.homedir(), '.horon', 'docker'))), 'data', 'horon.db'),
97
+ envFile: text(c.envFile)
98
+ ? path.resolve(expandHome(text(c.envFile)))
99
+ : path.join(path.resolve(expandHome(text(c.dataDir) ?? path.join(os.homedir(), '.horon', 'docker'))), '.env'),
100
+ lock,
101
+ }
102
+ }
103
+
104
+ /** What the settings page shows: is this release unpacked, and is its runtime ready. */
105
+ export async function artifactStatus(a) {
106
+ if (!a.commit) return { platform: a.platform, installed: false, ready: false, url: '', error: 'no pinned commit in horon.lock.json' }
107
+ const installed = existsSync(path.join(a.root, INSTALLED))
108
+ const ready = existsSync(path.join(a.root, READY))
109
+ let recorded
110
+ try {
111
+ recorded = JSON.parse(await fs.readFile(path.join(a.root, INSTALLED), 'utf8'))
112
+ } catch {
113
+ recorded = undefined
114
+ }
115
+ return {
116
+ platform: a.platform,
117
+ commit: a.commit,
118
+ commit8: a.commit8,
119
+ dir: a.dir,
120
+ root: a.root,
121
+ asset: a.asset,
122
+ url: a.url,
123
+ installed,
124
+ ready,
125
+ sha256: a.sha256 || undefined,
126
+ installedAt: recorded?.at,
127
+ python: ready ? venvPython(a.appDir) : a.basePython,
128
+ }
129
+ }
130
+
131
+ // ---- downloading ------------------------------------------------------------------------------
132
+
133
+ function fetchFile(url, destination, { onProgress } = {}) {
134
+ const get = (target, redirects) =>
135
+ new Promise((resolve, reject) => {
136
+ const agent = target.startsWith('https:') ? https : http
137
+ const request = agent.get(target, { headers: { 'user-agent': 'dsh-horon' } }, (response) => {
138
+ const status = response.statusCode ?? 0
139
+ if (status >= 300 && status < 400 && response.headers.location) {
140
+ response.resume()
141
+ if (redirects <= 0) {
142
+ reject(new Error(`too many redirects for ${url}`))
143
+ return
144
+ }
145
+ resolve(get(new URL(response.headers.location, target).toString(), redirects - 1))
146
+ return
147
+ }
148
+ if (status !== 200) {
149
+ response.resume()
150
+ reject(new Error(`HTTP ${status} for ${url}`))
151
+ return
152
+ }
153
+ const total = Number(response.headers['content-length'] ?? 0)
154
+ let seen = 0
155
+ let last = 0
156
+ const out = createWriteStream(destination)
157
+ out.on('error', reject)
158
+ response.on('data', (chunk) => {
159
+ seen += chunk.length
160
+ out.write(chunk)
161
+ const now = Date.now()
162
+ if (onProgress && (now - last > 400 || seen === total)) {
163
+ last = now
164
+ onProgress({ seen, total })
165
+ }
166
+ })
167
+ response.on('end', () => {
168
+ out.end(() => resolve({ bytes: seen }))
169
+ })
170
+ response.on('error', reject)
171
+ })
172
+ request.on('error', reject)
173
+ request.setTimeout(60_000, () => request.destroy(new Error(`timeout downloading ${url}`)))
174
+ })
175
+ return get(url, 5)
176
+ }
177
+
178
+ function sha256File(file) {
179
+ return createHash('sha256').update(readFileSync(file)).digest('hex')
180
+ }
181
+
182
+ function run(command, args, { cwd, onLine } = {}) {
183
+ return new Promise((resolve) => {
184
+ let child
185
+ try {
186
+ child = spawn(command, args, { cwd, shell: false, windowsHide: true })
187
+ } catch (error) {
188
+ resolve({ code: -1, out: String(error?.message ?? error) })
189
+ return
190
+ }
191
+ const lines = []
192
+ const push = (chunk) => {
193
+ const value = String(chunk)
194
+ lines.push(value)
195
+ onLine?.(value)
196
+ }
197
+ child.stdout.on('data', push)
198
+ child.stderr.on('data', push)
199
+ child.on('error', (error) => resolve({ code: -1, out: lines.join('') + error.message }))
200
+ child.on('close', (code) => resolve({ code: code ?? -1, out: lines.join('') }))
201
+ })
202
+ }
203
+
204
+ // `tar` ships with Windows 10+ (bsdtar) and with every POSIX we support; one tarball keeps the
205
+ // artifact a single verifiable file.
206
+ //
207
+ // GNU tar treats `D:\x.tgz` as a remote host named D, so it needs --force-local; bsdtar has no
208
+ // such flag and no such problem. Ask once which tar is on PATH and remember it.
209
+ let tarFlavour
210
+ function gnuTar() {
211
+ if (tarFlavour !== undefined) return tarFlavour
212
+ const probe = spawnSync('tar', ['--version'], { encoding: 'utf8', windowsHide: true })
213
+ tarFlavour = /GNU tar/i.test(`${probe.stdout ?? ''}${probe.stderr ?? ''}`)
214
+ return tarFlavour
215
+ }
216
+
217
+ async function extract(archive, destination, log) {
218
+ await fs.rm(destination, { recursive: true, force: true })
219
+ await fs.mkdir(destination, { recursive: true })
220
+ // Forward slashes keep GNU tar from reading the backslashes as escapes; --force-local keeps it
221
+ // from reading `C:` as a host name. bsdtar wants neither and copes with the slashes anyway.
222
+ const tarPath = (value) => path.resolve(value).split(path.sep).join('/')
223
+ const args = ['-xzf', tarPath(archive), '-C', tarPath(destination)]
224
+ if (gnuTar()) args.unshift('--force-local')
225
+ const done = await run('tar', args, {
226
+ onLine: (line) => log(`tar: ${line.trimEnd()}`),
227
+ })
228
+ if (done.code !== 0) throw new Error(`tar failed (${String(done.code)}): ${done.out.trim().slice(-400)}`)
229
+ }
230
+
231
+ // The service reads ROOT/.env and needs HORON_DB there. The file lives inside the version
232
+ // directory, but every path in it points outside, at the shared database and key file, so it
233
+ // survives an upgrade. It is rewritten on every start of this code path, so editing dbFile or
234
+ // envFile in the profile takes effect instead of being silently ignored.
235
+ async function writeEnv(a, log) {
236
+ const file = path.join(a.appDir, '.env')
237
+ let body = ''
238
+ if (existsSync(a.envFile)) {
239
+ body = await fs.readFile(a.envFile, 'utf8')
240
+ log(`seeded app/.env from ${a.envFile}`)
241
+ }
242
+ const lines = body.split(/\r?\n/).filter((line) => !/^\s*HORON_DB\s*=/.test(line))
243
+ while (lines.length > 0 && lines[lines.length - 1].trim() === '') lines.pop()
244
+ lines.push(`HORON_DB=${a.dbFile}`)
245
+ await fs.writeFile(file, `${lines.join('\n')}\n`, 'utf8')
246
+ }
247
+
248
+ /**
249
+ * Make sure this pinned release is unpacked and its runtime is installed.
250
+ * Resolves to `{ root, python }` for the spawn path; throws with a readable message otherwise.
251
+ */
252
+ export async function provisionArtifact(a, log = () => {}) {
253
+ if (!a.commit) throw new Error('horon.lock.json has no pinned commit; nothing to download')
254
+ if (!a.url) throw new Error('horon.lock.json has no artifact baseUrl; nothing to download')
255
+
256
+ const marker = path.join(a.root, INSTALLED)
257
+ const python = venvPython(a.appDir)
258
+ if (existsSync(marker) && existsSync(python)) {
259
+ log(`artifact already installed at ${a.root}`)
260
+ // Still reconcile app/.env: the config may name a different database since the last start.
261
+ await writeEnv(a, log)
262
+ return { root: a.appDir, python }
263
+ }
264
+
265
+ await fs.mkdir(a.dir, { recursive: true })
266
+ const archive = path.join(a.dir, `${a.commit8}-${a.platform}.tar.gz`)
267
+ if (!existsSync(archive)) {
268
+ log(`downloading ${a.url}`)
269
+ const tmp = `${archive}.part`
270
+ let lastBucket = -1
271
+ const { bytes } = await fetchFile(a.url, tmp, {
272
+ onProgress: ({ seen, total }) => {
273
+ if (!total) return
274
+ const bucket = Math.floor((seen / total) * 10)
275
+ if (bucket > lastBucket) {
276
+ lastBucket = bucket
277
+ log(` ${String(bucket * 10).padStart(3)}% ${(seen / 1024 / 1024).toFixed(1)} / ${(total / 1024 / 1024).toFixed(1)} MB`)
278
+ }
279
+ },
280
+ })
281
+ if (a.sha256) {
282
+ const digest = sha256File(tmp)
283
+ if (digest !== a.sha256) {
284
+ await fs.rm(tmp, { force: true })
285
+ throw new Error(`sha256 mismatch for ${a.asset}: expected ${a.sha256.slice(0, 12)}…, got ${digest.slice(0, 12)}…`)
286
+ }
287
+ log('sha256 verified')
288
+ } else {
289
+ log('no sha256 in honon.lock.json for this platform; skipping verification')
290
+ }
291
+ await fs.rename(tmp, archive)
292
+ log(`downloaded ${(bytes / 1024 / 1024).toFixed(1)} MB`)
293
+ } else {
294
+ log(`using the downloaded ${path.basename(archive)}`)
295
+ }
296
+
297
+ log(`unpacking into ${a.root}`)
298
+ await extract(archive, a.root, log)
299
+ await writeEnv(a, log)
300
+
301
+ log('installing the runtime from the bundled wheels (no network)')
302
+ const done = await run(a.basePython, [path.join(a.root, 'bootstrap.py')], {
303
+ cwd: a.root,
304
+ onLine: (line) => log(line.trimEnd()),
305
+ })
306
+ if (done.code !== 0) throw new Error(`bootstrap failed (${String(done.code)}): ${done.out.trim().slice(-600)}`)
307
+ if (!existsSync(python)) {
308
+ // bootstrap fell back to app/vendor; the plugin puts that directory on PYTHONPATH.
309
+ log('bootstrap used the vendor layout; the service runs on the base interpreter')
310
+ await fs.writeFile(marker, JSON.stringify({ at: new Date().toISOString(), vendor: true }, null, 2))
311
+ return { root: a.appDir, python: a.basePython, vendor: true }
312
+ }
313
+ await fs.writeFile(marker, JSON.stringify({ at: new Date().toISOString(), asset: a.asset }, null, 2))
314
+ log(`artifact ready: ${a.root}`)
315
+ return { root: a.appDir, python }
316
+ }
317
+
318
+ export const __internal = { sha256File, venvPython }