dsh-horon 1.0.2 → 1.2.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.
package/README.md CHANGED
@@ -13,22 +13,20 @@
13
13
  3. **控制台**(`index.js` + `client.js`)—— dsh web 服务器上的 `/dsh-horon/` 反向代理,把 Horon 控制台塞进侧栏 iframe;它和 dsh 自己的路由走同一道 Host/Origin + 登录栅栏,所以跨机器打开 dsh 也能用。`/dsh-horon/_dsh/*` 是设置页用的状态、配置、预览、部署、日志接口。
14
14
  4. **skills 注册**(可选)—— 宿主读不到 Horon 仓库时(`artifact` / `docker` / `url` 模式),从本包的 `skills/` 注册 `horon-cli` / `horon-circuit` / `horon-plugin`;`artifact` 模式解包后也有 `app/.agents/skills`,两处内容同源(都来自锁定的 commit)。
15
15
 
16
- ## 四种服务运行方式
16
+ ## 两种运行方式
17
17
 
18
- `config.mode` 决定 Horon 服务从哪来(不写 `mode` 时:有 `url` 就是 `url`,否则 `spawn`):
18
+ 面板上只有两个选择(`config.mode`):
19
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` |
20
+ | mode | 做法 |
21
+ | --- | --- |
22
+ | `artifact` | **默认**。从 GitHub Release 下载 CI 发布的构建物(后端 + 打包好的控制台 + 依赖轮子),走 `proxy`,校验 sha256,解包到 `<dataDir>/runtime/` 并离线装好运行时,然后当本机进程跑。只要 Python 3.12,不需要 Docker / git / npm / Horon 仓库 |
23
+ | `docker` | 拉 CI 发布的镜像并作为本机容器运行(`127.0.0.1:8710`,容器名 `horon`,镜像固定取自 `horon.lock.json`),`<dataDir>` 挂到 `/data`。本机不构建 |
26
24
 
27
- `artifact` 与 `docker` 共用 `dataDir`(默认 `~/.horon/docker`):数据库、`.env`、tag 插件都在那里,所以换方式、换版本都不丢记忆;构建物按 `<commit8>-<platform>` 解包,回滚就是把锁文件的 commit 改回去。
25
+ 两者共用同一个数据目录,所以随时切换都不丢记忆;容器地址、镜像名、容器名都不需要用户填。
28
26
 
29
- 服务本身没有鉴权,默认只绑回环地址;要跨机器用请自行加一层可信网络。
27
+ `spawn`(跑本机克隆)与 `url`(接已有远端服务)仍在代码里,供开发使用,不在面板上出现。
30
28
 
31
- **首次安装没有 `mode`**:插件不启动任何东西,Horon 面板会先问「下载构建物 / 拉取 Docker 镜像」。
29
+ **首次安装没有 `mode`**:插件不启动任何东西,Horon 面板会先问「构建物 / Docker」。
32
30
  本机已有 `~/horon` 克隆的机器视为已选择 `spawn`,升级不会被重新询问。
33
31
 
34
32
  ## 安装
@@ -141,23 +139,23 @@ node test/host-smoke.mjs && node test/artifact-smoke.mjs # 只跑纯函数
141
139
 
142
140
  | key | 默认 | 说明 |
143
141
  | --- | --- | --- |
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` 模式的绑定地址 |
142
+ | `mode` | 首次安装面板会问 | **面板只给两种**:`artifact`(构建物,免 Docker)、`docker`(拉镜像)。`spawn` / `url` 仍在代码里,供开发和接已有远端服务,不在面板上 |
143
+ | `dataDir` | `~/.horon` | **唯一的路径设置**。数据库、`.env`、构建物、tag 插件全部由它派生;两种运行方式共用 |
144
+ | `proxy` | `https://gh-proxy.com` | 下载代理:构建物从 GitHub Release 取;docker 模式下同时写进 `~/.docker/config.json` 的 `proxies`。留空表示直连 |
152
145
  | `autostart` | `true` | `false` 只挂插件,不拉起服务/容器 |
153
146
  | `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` | 镜像与容器名 |
147
+ | `skills` | `spawn` 模式外默认开 | 是否注册三个 horon skill |
148
+
149
+ 由 `dataDir` 派生、**不需要手写**:
150
+
151
+ | 路径 | 用途 |
152
+ | --- | --- |
153
+ | `<dataDir>/horon.db` | SQLite 库;docker 模式下整个 `<dataDir>` 挂到容器 `/data`,所以两边是同一个文件 |
154
+ | `<dataDir>/.env` | API key / 代理。旧布局的 `<dataDir>/docker/.env` 在没有新文件时仍会被读取 |
155
+ | `<dataDir>/runtime/<commit8>-<platform>/` | 构建物解包目录,换版本解包到新目录,旧的可回滚 |
156
+ | `<dataDir>/tag_plugins/` | 个人 tag 插件;artifact 模式每次启动镜像进构建物的 `app/backend/tag_plugins`,docker 模式直接挂载 |
157
+
158
+ 开发/接入已有服务时才用:`root`(`~/horon`)、`python`、`url`、`host` / `port`(`127.0.0.1:8710`)。
161
159
 
162
160
  ## License
163
161
 
package/artifact.js CHANGED
@@ -21,6 +21,16 @@ import https from 'node:https'
21
21
  import os from 'node:os'
22
22
  import path from 'node:path'
23
23
  import { fileURLToPath } from 'node:url'
24
+ import {
25
+ dbFileFor,
26
+ envFileFor,
27
+ expandHome,
28
+ proxyOf,
29
+ proxied,
30
+ resolveDataDir,
31
+ runtimeDirFor,
32
+ tagPluginsDirFor,
33
+ } from './paths.js'
24
34
 
25
35
  const HERE = path.dirname(fileURLToPath(import.meta.url))
26
36
  export const LOCK_FILE = path.join(HERE, 'horon.lock.json')
@@ -29,10 +39,6 @@ const READY = path.join('app', '.ready.json')
29
39
 
30
40
  const text = (value) => (typeof value === 'string' && value.trim() ? value.trim() : undefined)
31
41
 
32
- function expandHome(value) {
33
- return value === '~' || /^~[\\/]/.test(value) ? path.join(os.homedir(), value.slice(1)) : value
34
- }
35
-
36
42
  /** Bumped by CI; read at runtime so one package version can describe several deployments. */
