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 +24 -26
- package/artifact.js +66 -19
- package/client.js +39 -191
- package/docker.js +86 -30
- package/index.js +26 -35
- package/package.json +2 -1
- package/paths.js +60 -0
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
|
-
|
|
18
|
+
面板上只有两个选择(`config.mode`):
|
|
19
19
|
|
|
20
|
-
| mode | 做法 |
|
|
21
|
-
| --- | --- |
|
|
22
|
-
| `artifact` |
|
|
23
|
-
| `docker` |
|
|
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
|
-
|
|
25
|
+
两者共用同一个数据目录,所以随时切换都不丢记忆;容器地址、镜像名、容器名都不需要用户填。
|
|
28
26
|
|
|
29
|
-
|
|
27
|
+
`spawn`(跑本机克隆)与 `url`(接已有远端服务)仍在代码里,供开发使用,不在面板上出现。
|
|
30
28
|
|
|
31
|
-
**首次安装没有 `mode`**:插件不启动任何东西,Horon
|
|
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` |
|
|
145
|
-
| `
|
|
146
|
-
| `
|
|
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`
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
|
159
|
-
|
|
|
160
|
-
| `
|
|
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
|
|
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
|
|
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
|
|
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
|
-
//
|
|
91
|
-
dataDir:
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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: '
|
|
57
|
+
paths: '数据与下载',
|
|
58
|
+
pathsHint: '所有数据(数据库、.env、构建物、tag 插件)都在这一个目录下,两种运行方式共用,不会因为换方式丢记忆。留空用默认值,可以写 ~。',
|
|
76
59
|
dataDir: '数据目录',
|
|
77
60
|
dataDirHint: '默认:{def}',
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
|
182
|
-
pathsHint: '
|
|
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
|
-
|
|
186
|
-
|
|
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
|
-
|
|
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') :
|
|
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-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
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
|
-
//
|
|
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('
|
|
873
|
-
value: draft.
|
|
874
|
-
placeholder:
|
|
875
|
-
hint: t('
|
|
876
|
-
onChange: (value) => update({
|
|
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-
|
|
895
|
-
|
|
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-
|
|
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' },
|
|
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
|
|
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
|
-
//
|
|
58
|
-
//
|
|
59
|
-
|
|
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
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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:
|
|
85
|
-
container:
|
|
86
|
-
|
|
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
|
-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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 === '
|
|
346
|
+
if (key === 'port') {
|
|
341
347
|
const port = Number(value)
|
|
342
|
-
if (!Number.isInteger(port) || port < 1 || port > 65535) throw httpError(400, '
|
|
343
|
-
out.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|