37
43
  export function readLock() {
38
44
  try {
@@ -50,8 +56,8 @@ export function platformKey(platform = process.platform, arch = process.arch) {
50
56
  return `${platform}-${arch}`
51
57
  }
52
58
 
53
- export function defaultArtifactDir() {
54
- return path.join(os.homedir(), '.horon', 'runtime')
59
+ export function defaultArtifactDir(dataDir) {
60
+ return runtimeDirFor(dataDir ?? resolveDataDir({}))
55
61
  }
56
62
 
57
63
  function venvPython(appDir) {
@@ -66,7 +72,9 @@ export function artifactSettings(config) {
66
72
  const lock = readLock()
67
73
  const commit = text(c.horonCommit) ?? lock?.horon?.commit ?? ''
68
74
  const platform = platformKey()
69
- const dir = path.resolve(expandHome(text(c.artifactDir) ?? defaultArtifactDir()))
75
+ const root = resolveDataDir(c)
76
+ // Unpacked releases live under the data directory, so one path covers the lot.
77
+ const dir = path.resolve(expandHome(text(c.artifactDir) ?? runtimeDirFor(root)))
70
78
  const staged = path.join(dir, `${commit.slice(0, 8)}-${platform}`)
71
79
  const short = commit.slice(0, 8)
72
80
  // `{commit8}` / `{platform}` keep one commit bump from scattering stale names over the release.
@@ -74,7 +82,10 @@ export function artifactSettings(config) {
74
82
  const given = text(c.artifactUrl)
75
83
  const name = fill(given ?? lock?.artifact?.asset ?? 'horon-app-{platform}-{commit8}.tar.gz')
76
84
  const baseUrl = fill(text(c.artifactBaseUrl) ?? lock?.artifact?.baseUrl ?? '')
77
- const url = /^[a-z][a-z0-9+.-]*:\/\//i.test(name) ? name : `${baseUrl.replace(/\/+$/, '')}/${name}`
85
+ const direct = /^[a-z][a-z0-9+.-]*:\/\//i.test(name) ? name : `${baseUrl.replace(/\/+$/, '')}/${name}`
86
+ // GitHub releases are the only source; the proxy is what makes them reachable.
87
+ const proxy = proxyOf(c)
88
+ const url = proxied(direct, proxy)
78
89
  return {
79
90
  platform,
80
91
  commit,
@@ -87,16 +98,15 @@ export function artifactSettings(config) {
87
98
  sha256: lock?.artifact?.sha256?.[platform] ?? text(c.artifactSha256) ?? '',
88
99
  // The interpreter bootstrap runs with; the venv it creates is what the service runs on.
89
100
  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'),
101
+ // One directory for both runtimes: switching keeps the database, the key file and tag plugins.
102
+ dataDir: root,
103
+ dbFile: text(c.dbFile) ? path.resolve(expandHome(text(c.dbFile))) : dbFileFor(root),
104
+ envFile: text(c.envFile) ? path.resolve(expandHome(text(c.envFile))) : envFileFor(root),
105
+ proxy: proxyOf(c),
106
+ // tag plugins are personal: they never travel in the release, so the user's directory is
107
+ // mirrored into the unpacked tree on every start.
108
+ tagPluginsDir: tagPluginsDirFor(root),
109
+ directUrl: direct,
100
110
  lock,
101
111
  }
102
112
  }
@@ -245,6 +255,40 @@ async function writeEnv(a, log) {
245
255
  await fs.writeFile(file, `${lines.join('\n')}\n`, 'utf8')
246
256
  }
247
257
 
258
+ /**
259
+ * Mirror the user's tag plugins into the unpacked release.
260
+ *
261
+ * The release only carries the system plugins (state/action/result/exit by upstream allow-list);
262
+ * anything a user wrote for themselves lives in the data directory. Docker mode bind-mounts that
263
+ * directory over the same path, so this walk is what artifact mode needs to behave identically.
264
+ */
265
+ async function syncTagPlugins(a, log) {
266
+ const target = path.join(a.appDir, 'backend', 'tag_plugins')
267
+ let names
268
+ try {
269
+ names = await fs.readdir(a.tagPluginsDir)
270
+ } catch {
271
+ return
272
+ }
273
+ const files = names.filter((name) => name.endsWith('.py'))
274
+ if (files.length === 0) return
275
+ await fs.mkdir(target, { recursive: true })
276
+ let copied = 0
277
+ for (const name of files) {
278
+ const from = path.join(a.tagPluginsDir, name)
279
+ const to = path.join(target, name)
280
+ try {
281
+ const [source, current] = await Promise.all([fs.stat(from), fs.stat(to).catch(() => undefined)])
282
+ if (current !== undefined && current.size === source.size && current.mtimeMs >= source.mtimeMs) continue
283
+ await fs.copyFile(from, to)
284
+ copied += 1
285
+ } catch {
286
+ // A plugin that cannot be read is skipped; the service logs its own loading errors.
287
+ }
288
+ }
289
+ if (copied > 0) log(`tag plugins: ${String(copied)} file(s) synced from ${a.tagPluginsDir}`)
290
+ }
291
+
248
292
  /**
249
293
  * Make sure this pinned release is unpacked and its runtime is installed.
250
294
  * Resolves to `{ root, python }` for the spawn path; throws with a readable message otherwise.
@@ -257,8 +301,10 @@ export async function provisionArtifact(a, log = () => {}) {
257
301
  const python = venvPython(a.appDir)
258
302
  if (existsSync(marker) && existsSync(python)) {
259
303
  log(`artifact already installed at ${a.root}`)
260
- // Still reconcile app/.env: the config may name a different database since the last start.
304
+ // Still reconcile both: the config may name a different directory since the last start, and
305
+ // tag plugins may have been edited.
261
306
  await writeEnv(a, log)
307
+ await syncTagPlugins(a, log)
262
308
  return { root: a.appDir, python }
263
309
  }
264
310
 
@@ -297,6 +343,7 @@ export async function provisionArtifact(a, log = () => {}) {
297
343
  log(`unpacking into ${a.root}`)
298
344
  await extract(archive, a.root, log)
299
345
  await writeEnv(a, log)
346
+ await syncTagPlugins(a, log)
300
347
 
301
348
  log('installing the runtime from the bundled wheels (no network)')
302
349
  const done = await run(a.basePython, [path.join(a.root, 'bootstrap.py')], {
package/client.js CHANGED
@@ -41,63 +41,33 @@ window.__ModuleLoader__.load({
41
41
  sectionTitle: 'Horon 服务',
42
42
  loading: '正在读取配置…',
43
43
  mode: '运行方式',
44
- modeSpawn: '本机进程(python -m uvicorn)',
45
44
  modeArtifact: '构建物(免 Docker)',
46
45
  modeDocker: 'Docker 容器(本机)',
47
- modeUrl: '远端服务(只填 URL)',
48
- modeHintSpawn: '由 dsh 自己拉起 Horon(需要本机 Python 环境和 Horon 仓库),开发时用。',
49
- modeHintArtifact: '下载 CI 发布的构建物(后端 + 打包好的控制台 + 依赖轮子),解包到本机直接跑:不需要 Horon 仓库,也不需要 git / npm / Docker。',
50
- modeHintDocker: 'Horon 跑在本机 Docker 容器里,数据全部挂载到下面这些宿主机目录。',
51
- modeHintUrl: 'Horon 跑在别处,这里只记住地址;不启动也不部署。',
52
- artifact: '构建物',
53
- artifactHint: '按 horon.lock.json 锁定的 commit 和当前平台下载;换版本解包到新目录,旧版本留着可回滚。',
54
- artifactDir: '构建物目录',
55
- artifactDirHint: '默认:{def}',
56
- artifactUrl: '构建物地址(可选)',
57
- artifactUrlHint: '留空用 horon.lock.json 里的发布地址;也可以填镜像站或本地路径。',
58
- artifactState: '构建物状态',
59
- artifactMissing: '未下载',
60
- artifactReady: '已就绪',
61
- artifactPending: '已解包,运行时未装好',
62
- artifactPin: '锁定',
46
+ modeHintArtifact: '从 GitHub Release 下载 CI 发布的构建物,解包到数据目录里直接跑:不需要 Horon 仓库,也不需要 git / npm / Docker。',
47
+ modeHintDocker: '拉取 CI 发布的镜像并作为本机容器运行,数据挂载到同一个数据目录。',
48
+ artifactReadyHint: '已就绪:构建物 {commit} · {platform},位于数据目录的 runtime 下。',
49
+ artifactMissingHint: '还没下载:点「下载并安装」后会按锁定 commit 下载、校验摘要、离线装依赖。',
63
50
  provision: '下载并安装',
64
51
  provisioning: '下载安装中…',
65
- provisionNote: '按锁定 commit 下载、校验 sha256、解包,再用自带的轮子离线装依赖。',
66
52
  provisionDone: '构建物已就绪,服务正在启动。',
67
53
  provisionFailed: '下载或安装失败,原因见日志。',
68
- dataDirShared: '数据目录与 Docker 模式共用:数据库、.env、tag 插件都在这里,切换运行方式不会丢记忆。',
69
- url: '服务地址',
70
- urlHint: '例如 http://127.0.0.1:8710。服务本身没有鉴权,只应放在可信网络里。',
71
54
  autostart: '启动 dsh 时自动拉起',
72
55
  autostartHint: 'Docker 模式下只会 docker start 已存在的容器,不会替你创建。',
73
56
 
74
- paths: '数据与挂载(全部在宿主机上)',
75
- pathsHint: '留空即用默认值;路径可以写 ~,表示当前用户目录。数据库、.env、tag_plugins 都挂在宿主机上,重建容器不会丢。',
57
+ paths: '数据与下载',
58
+ pathsHint: '所有数据(数据库、.env、构建物、tag 插件)都在这一个目录下,两种运行方式共用,不会因为换方式丢记忆。留空用默认值,可以写 ~。',
76
59
  dataDir: '数据目录',
77
60
  dataDirHint: '默认:{def}',
78
- dbFile: '数据库文件',
79
- dbFileHint: '默认:{def};所在目录挂到容器的 /data',
80
- envFile: '环境变量文件',
81
- envFileHint: '默认:{def};API key 和代理写在这里,部署时若不存在会从 env.example 复制',
82
- tagPluginsDir: 'tag 插件目录',
83
- tagPluginsDirHint: '默认:{def};挂到 /app/backend/tag_plugins',
84
- dockerBind: '监听地址',
85
- dockerPort: '端口',
86
- bindHint: '默认 127.0.0.1',
87
- portHint: '默认 8710',
88
- image: '镜像',
89
- container: '容器名',
61
+ proxy: '下载代理',
62
+ proxyHint: '默认 https://gh-proxy.com,用于从 GitHub Release 下载构建物;Docker 模式下拉镜像也会写进 ~/.docker/config.json。留空表示直连。',
90
63
 
91
64
  applied: '实际生效',
92
- home: '用户目录',
93
- platform: '当前平台',
94
- bindWarning: '端口没有绑在回环地址上:Horon 服务没有鉴权,任何能访问这个端口的人都能读写你的记忆库。',
95
65
 
96
66
  save: '保存',
97
67
  saving: '保存中…',
98
68
  reset: '还原',
99
- saveNote: '保存会改写 profile 的 cordis.patch.yml 并立即重载插件:本机进程模式的服务会被重启,端口改了还要刷新页面。',
100
- saved: '已保存,插件正在重载;端口有变化时请刷新页面。',
69
+ saveNote: '保存会改写 profile 的 cordis.patch.yml 并立即重载插件:服务会按新的方式和目录重启。',
70
+ saved: '已保存,插件正在重载。',
101
71
  editBlocked: '这个 dsh 没有提供配置编辑器,只能手工改 profile 的 cordis.patch.yml。',
102
72
 
103
73
  deploy: '一键部署',
@@ -111,9 +81,7 @@ window.__ModuleLoader__.load({
111
81
  copyFailed: '浏览器不允许自动复制,请在下面手动选中。',
112
82
  tabCommands: '命令',
113
83
  tabCompose: 'compose',
114
- tabDockerfile: 'Dockerfile',
115
84
  tabEnv: '.env 示例',
116
- noContext: '镜像还没拉下来:点「一键部署」拉取即可。',
117
85
 
118
86
  onboardingTitle: '先选一种运行方式',
119
87
  onboardingNote: 'Horon 服务需要一个运行时。选一种装好就能用;之后可在「设置 → Horon 服务」里随时改。',
@@ -148,57 +116,27 @@ window.__ModuleLoader__.load({
148
116
  sectionTitle: 'Horon service',
149
117
  loading: 'Loading the settings…',
150
118
  mode: 'Mode',
151
- modeSpawn: 'Local process (python -m uvicorn)',
152
119
  modeArtifact: 'Build artifact (no Docker)',
153
120
  modeDocker: 'Docker container (this host)',
154
- modeUrl: 'Remote service (URL only)',
155
- modeHintSpawn: 'dsh starts Horon itself from a local clone; the developer path.',
156
- modeHintArtifact: 'Downloads the artifact CI released (backend + built console + pinned wheels) and runs it here: no Horon repository, no git, no npm, no Docker.',
157
- modeHintDocker: 'Horon runs in a container on this host; everything below is bind-mounted from the host.',
158
- modeHintUrl: 'Horon runs elsewhere; only the address is remembered. Nothing is started or deployed.',
159
- artifact: 'Build artifact',
160
- artifactHint: 'Downloaded for the commit pinned in horon.lock.json and this platform; a new version unpacks next to the old one, so rolling back is a config change.',
161
- artifactDir: 'Artifact directory',
162
- artifactDirHint: 'Default: {def}',
163
- artifactUrl: 'Artifact URL (optional)',
164
- artifactUrlHint: 'Empty uses the release URL in horon.lock.json; a mirror or a local path works too.',
165
- artifactState: 'Artifact state',
166
- artifactMissing: 'not downloaded',
167
- artifactReady: 'ready',
168
- artifactPending: 'unpacked, runtime not installed',
169
- artifactPin: 'pinned',
121
+ modeHintArtifact: 'Downloads the artifact CI released from GitHub, unpacks it under the data directory and runs it: no Horon repository, no git, no npm, no Docker.',
122
+ modeHintDocker: 'Pulls the image CI released and runs it as a local container, with the same data directory mounted in.',
123
+ artifactReadyHint: 'Ready: artifact {commit} · {platform}, under the data directory runtime folder.',
124
+ artifactMissingHint: 'Not downloaded yet: Download and install fetches the pinned commit, verifies the digest and installs the runtime offline.',
170
125
  provision: 'Download and install',
171
126
  provisioning: 'Downloading…',
172
- provisionNote: 'Downloads the pinned commit, verifies sha256, unpacks it and installs the runtime from the bundled wheels, offline.',
173
127
  provisionDone: 'The artifact is ready; the service is starting.',
174
128
  provisionFailed: 'The download or install failed; see the log.',
175
- dataDirShared: 'The data directory is shared with Docker mode: database, .env and tag plugins stay put when you switch.',
176
- url: 'Service URL',
177
- urlHint: 'For example http://127.0.0.1:8710. The service has no auth of its own, so keep it inside a trusted network.',
178
129
  autostart: 'Start it when dsh starts',
179
130
  autostartHint: 'In Docker mode this only runs docker start on an existing container; it never creates one.',
180
131
 
181
- paths: 'Data and mounts (all on the host)',
182
- pathsHint: 'Leave a field empty to use the default; `~` means the home directory. The database, .env and tag_plugins live on the host, so recreating the container keeps them.',
132
+ paths: 'Data and downloads',
133
+ pathsHint: 'Everything lives under this one directory — database, .env, unpacked artifacts, tag plugins — and both runtimes share it, so switching keeps your memory. Empty uses the default; ~ works.',
183
134
  dataDir: 'Data directory',
184
135
  dataDirHint: 'Default: {def}',
185
- dbFile: 'Database file',
186
- dbFileHint: 'Default: {def}; its directory is mounted at /data',
187
- envFile: 'Environment file',
188
- envFileHint: 'Default: {def}; API keys and proxies go here, copied from env.example when missing',
189
- tagPluginsDir: 'Tag plugins directory',
190
- tagPluginsDirHint: 'Default: {def}; mounted at /app/backend/tag_plugins',
191
- dockerBind: 'Listen address',
192
- dockerPort: 'Port',
193
- bindHint: 'Default 127.0.0.1',
194
- portHint: 'Default 8710',
195
- image: 'Image',
196
- container: 'Container name',
136
+ proxy: 'Download proxy',
137
+ proxyHint: 'https://gh-proxy.com by default, used to fetch the artifact from GitHub Releases; in Docker mode it is also written into ~/.docker/config.json for pulls. Empty means a direct connection.',
197
138
 
198
139
  applied: 'In effect',
199
- home: 'Home directory',
200
- platform: 'Platform',
201
- bindWarning: 'The port is not bound to a loopback address: the Horon service has no auth, so anyone who can reach it can read and write your memory database.',
202
140
 
203
141
  save: 'Save',
204
142
  saving: 'Saving…',
@@ -218,9 +156,7 @@ window.__ModuleLoader__.load({
218
156
  copyFailed: 'The browser refused clipboard access; select the text below instead.',
219
157
  tabCommands: 'Commands',
220
158
  tabCompose: 'compose',
221
- tabDockerfile: 'Dockerfile',
222
159
  tabEnv: '.env example',
223
- noContext: 'The image has not been pulled yet: press Deploy now.',
224
160
 
225
161
  onboardingTitle: 'Choose how to run it',
226
162
  onboardingNote: 'The Horon service needs a runtime. Pick one and it is ready; you can change it later under Settings → Horon service.',
@@ -532,12 +468,6 @@ window.__ModuleLoader__.load({
532
468
  )
533
469
  }
534
470
 
535
- const DEFAULTS = {
536
- dockerBind: '127.0.0.1',
537
- dockerPort: '8710',
538
- container: 'horon',
539
- }
540
-
541
471
  function SettingsSection(props) {
542
472
  const t = props.t ?? ((key) => key)
543
473
 
@@ -728,17 +658,16 @@ window.__ModuleLoader__.load({
728
658
 
729
659
  const resolved = preview?.resolved ?? data?.resolved ?? {}
730
660
  const texts = preview?.texts ?? data?.texts ?? {}
731
- const mode = draft.mode ?? data?.mode ?? 'spawn'
661
+ // Only the two runtimes a user actually picks between; spawn/url stay in the code for
662
+ // development and for an existing remote service, but they are not offered here.
663
+ const mode = draft.mode === 'docker' ? 'docker' : 'artifact'
732
664
  const serving = preview?.serving ?? status?.serving
733
665
  const docker = status?.docker
734
666
  const hooks = status?.hooks
735
- const bind = resolved.dockerBind ?? DEFAULTS.dockerBind
736
- const bindIsLoopback = ['127.0.0.1', 'localhost', '::1'].includes(String(bind))
737
667
 
738
668
  const blocks = [
739
669
  ['commands', t('tabCommands'), texts.commands],
740
670
  ['compose', t('tabCompose'), texts.compose],
741
- ['dockerfile', t('tabDockerfile'), texts.dockerfile],
742
671
  ['env', t('tabEnv'), texts.envExample],
743
672
  ]
744
673
  const active = blocks.find(([key]) => key === tab) ?? blocks[0]
@@ -758,21 +687,10 @@ window.__ModuleLoader__.load({
758
687
  h(
759
688
  'select',
760
689
  { className: 'dshhw-select', value: mode, onChange: (event) => update({ mode: event.target.value }) },
761
- h('option', { value: 'spawn' }, t('modeSpawn')),
762
690
  h('option', { value: 'artifact' }, t('modeArtifact')),
763
691
  h('option', { value: 'docker' }, t('modeDocker')),
764
- h('option', { value: 'url' }, t('modeUrl')),
765
692
  ),
766
693
  ),
767
- mode === 'url'
768
- ? h(TextField, {
769
- label: t('url'),
770
- value: draft.url,
771
- placeholder: 'http://127.0.0.1:8710',
772
- width: '320px',
773
- onChange: (value) => update({ url: value }),
774
- })
775
- : null,
776
694
  h(
777
695
  'label',
778
696
  { className: 'dshhw-check', title: t('autostartHint') },
@@ -784,42 +702,17 @@ window.__ModuleLoader__.load({
784
702
  t('autostart'),
785
703
  ),
786
704
  ),
787
- h('div', { className: 'dshhw-note' }, mode === 'artifact' ? t('modeHintArtifact') : mode === 'docker' ? t('modeHintDocker') : mode === 'url' ? t('modeHintUrl') : t('modeHintSpawn')),
788
- mode === 'url' ? h('div', { className: 'dshhw-hint' }, t('urlHint')) : null,
789
-
790
- // Artifact mode: the released tree, its pin, and where it lands.
705
+ h('div', { className: 'dshhw-note' }, mode === 'artifact' ? t('modeHintArtifact') : t('modeHintDocker')),
791
706
  mode === 'artifact'
792
707
  ? h(
793
708
  'div',
794
- { className: 'dshhw-section' },
795
- h('h4', null, t('artifact')),
796
- h('div', { className: 'dshhw-note' }, t('artifactHint')),
797
- h(TextField, {
798
- label: t('artifactDir'),
799
- value: draft.artifactDir,
800
- placeholder: data?.artifact?.dir ?? data?.defaultArtifactDir ?? '',
801
- hint: t('artifactDirHint', { def: data?.defaultArtifactDir ?? data?.artifact?.dir ?? '' }),
802
- onChange: (value) => update({ artifactDir: value }),
803
- }),
804
- h(TextField, {
805
- label: t('artifactUrl'),
806
- value: draft.artifactUrl,
807
- placeholder: data?.artifact?.url ?? '',
808
- hint: t('artifactUrlHint'),
809
- onChange: (value) => update({ artifactUrl: value }),
810
- }),
811
- h(
812
- 'div',
813
- { className: 'dshhw-hint' },
814
- `${t('artifactPin')}: ${(data?.artifact?.commit ?? '').slice(0, 8)} · ${data?.artifact?.platform ?? ''} · ${t('artifactState')}: ${
815
- data?.artifactState?.ready === true
816
- ? t('artifactReady')
817
- : data?.artifactState?.installed === true
818
- ? t('artifactPending')
819
- : t('artifactMissing')
820
- }`,
821
- ),
822
- h('div', { className: 'dshhw-hint' }, t('dataDirShared')),
709
+ { className: 'dshhw-hint' },
710
+ data?.artifactState?.ready === true
711
+ ? t('artifactReadyHint', {
712
+ commit: (data?.artifact?.commit ?? '').slice(0, 8),
713
+ platform: data?.artifact?.platform ?? '',
714
+ })
715
+ : t('artifactMissingHint'),
823
716
  )
824
717
  : null,
825
718
 
@@ -855,7 +748,7 @@ window.__ModuleLoader__.load({
855
748
  ),
856
749
  ),
857
750
 
858
- // Host mounts
751
+ // One directory decides everything else: database, key file, releases, tag plugins.
859
752
  h(
860
753
  'div',
861
754
  { className: 'dshhw-section' },
@@ -869,63 +762,18 @@ window.__ModuleLoader__.load({
869
762
  onChange: (value) => update({ dataDir: value }),
870
763
  }),
871
764
  h(TextField, {
872
- label: t('dbFile'),
873
- value: draft.dbFile,
874
- placeholder: resolved.dbFile ?? '',
875
- hint: t('dbFileHint', { def: resolved.dbFile ?? '' }),
876
- onChange: (value) => update({ dbFile: value }),
877
- }),
878
- h(TextField, {
879
- label: t('envFile'),
880
- value: draft.envFile,
881
- placeholder: resolved.envFile ?? '',
882
- hint: t('envFileHint', { def: resolved.envFile ?? '' }),
883
- onChange: (value) => update({ envFile: value }),
884
- }),
885
- h(TextField, {
886
- label: t('tagPluginsDir'),
887
- value: draft.tagPluginsDir,
888
- placeholder: resolved.tagPluginsDir ?? '',
889
- hint: t('tagPluginsDirHint', { def: resolved.tagPluginsDir ?? '' }),
890
- onChange: (value) => update({ tagPluginsDir: value }),
765
+ label: t('proxy'),
766
+ value: draft.proxy,
767
+ placeholder: data?.defaultProxy ?? '',
768
+ hint: t('proxyHint'),
769
+ onChange: (value) => update({ proxy: value }),
891
770
  }),
892
771
  h(
893
772
  'div',
894
- { className: 'dshhw-row' },
895
- h(TextField, {
896
- label: t('dockerBind'),
897
- value: draft.dockerBind,
898
- placeholder: DEFAULTS.dockerBind,
899
- hint: t('bindHint'),
900
- width: '170px',
901
- onChange: (value) => update({ dockerBind: value }),
902
- }),
903
- h(TextField, {
904
- label: t('dockerPort'),
905
- value: draft.dockerPort === undefined ? '' : String(draft.dockerPort),
906
- placeholder: DEFAULTS.dockerPort,
907
- hint: t('portHint'),
908
- width: '110px',
909
- onChange: (value) => update({ dockerPort: value }),
910
- }),
911
- h(TextField, {
912
- label: t('image'),
913
- value: draft.image,
914
- placeholder: texts.image ?? '',
915
- width: '200px',
916
- onChange: (value) => update({ image: value }),
917
- }),
918
- h(TextField, {
919
- label: t('container'),
920
- value: draft.container,
921
- placeholder: DEFAULTS.container,
922
- width: '150px',
923
- onChange: (value) => update({ container: value }),
924
- }),
773
+ { className: 'dshhw-hint' },
774
+ `${t('applied')}: ${resolved.dbFile ?? ''} · ${resolved.envFile ?? ''} · ${resolved.base ?? ''}`,
925
775
  ),
926
- h('div', { className: 'dshhw-hint' }, `${t('home')}: ${data?.home ?? ''} · ${t('platform')}: ${data?.platform ?? ''}`),
927
- h('div', { className: 'dshhw-hint' }, `${t('applied')}: ${resolved.dataDir ?? ''} → ${resolved.base ?? ''}`),
928
- bindIsLoopback ? null : h('div', { className: 'dshhw-warn' }, t('bindWarning')),
776
+ h('div', { className: 'dshhw-note' }, t('saveNote')),
929
777
  h('div', { className: 'dshhw-note' }, t('saveNote')),
930
778
  h(
931
779
  'div',
@@ -995,7 +843,7 @@ window.__ModuleLoader__.load({
995
843
  h('div', { className: 'dshhw-note' }, t('deployNote')),
996
844
  active[2]
997
845
  ? h('pre', { className: 'dshhw-code' }, active[2])
998
- : h('div', { className: 'dshhw-warn' }, texts.contextReady === false ? t('noContext') : t('loading')),
846
+ : h('div', { className: 'dshhw-warn' }, t('loading')),
999
847
  h('textarea', {
1000
848
  ref: clip,
1001
849
  className: 'dshhw-clip',
package/docker.js CHANGED
@@ -21,6 +21,16 @@ import os from 'node:os'
21
21
  import path from 'node:path'
22
22
  import { fileURLToPath } from 'node:url'
23
23
  import { readLock } from './artifact.js'
24
+ import {
25
+ dbFileFor,
26
+ defaultDataDir,
27
+ envFileFor,
28
+ expandHome,
29
+ localDirFor,
30
+ proxyOf,
31
+ resolveDataDir,
32
+ tagPluginsDirFor,
33
+ } from './paths.js'
24
34
 
25
35
  const HERE = path.dirname(fileURLToPath(import.meta.url))
26
36
  export const DOCKER_DIR = path.join(HERE, 'docker')
@@ -29,6 +39,8 @@ const CONTAINER_PORT = 8710
29
39
  const LOG_LIMIT = 256 * 1024
30
40
  const READY_TIMEOUT_MS = 120_000
31
41
  const STATUS_TIMEOUT_MS = 8000
42
+ // Same file Docker Desktop's own proxy settings are written to.
43
+ const DOCKER_CONFIG = path.join(os.homedir(), '.docker', 'config.json')
32
44
 
33
45
  /** The published image this plugin version was built against. */
34
46
  export function defaultImage() {
@@ -36,9 +48,7 @@ export function defaultImage() {
36
48
  return image?.name && image?.tag ? `${image.name}:${image.tag}` : 'horon-service:local'
37
49
  }
38
50
 
39
- export function defaultDataDir() {
40
- return path.join(os.homedir(), '.horon', 'docker')
41
- }
51
+ export { defaultDataDir, expandHome }
42
52
 
43
53
  // Host part of a URL for a bind address; a wildcard bind is reached on loopback.
44
54
  export function urlHost(host) {
@@ -54,36 +64,30 @@ function hostTimeZone() {
54
64
  }
55
65
  }
56
66
 
57
- // `~` / `~/…` / `~\…` is the home directory of the user running dsh, so one config works on both
58
- // Windows and Linux.
59
- export function expandHome(value) {
60
- return value === '~' || /^~[\\/]/.test(value) ? path.join(os.homedir(), value.slice(1)) : value
61
- }
62
-
63
- // Docker settings with every default filled in. Paths that are not set follow dataDir.
67
+ // Docker settings with every default filled in. Everything derives from `dataDir`; the container
68
+ // itself is fixed (loopback, 8710, the published image), because none of that is a decision a user
69
+ // should have to make.
64
70
  export function dockerSettings(config) {
65
71
  const c = config ?? {}
66
72
  const text = (value) => (typeof value === 'string' && value.trim() ? value.trim() : undefined)
67
- const file = (value) => {
68
- const given = text(value)
69
- return given === undefined ? undefined : path.resolve(expandHome(given))
70
- }
71
- const dataDir = file(c.dataDir) ?? defaultDataDir()
72
- const dbFile = file(c.dbFile) ?? path.join(dataDir, 'data', 'horon.db')
73
- const dockerBind = text(c.dockerBind) ?? '127.0.0.1'
74
- const dockerPort = Number(c.dockerPort ?? 8710)
73
+ const root = resolveDataDir(c)
74
+ const dockerBind = '127.0.0.1'
75
+ const dockerPort = 8710
75
76
  return {
76
- dataDir,
77
- dbFile,
78
- dbDir: path.dirname(dbFile),
79
- localDir: path.join(dataDir, 'local'),
80
- envFile: file(c.envFile) ?? path.join(dataDir, '.env'),
81
- tagPluginsDir: file(c.tagPluginsDir) ?? path.join(dataDir, 'tag_plugins'),
77
+ dataDir: root,
78
+ root,
79
+ // The directory bind-mounted at /data; the database sits directly in it, which is the same
80
+ // layout artifact mode uses, so switching runtimes finds the same file.
81
+ dbFile: text(c.dbFile) ? path.resolve(expandHome(text(c.dbFile))) : dbFileFor(root),
82
+ dbDir: path.dirname(text(c.dbFile) ? path.resolve(expandHome(text(c.dbFile))) : dbFileFor(root)),
83
+ localDir: localDirFor(root),
84
+ envFile: text(c.envFile) ? path.resolve(expandHome(text(c.envFile))) : envFileFor(root),
85
+ tagPluginsDir: tagPluginsDirFor(root),
82
86
  dockerBind,
83
87
  dockerPort,
84
- image: text(c.image) ?? defaultImage(),
85
- container: text(c.container) ?? 'horon',
86
- buildProxy: text(c.buildProxy) ?? '',
88
+ image: defaultImage(),
89
+ container: 'horon',
90
+ proxy: proxyOf(c),
87
91
  timeZone: hostTimeZone(),
88
92
  base: `http://${urlHost(dockerBind)}:${dockerPort}`,
89
93
  }
@@ -233,14 +237,14 @@ export async function renderTexts(d, platform = process.platform) {
233
237
 
234
238
  // ---- running docker --------------------------------------------------------------------------
235
239
 
236
- function runDocker(args, { onData, timeoutMs } = {}) {
240
+ function runDocker(args, { onData, timeoutMs, env } = {}) {
237
241
  return new Promise((resolve) => {
238
242
  let child
239
243
  try {
240
244
  child = spawn('docker', args, {
241
245
  shell: false,
242
246
  windowsHide: true,
243
- env: { ...process.env, DOCKER_CLI_HINTS: 'false' },
247
+ env: { ...process.env, DOCKER_CLI_HINTS: 'false', ...env },
244
248
  })
245
249
  } catch (error) {
246
250
  resolve({ code: -1, out: '', err: String(error?.message ?? error) })
@@ -273,6 +277,55 @@ function runDocker(args, { onData, timeoutMs } = {}) {
273
277
 
274
278
  const firstLine = (text) => String(text ?? '').trim().split(/\r?\n/).pop() ?? ''
275
279
 
280
+ /**
281
+ * `docker pull` runs in the engine, not in this process, so an environment variable here cannot
282
+ * reach it. Docker Desktop reads `proxies` from ~/.docker/config.json — the same file its own
283
+ * Settings page writes — so the proxy is recorded there before a pull. A Linux engine keeps that
284
+ * value in its service unit instead, and the log says so rather than pretending it took effect.
285
+ */
286
+ async function applyDockerProxy(proxy, say) {
287
+ if (proxy === '') {
288
+ say('\n# no download proxy configured; pulling directly\n')
289
+ return
290
+ }
291
+ let raw
292
+ let config = {}
293
+ try {
294
+ raw = await fs.readFile(DOCKER_CONFIG, 'utf8')
295
+ config = JSON.parse(raw)
296
+ } catch {
297
+ config = {}
298
+ }
299
+ const current = config.proxies?.default ?? {}
300
+ if (current.httpProxy === proxy && current.httpsProxy === proxy) {
301
+ say(`\n# docker already uses ${proxy}\n`)
302
+ return
303
+ }
304
+ if (raw !== undefined) {
305
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-')
306
+ await fs.writeFile(`${DOCKER_CONFIG}.horon-backup-${stamp}`, raw, 'utf8')
307
+ say(`\n# backed up config.json -> ${path.basename(DOCKER_CONFIG)}.horon-backup-${stamp}\n`)
308
+ }
309
+ config.proxies = {
310
+ ...(config.proxies ?? {}),
311
+ default: {
312
+ httpProxy: proxy,
313
+ httpsProxy: proxy,
314
+ noProxy: current.noProxy ?? 'localhost,127.0.0.1,::1',
315
+ },
316
+ }
317
+ await fs.mkdir(path.dirname(DOCKER_CONFIG), { recursive: true })
318
+ await fs.writeFile(DOCKER_CONFIG, `${JSON.stringify(config, null, 2)}\n`, 'utf8')
319
+ say(`# recorded the proxy in ${DOCKER_CONFIG}\n`)
320
+ say('# Docker Desktop applies it; a Linux engine needs the same value in its service config\n')
321
+ }
322
+
323
+ // Some pulls (and `docker login`) do honour the client environment, so pass it along as well.
324
+ function proxyEnv(proxy) {
325
+ if (proxy === '') return {}
326
+ return { HTTP_PROXY: proxy, HTTPS_PROXY: proxy, http_proxy: proxy, https_proxy: proxy }
327
+ }
328
+
276
329
  export async function isServing(base, timeoutMs = 1200) {
277
330
  try {
278
331
  const response = await fetch(`${base}/api/sessions`, { signal: AbortSignal.timeout(timeoutMs) })
@@ -397,6 +450,8 @@ async function deploy(d, say) {
397
450
  return false
398
451
  }
399
452
 
453
+ await applyDockerProxy(d.proxy, say)
454
+
400
455
  // Something else on the port (typically a spawn/artifact-mode service) would make `docker run`
401
456
  // fail after a long pull; stop early instead. Our own running container is replaced below.
402
457
  const inspect = await runDocker(['inspect', '--type', 'container', '--format', '{{.State.Running}}', d.container])
@@ -419,7 +474,8 @@ async function deploy(d, say) {
419
474
  say(`created ${d.envFile} from env.example; put the API keys / proxy there\n`)
420
475
  }
421
476
 
422
- if ((await step(flat(plan.pull))).code !== 0) return false
477
+ say(`\n$ docker ${flat(plan.pull).map(quotePosix).join(' ')}\n`)
478
+ if ((await runDocker(flat(plan.pull), { onData: say, env: proxyEnv(d.proxy) })).code !== 0) return false
423
479
  await step(flat(plan.remove)) // "No such container" on the first deploy is fine
424
480
  if ((await step(flat(plan.run))).code !== 0) return false
425
481
 
package/index.js CHANGED
@@ -60,14 +60,9 @@ import https from 'node:https'
60
60
  import os from 'node:os'
61
61
  import path from 'node:path'
62
62
  import { fileURLToPath } from 'node:url'
63
+ import { artifactSettings, artifactStatus, provisionArtifact } from './artifact.js'
64
+ import { defaultDataDir, proxyOf } from './paths.js'
63
65
  import {
64
- artifactSettings,
65
- artifactStatus,
66
- defaultArtifactDir,
67
- provisionArtifact,
68
- } from './artifact.js'
69
- import {
70
- defaultDataDir,
71
66
  deployState,
72
67
  dockerSettings,
73
68
  dockerStatus,
@@ -98,12 +93,10 @@ const ROUTE = '/dsh-horon'
98
93
  const API_PREFIX = `${ROUTE}/_dsh/`
99
94
  // The settings call carries one small config object; anything bigger is not a config edit.
100
95
  const MAX_BODY_BYTES = 512 * 1024
101
- // Keys the settings page may write into the profile patch.
102
- const EDITABLE = new Set([
103
- 'mode', 'url', 'autostart', 'dataDir', 'dbFile', 'envFile', 'tagPluginsDir',
104
- 'dockerBind', 'dockerPort', 'image', 'container',
105
- 'artifactDir', 'artifactUrl', 'artifactBaseUrl', 'artifactSha256', 'horonCommit', 'python', 'root',
106
- ])
96
+ // Keys the settings page may write into the profile patch. Everything else is derived: the
97
+ // database, the key file, the runtime directory and the container all follow `dataDir`, and the
98
+ // container itself is fixed (loopback, 8710, the published image).
99
+ const EDITABLE = new Set(['mode', 'proxy', 'dataDir', 'autostart', 'hooks', 'skills', 'url', 'port', 'python', 'root'])
107
100
  const MODES = new Set(['spawn', 'artifact', 'docker', 'url'])
108
101
  // BUNDLED_SKILL_RANK of @deepseek-ai/dsh-skill: user (~/.dsh/skills) and project skills win over it.
109
102
  const SKILL_RANK = 600
@@ -331,16 +324,29 @@ function cleanInput(input) {
331
324
  out.autostart = value !== false && value !== 'false' && value !== null
332
325
  continue
333
326
  }
327
+ if (key === 'skills') {
328
+ out.skills = value !== false && value !== 'false' && value !== null
329
+ continue
330
+ }
331
+ if (key === 'hooks') {
332
+ out.hooks = value !== false && value !== 'false' && value !== null
333
+ continue
334
+ }
335
+ // An empty proxy is a choice ("connect directly"), not a reset, so it is stored as "".
336
+ if (key === 'proxy') {
337
+ out.proxy = String(value ?? '').trim()
338
+ continue
339
+ }
334
340
  // An emptied field clears the override instead of writing an empty string; preview and
335
341
  // deploy read it as "use the default".
336
342
  if (value === null || String(value).trim() === '') {
337
343
  out[key] = null
338
344
  continue
339
345
  }
340
- if (key === 'dockerPort') {
346
+ if (key === 'port') {
341
347
  const port = Number(value)
342
- if (!Number.isInteger(port) || port < 1 || port > 65535) throw httpError(400, 'dockerPort must be a port number')
343
- out.dockerPort = port
348
+ if (!Number.isInteger(port) || port < 1 || port > 65535) throw httpError(400, 'port must be a port number')
349
+ out.port = port
344
350
  continue
345
351
  }
346
352
  if (key === 'mode' && !MODES.has(value)) throw httpError(400, 'mode must be spawn, artifact, docker or url')
@@ -400,35 +406,20 @@ function readableConfig(config) {
400
406
  url: c.url,
401
407
  autostart: c.autostart !== false,
402
408
  dataDir: c.dataDir,
403
- dbFile: c.dbFile,
404
- envFile: c.envFile,
405
- tagPluginsDir: c.tagPluginsDir,
406
- dockerBind: c.dockerBind,
407
- dockerPort: c.dockerPort,
408
- image: c.image,
409
- container: c.container,
410
- artifactDir: c.artifactDir,
411
- artifactUrl: c.artifactUrl,
412
- artifactBaseUrl: c.artifactBaseUrl,
413
- artifactSha256: c.artifactSha256,
414
- horonCommit: c.horonCommit,
415
- python: c.python,
416
- root: c.root,
409
+ proxy: c.proxy,
417
410
  }
418
411
  }
419
412
 
420
413
  function describeResolved(d) {
421
414
  return {
422
415
  dataDir: d.dataDir,
423
- dbDir: d.dbDir,
424
416
  dbFile: d.dbFile,
425
- localDir: d.localDir,
426
417
  envFile: d.envFile,
427
418
  tagPluginsDir: d.tagPluginsDir,
428
- dockerBind: d.dockerBind,
429
- dockerPort: d.dockerPort,
419
+ localDir: d.localDir,
430
420
  image: d.image,
431
421
  container: d.container,
422
+ proxy: d.proxy,
432
423
  base: d.base,
433
424
  }
434
425
  }
@@ -470,7 +461,7 @@ async function describeConfig(ctx, config) {
470
461
  platform: process.platform,
471
462
  home: os.homedir(),
472
463
  defaultDataDir: defaultDataDir(),
473
- defaultArtifactDir: defaultArtifactDir(),
464
+ defaultProxy: proxyOf({}),
474
465
  resolved: describeResolved(s.docker),
475
466
  artifact: describeArtifact(s.artifact),
476
467
  artifactState: await artifactStatus(s.artifact),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-horon",
3
- "version": "1.0.2",
3
+ "version": "1.2.0",
4
4
  "description": "Horon memory graph as a DeepSeek Harness plugin: the horon tool, lifecycle hooks, the console panel and a one-click service runtime (released artifact or Docker image)",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -23,6 +23,7 @@
23
23
  "client.js",
24
24
  "docker.js",
25
25
  "artifact.js",
26
+ "paths.js",
26
27
  "horon_web_app.py",
27
28
  "horon.lock.json",
28
29
  "cordis.patch.yml",
package/paths.js ADDED
@@ -0,0 +1,60 @@
1
+ // Where everything Horon-related lives, for both runtimes.
2
+ //
3
+ // One path decides all of it: `dataDir`, by default ~/.horon. The database, the key file, the
4
+ // unpacked releases and the tag plugins are all derived from it, so a user only ever answers
5
+ // "where do I keep this" once. Docker mode mounts the same directory into the container at /data,
6
+ // which is what makes switching runtimes keep the memory.
7
+
8
+ import { existsSync } from 'node:fs'
9
+ import os from 'node:os'
10
+ import path from 'node:path'
11
+
12
+ /** The public release proxy used when downloading from GitHub by default. */
13
+ export const DEFAULT_PROXY = 'https://gh-proxy.com'
14
+
15
+ export function expandHome(value) {
16
+ return value === '~' || /^~[\\/]/.test(value) ? path.join(os.homedir(), value.slice(1)) : value
17
+ }
18
+
19
+ export function defaultDataDir() {
20
+ return path.join(os.homedir(), '.horon')
21
+ }
22
+
23
+ const text = (value) => (typeof value === 'string' && value.trim() ? value.trim() : undefined)
24
+
25
+ /** The configured data directory, absolute. */
26
+ export function resolveDataDir(config) {
27
+ const given = text(config?.dataDir)
28
+ return given === undefined ? defaultDataDir() : path.resolve(expandHome(given))
29
+ }
30
+
31
+ /**
32
+ * The key/value file (API keys, proxies).
33
+ *
34
+ * `<root>/.env` is the documented location. An older layout kept it in `<root>/docker/.env`;
35
+ * that file is still read when the new one does not exist, so upgrading does not lose the keys.
36
+ */
37
+ export function envFileFor(root) {
38
+ const current = path.join(root, '.env')
39
+ if (existsSync(current)) return current
40
+ const legacy = path.join(root, 'docker', '.env')
41
+ return existsSync(legacy) ? legacy : current
42
+ }
43
+
44
+ export const dbFileFor = (root) => path.join(root, 'horon.db')
45
+ export const runtimeDirFor = (root) => path.join(root, 'runtime')
46
+ export const tagPluginsDirFor = (root) => path.join(root, 'tag_plugins')
47
+ export const localDirFor = (root) => path.join(root, 'local')
48
+
49
+ /** Download proxy: empty means a direct connection. */
50
+ export function proxyOf(config) {
51
+ const given = config?.proxy
52
+ if (given === null) return ''
53
+ return typeof given === 'string' ? given.trim() : DEFAULT_PROXY
54
+ }
55
+
56
+ /** Prefix a URL with the proxy: https://gh-proxy.com/https://github.com/... */
57
+ export function proxied(url, proxy) {
58
+ const base = String(proxy ?? '').trim().replace(/\/+$/, '')
59
+ return base === '' ? url : `${base}/${url}`
60
+ }