jishushell 0.4.10 → 0.4.24-beta.2
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/Dockerfile.hermes-slim +193 -0
- package/INSTALL-NOTICE +10 -12
- package/apps/hermes-container.yaml +35 -0
- package/apps/ollama-binary.yaml +164 -0
- package/apps/ollama-cpu-container.yaml +37 -0
- package/apps/ollama-with-hollama-binary.yaml +159 -0
- package/apps/openclaw-binary.yaml +69 -0
- package/apps/openclaw-container.yaml +37 -0
- package/apps/openclaw-with-ollama-container.yaml +42 -0
- package/apps/openclaw-with-searxng-container.yaml +136 -0
- package/apps/openwebui-container.yaml +53 -0
- package/apps/playwright-container.yaml +120 -0
- package/apps/searxng-container.yaml +115 -0
- package/dist/auth.d.ts +1 -0
- package/dist/auth.js +15 -14
- package/dist/auth.js.map +1 -1
- package/dist/cli/app.d.ts +4 -0
- package/dist/cli/app.js +874 -0
- package/dist/cli/app.js.map +1 -0
- package/dist/cli/backup.d.ts +3 -0
- package/dist/cli/backup.js +434 -0
- package/dist/cli/backup.js.map +1 -0
- package/dist/{doctor.d.ts → cli/doctor.d.ts} +7 -1
- package/dist/{doctor.js → cli/doctor.js} +377 -22
- package/dist/cli/doctor.js.map +1 -0
- package/dist/cli/helpers.d.ts +4 -0
- package/dist/cli/helpers.js +32 -0
- package/dist/cli/helpers.js.map +1 -0
- package/dist/cli/job.d.ts +4 -0
- package/dist/cli/job.js +198 -0
- package/dist/cli/job.js.map +1 -0
- package/dist/cli/llm.d.ts +25 -0
- package/dist/cli/llm.js +599 -0
- package/dist/cli/llm.js.map +1 -0
- package/dist/cli/managed-list.d.ts +30 -0
- package/dist/cli/managed-list.js +129 -0
- package/dist/cli/managed-list.js.map +1 -0
- package/dist/cli/panel.d.ts +26 -0
- package/dist/cli/panel.js +804 -0
- package/dist/cli/panel.js.map +1 -0
- package/dist/cli/version.d.ts +1 -0
- package/dist/cli/version.js +12 -0
- package/dist/cli/version.js.map +1 -0
- package/dist/cli.js +48 -776
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +69 -0
- package/dist/config.js +268 -7
- package/dist/config.js.map +1 -1
- package/dist/control.d.ts +17 -41
- package/dist/control.js +61 -1323
- package/dist/control.js.map +1 -1
- package/dist/install.d.ts +16 -0
- package/dist/install.js +75 -26
- package/dist/install.js.map +1 -1
- package/dist/routes/agent-apps.d.ts +15 -0
- package/dist/routes/agent-apps.js +78 -0
- package/dist/routes/agent-apps.js.map +1 -0
- package/dist/routes/apps.d.ts +3 -0
- package/dist/routes/apps.js +278 -0
- package/dist/routes/apps.js.map +1 -0
- package/dist/routes/backup.js +3 -3
- package/dist/routes/backup.js.map +1 -1
- package/dist/routes/instances.d.ts +6 -0
- package/dist/routes/instances.js +863 -874
- package/dist/routes/instances.js.map +1 -1
- package/dist/routes/llm.d.ts +15 -0
- package/dist/routes/llm.js +247 -0
- package/dist/routes/llm.js.map +1 -0
- package/dist/routes/runtime.d.ts +15 -0
- package/dist/routes/runtime.js +69 -0
- package/dist/routes/runtime.js.map +1 -0
- package/dist/routes/setup.js +131 -9
- package/dist/routes/setup.js.map +1 -1
- package/dist/routes/system.js +56 -9
- package/dist/routes/system.js.map +1 -1
- package/dist/server.js +107 -7
- package/dist/server.js.map +1 -1
- package/dist/services/agent-apps/catalog.d.ts +30 -0
- package/dist/services/agent-apps/catalog.js +60 -0
- package/dist/services/agent-apps/catalog.js.map +1 -0
- package/dist/services/agent-apps/index.d.ts +36 -0
- package/dist/services/agent-apps/index.js +171 -0
- package/dist/services/agent-apps/index.js.map +1 -0
- package/dist/services/agent-apps/installers/adapter-probes.d.ts +49 -0
- package/dist/services/agent-apps/installers/adapter-probes.js +223 -0
- package/dist/services/agent-apps/installers/adapter-probes.js.map +1 -0
- package/dist/services/agent-apps/installers/adapter.d.ts +30 -0
- package/dist/services/agent-apps/installers/adapter.js +171 -0
- package/dist/services/agent-apps/installers/adapter.js.map +1 -0
- package/dist/services/agent-apps/installers/registry-probe.d.ts +38 -0
- package/dist/services/agent-apps/installers/registry-probe.js +183 -0
- package/dist/services/agent-apps/installers/registry-probe.js.map +1 -0
- package/dist/services/agent-apps/installers/shell-script.d.ts +47 -0
- package/dist/services/agent-apps/installers/shell-script.js +471 -0
- package/dist/services/agent-apps/installers/shell-script.js.map +1 -0
- package/dist/services/agent-apps/types.d.ts +125 -0
- package/dist/services/agent-apps/types.js +17 -0
- package/dist/services/agent-apps/types.js.map +1 -0
- package/dist/services/app/app-compiler.d.ts +15 -0
- package/dist/services/app/app-compiler.js +172 -0
- package/dist/services/app/app-compiler.js.map +1 -0
- package/dist/services/app/app-manager.d.ts +142 -0
- package/dist/services/app/app-manager.js +2148 -0
- package/dist/services/app/app-manager.js.map +1 -0
- package/dist/services/app/custom-manager.d.ts +27 -0
- package/dist/services/app/custom-manager.js +285 -0
- package/dist/services/app/custom-manager.js.map +1 -0
- package/dist/services/app/hermes-agent-manager.d.ts +20 -0
- package/dist/services/app/hermes-agent-manager.js +289 -0
- package/dist/services/app/hermes-agent-manager.js.map +1 -0
- package/dist/services/app/id-normalizer.d.ts +27 -0
- package/dist/services/app/id-normalizer.js +77 -0
- package/dist/services/app/id-normalizer.js.map +1 -0
- package/dist/services/app/ollama-manager.d.ts +18 -0
- package/dist/services/app/ollama-manager.js +207 -0
- package/dist/services/app/ollama-manager.js.map +1 -0
- package/dist/services/app/openclaw-manager.d.ts +63 -0
- package/dist/services/app/openclaw-manager.js +1178 -0
- package/dist/services/app/openclaw-manager.js.map +1 -0
- package/dist/services/app/paths.d.ts +47 -0
- package/dist/services/app/paths.js +68 -0
- package/dist/services/app/paths.js.map +1 -0
- package/dist/services/app/registry.d.ts +17 -0
- package/dist/services/app/registry.js +31 -0
- package/dist/services/app/registry.js.map +1 -0
- package/dist/services/app/remote-spec.d.ts +14 -0
- package/dist/services/app/remote-spec.js +58 -0
- package/dist/services/app/remote-spec.js.map +1 -0
- package/dist/services/app/terminal-session-manager.d.ts +27 -0
- package/dist/services/app/terminal-session-manager.js +157 -0
- package/dist/services/app/terminal-session-manager.js.map +1 -0
- package/dist/services/app/types.d.ts +72 -0
- package/dist/services/app/types.js +16 -0
- package/dist/services/app/types.js.map +1 -0
- package/dist/services/backup-manager.js +60 -22
- package/dist/services/backup-manager.js.map +1 -1
- package/dist/services/instance-manager.d.ts +125 -34
- package/dist/services/instance-manager.js +679 -1043
- package/dist/services/instance-manager.js.map +1 -1
- package/dist/services/llm-proxy/adapters.js +5 -1
- package/dist/services/llm-proxy/adapters.js.map +1 -1
- package/dist/services/llm-proxy/circuit-breaker.js +10 -2
- package/dist/services/llm-proxy/circuit-breaker.js.map +1 -1
- package/dist/services/llm-proxy/index.d.ts +43 -0
- package/dist/services/llm-proxy/index.js +120 -5
- package/dist/services/llm-proxy/index.js.map +1 -1
- package/dist/services/llm-proxy/ssrf.js +1 -1
- package/dist/services/llm-proxy/ssrf.js.map +1 -1
- package/dist/services/nomad-manager.d.ts +260 -3
- package/dist/services/nomad-manager.js +2921 -341
- package/dist/services/nomad-manager.js.map +1 -1
- package/dist/services/panel-manager.d.ts +50 -0
- package/dist/services/panel-manager.js +443 -0
- package/dist/services/panel-manager.js.map +1 -0
- package/dist/services/plugin-installer.js +28 -2
- package/dist/services/plugin-installer.js.map +1 -1
- package/dist/services/process-manager.js +42 -7
- package/dist/services/process-manager.js.map +1 -1
- package/dist/services/runtime/adapters/custom.d.ts +20 -0
- package/dist/services/runtime/adapters/custom.js +90 -0
- package/dist/services/runtime/adapters/custom.js.map +1 -0
- package/dist/services/runtime/adapters/hermes.d.ts +174 -0
- package/dist/services/runtime/adapters/hermes.js +1316 -0
- package/dist/services/runtime/adapters/hermes.js.map +1 -0
- package/dist/services/runtime/adapters/openclaw-routes.d.ts +17 -0
- package/dist/services/runtime/adapters/openclaw-routes.js +946 -0
- package/dist/services/runtime/adapters/openclaw-routes.js.map +1 -0
- package/dist/services/runtime/adapters/openclaw.d.ts +188 -0
- package/dist/services/runtime/adapters/openclaw.js +2195 -0
- package/dist/services/runtime/adapters/openclaw.js.map +1 -0
- package/dist/services/runtime/errors.d.ts +28 -0
- package/dist/services/runtime/errors.js +31 -0
- package/dist/services/runtime/errors.js.map +1 -0
- package/dist/services/runtime/index.d.ts +34 -0
- package/dist/services/runtime/index.js +51 -0
- package/dist/services/runtime/index.js.map +1 -0
- package/dist/services/runtime/instance.d.ts +24 -0
- package/dist/services/runtime/instance.js +143 -0
- package/dist/services/runtime/instance.js.map +1 -0
- package/dist/services/runtime/migrations.d.ts +15 -0
- package/dist/services/runtime/migrations.js +25 -0
- package/dist/services/runtime/migrations.js.map +1 -0
- package/dist/services/runtime/registry.d.ts +13 -0
- package/dist/services/runtime/registry.js +32 -0
- package/dist/services/runtime/registry.js.map +1 -0
- package/dist/services/runtime/types.d.ts +545 -0
- package/dist/services/runtime/types.js +14 -0
- package/dist/services/runtime/types.js.map +1 -0
- package/dist/services/setup-manager.d.ts +70 -29
- package/dist/services/setup-manager.js +591 -625
- package/dist/services/setup-manager.js.map +1 -1
- package/dist/services/task-registry.d.ts +44 -0
- package/dist/services/task-registry.js +74 -0
- package/dist/services/task-registry.js.map +1 -0
- package/dist/services/telemetry/heartbeat.d.ts +6 -6
- package/dist/services/telemetry/heartbeat.js +29 -30
- package/dist/services/telemetry/heartbeat.js.map +1 -1
- package/dist/services/update-manager.d.ts +47 -0
- package/dist/services/update-manager.js +305 -0
- package/dist/services/update-manager.js.map +1 -0
- package/dist/types.d.ts +224 -0
- package/dist/utils/docker-host.d.ts +15 -0
- package/dist/utils/docker-host.js +64 -0
- package/dist/utils/docker-host.js.map +1 -0
- package/install/jishu-install.sh +303 -38
- package/install/post-install.sh +64 -5
- package/package.json +19 -5
- package/public/assets/Dashboard-rh9qpYRR.js +1 -0
- package/public/assets/HermesChatPanel-D6JI6lLY.js +1 -0
- package/public/assets/HermesConfigForm-DcbSemaj.js +4 -0
- package/public/assets/InitPassword-CFTKsED4.js +1 -0
- package/public/assets/InstanceDetail-BhNIKA6Z.js +91 -0
- package/public/assets/{Login-CUoEZOWR.js → Login-KB9qrtM0.js} +1 -1
- package/public/assets/NewInstance-CxkO8Hlq.js +1 -0
- package/public/assets/Settings-BVWJvOkU.js +1 -0
- package/public/assets/Setup-X-lzuaUT.js +1 -0
- package/public/assets/WeixinLoginPanel-gca0QTic.js +9 -0
- package/public/assets/index-C8B0cFJM.js +19 -0
- package/public/assets/index-CPhVFEsx.css +1 -0
- package/public/assets/input-paste-CrNVAyOy.js +1 -0
- package/public/assets/{providers-lBSOjUWy.js → providers-V-vwrExZ.js} +1 -1
- package/public/assets/registry-fVUSujib.js +2 -0
- package/public/assets/{usePolling-CK0DfI4h.js → usePolling-Do5Erqm_.js} +1 -1
- package/public/assets/vendor-i18n-ucpM0OR0.js +9 -0
- package/public/assets/{vendor-react-B1-3Yrt-.js → vendor-react-Bk1hRGiY.js} +1 -1
- package/public/favicon.png +0 -0
- package/public/index.html +9 -4
- package/public/logos/hermes.png +0 -0
- package/public/logos/ollama.png +0 -0
- package/public/logos/openclaw.svg +60 -0
- package/scripts/build-hermes-image.sh +21 -0
- package/scripts/build-local.sh +54 -0
- package/scripts/check-adapter-isolation.ts +293 -0
- package/scripts/fixtures/instances/hermes-sample/instance.json +37 -0
- package/scripts/fixtures/instances/legacy-openclaw-sample/instance.json +7 -0
- package/scripts/smoke/hermes-bootstrap.sh +195 -0
- package/templates/hermes-entrypoint.sh +154 -0
- package/dist/doctor.js.map +0 -1
- package/install/jishu-install-china.sh +0 -3092
- package/public/assets/Dashboard-DhsrzJ4F.js +0 -1
- package/public/assets/InitPassword-BjubiVdd.js +0 -1
- package/public/assets/InstanceDetail-DMcywsof.js +0 -17
- package/public/assets/NewInstance-Bk0G4EiJ.js +0 -1
- package/public/assets/Settings-D5tHL_h5.js +0 -1
- package/public/assets/Setup-4t6E3Rut.js +0 -1
- package/public/assets/index-BJ47MWpF.css +0 -1
- package/public/assets/index-DbX85irc.js +0 -16
- package/public/assets/vendor-i18n-CfW0RvgE.js +0 -9
|
@@ -0,0 +1,2148 @@
|
|
|
1
|
+
import { createHash } from "crypto";
|
|
2
|
+
import { existsSync, mkdtempSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, unlinkSync, writeFileSync, chmodSync, } from "fs";
|
|
3
|
+
import { homedir, tmpdir } from "os";
|
|
4
|
+
import { basename, extname, join, dirname } from "path";
|
|
5
|
+
import { spawn } from "child_process";
|
|
6
|
+
import { fileURLToPath } from "url";
|
|
7
|
+
import { parse, stringify } from "yaml";
|
|
8
|
+
import * as config from "../../config.js";
|
|
9
|
+
import { ensureDirHost } from "../../utils/fs.js";
|
|
10
|
+
import { safeReadJson, safeWriteJson } from "../../utils/safe-json.js";
|
|
11
|
+
import * as legacyInstanceManager from "../instance-manager.js";
|
|
12
|
+
import { createTask, emitTask, getRunningTasks, getTask } from "../task-registry.js";
|
|
13
|
+
import { compileTaskRuntime } from "./app-compiler.js";
|
|
14
|
+
const DEFAULT_LIFECYCLE_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
|
|
15
|
+
function getConfigValue(name) {
|
|
16
|
+
return name in config ? config[name] : undefined;
|
|
17
|
+
}
|
|
18
|
+
function resolveConfigPath(value, fallback) {
|
|
19
|
+
return typeof value === "string" && value.trim() ? value : fallback;
|
|
20
|
+
}
|
|
21
|
+
const JISHUSHELL_HOME = resolveConfigPath(getConfigValue("JISHUSHELL_HOME"), join(process.env.HOME ?? homedir(), ".jishushell"));
|
|
22
|
+
const APPS_DIR = resolveConfigPath(getConfigValue("APPS_DIR"), join(JISHUSHELL_HOME, "apps"));
|
|
23
|
+
const INSTANCES_DIR = resolveConfigPath(getConfigValue("INSTANCES_DIR"), join(JISHUSHELL_HOME, "instances"));
|
|
24
|
+
const CURRENT_JISHUSHELL_VERSION = (() => {
|
|
25
|
+
try {
|
|
26
|
+
const pkg = JSON.parse(readFileSync(new URL("../../../package.json", import.meta.url), "utf-8"));
|
|
27
|
+
return String(pkg.version ?? "0.0.0");
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return "0.0.0";
|
|
31
|
+
}
|
|
32
|
+
})();
|
|
33
|
+
const BUILTIN_SPEC_FILE_RE = /\.ya?ml$/i;
|
|
34
|
+
function builtinAppsDirCandidates() {
|
|
35
|
+
return [
|
|
36
|
+
fileURLToPath(new URL("../../../apps/", import.meta.url)),
|
|
37
|
+
join(process.cwd(), "apps"),
|
|
38
|
+
];
|
|
39
|
+
}
|
|
40
|
+
function resolveBuiltinAppsDir() {
|
|
41
|
+
for (const candidate of builtinAppsDirCandidates()) {
|
|
42
|
+
if (existsSync(candidate))
|
|
43
|
+
return candidate;
|
|
44
|
+
}
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
function parseBuiltinTemplate(fileName, yamlText) {
|
|
48
|
+
let parsed = {};
|
|
49
|
+
try {
|
|
50
|
+
parsed = parse(yamlText) ?? {};
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
const tasks = Array.isArray(parsed.tasks) ? parsed.tasks : [];
|
|
56
|
+
const serviceTasks = tasks.filter((task) => (task?.role ?? "service") === "service");
|
|
57
|
+
const serviceTask = serviceTasks[0] ?? tasks[0] ?? null;
|
|
58
|
+
const serviceRuntime = typeof serviceTask?.runtime === "string" && serviceTask.runtime.trim()
|
|
59
|
+
? serviceTask.runtime.trim()
|
|
60
|
+
: null;
|
|
61
|
+
const id = typeof parsed.id === "string" && parsed.id.trim()
|
|
62
|
+
? parsed.id.trim()
|
|
63
|
+
: basename(fileName, extname(fileName));
|
|
64
|
+
const name = typeof parsed.name === "string" && parsed.name.trim()
|
|
65
|
+
? parsed.name.trim()
|
|
66
|
+
: id;
|
|
67
|
+
const description = typeof parsed.description === "string" ? parsed.description : "";
|
|
68
|
+
const isOllamaTemplate = /^ollama(?:[-_]|$)/i.test(basename(fileName, extname(fileName))) || /^ollama(?:[-_]|$)/i.test(id);
|
|
69
|
+
const suggestedAppType = isOllamaTemplate ? "ollama" : "custom";
|
|
70
|
+
return {
|
|
71
|
+
id,
|
|
72
|
+
fileName,
|
|
73
|
+
name,
|
|
74
|
+
description,
|
|
75
|
+
serviceRuntime,
|
|
76
|
+
instanceCompatible: serviceTasks.length === 1
|
|
77
|
+
&& tasks.every((task) => {
|
|
78
|
+
const runtime = typeof task?.runtime === "string" ? task.runtime.trim() : "";
|
|
79
|
+
return runtime === "container" || runtime === "process";
|
|
80
|
+
})
|
|
81
|
+
&& (tasks.length === 1 || isOllamaTemplate)
|
|
82
|
+
&& (serviceRuntime === "container" || serviceRuntime === "process"),
|
|
83
|
+
suggestedAppType,
|
|
84
|
+
yaml: yamlText,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
export function listBuiltinAppSpecs() {
|
|
88
|
+
const dir = resolveBuiltinAppsDir();
|
|
89
|
+
if (!dir)
|
|
90
|
+
return [];
|
|
91
|
+
return readdirSync(dir)
|
|
92
|
+
.filter((entry) => BUILTIN_SPEC_FILE_RE.test(entry))
|
|
93
|
+
.sort((left, right) => left.localeCompare(right))
|
|
94
|
+
.map((fileName) => parseBuiltinTemplate(fileName, readFileSync(join(dir, fileName), "utf-8")))
|
|
95
|
+
.filter((entry) => entry != null);
|
|
96
|
+
}
|
|
97
|
+
function installedAppDir(instanceId) {
|
|
98
|
+
for (const rootDir of [APPS_DIR, INSTANCES_DIR]) {
|
|
99
|
+
const dir = join(rootDir, instanceId);
|
|
100
|
+
if (existsSync(join(dir, "app-spec.yaml")) && existsSync(join(dir, "manifest.json"))) {
|
|
101
|
+
return dir;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
export function updateInstance(instanceId, name, description) {
|
|
107
|
+
const updatedMeta = legacyInstanceManager.updateInstance(instanceId, name, description);
|
|
108
|
+
const appData = getApp(instanceId);
|
|
109
|
+
const appDir = installedAppDir(instanceId);
|
|
110
|
+
if (appData && appDir) {
|
|
111
|
+
const nextSpec = {
|
|
112
|
+
...appData.spec,
|
|
113
|
+
...(name != null ? { name } : {}),
|
|
114
|
+
...(description != null ? { description } : {}),
|
|
115
|
+
};
|
|
116
|
+
const yamlPath = join(appDir, "app-spec.yaml");
|
|
117
|
+
const yamlTmp = yamlPath + ".tmp";
|
|
118
|
+
writeFileSync(yamlTmp, stringify(nextSpec), { mode: 0o644 });
|
|
119
|
+
renameSync(yamlTmp, yamlPath);
|
|
120
|
+
}
|
|
121
|
+
return legacyInstanceManager.getInstance(instanceId) ?? updatedMeta;
|
|
122
|
+
}
|
|
123
|
+
function parseComparableVersion(version, label) {
|
|
124
|
+
const normalized = version
|
|
125
|
+
.trim()
|
|
126
|
+
.replace(/^>=\s*/, "")
|
|
127
|
+
.replace(/^v/i, "")
|
|
128
|
+
.replace(/[-+].*$/, "");
|
|
129
|
+
const match = normalized.match(/^(\d+)(?:\.(\d+))?(?:\.(\d+))?$/);
|
|
130
|
+
if (!match) {
|
|
131
|
+
throw new Error(`${label} '${version}' 格式无效,应为 x.y.z`);
|
|
132
|
+
}
|
|
133
|
+
return [
|
|
134
|
+
Number(match[1] ?? 0),
|
|
135
|
+
Number(match[2] ?? 0),
|
|
136
|
+
Number(match[3] ?? 0),
|
|
137
|
+
];
|
|
138
|
+
}
|
|
139
|
+
function compareVersions(left, right) {
|
|
140
|
+
for (let index = 0; index < 3; index++) {
|
|
141
|
+
if (left[index] > right[index])
|
|
142
|
+
return 1;
|
|
143
|
+
if (left[index] < right[index])
|
|
144
|
+
return -1;
|
|
145
|
+
}
|
|
146
|
+
return 0;
|
|
147
|
+
}
|
|
148
|
+
function requiredJishuShellVersion(spec) {
|
|
149
|
+
const required = spec.jishushell?.min_version?.trim();
|
|
150
|
+
if (!required) {
|
|
151
|
+
throw new Error(`App '${spec.id}' 缺少 jishushell.min_version,请先在 YAML 中声明支持的最低 JishuShell 版本`);
|
|
152
|
+
}
|
|
153
|
+
return required;
|
|
154
|
+
}
|
|
155
|
+
function ensureCompatibleJishuShellVersion(spec) {
|
|
156
|
+
const required = requiredJishuShellVersion(spec);
|
|
157
|
+
const current = parseComparableVersion(CURRENT_JISHUSHELL_VERSION, "当前 JishuShell 版本");
|
|
158
|
+
const minimum = parseComparableVersion(required, "jishushell.min_version");
|
|
159
|
+
if (compareVersions(current, minimum) < 0) {
|
|
160
|
+
throw new Error(`当前 JishuShell 版本 ${CURRENT_JISHUSHELL_VERSION} 低于应用要求 ${required},请先升级 JishuShell`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
function expandPath(p) {
|
|
164
|
+
if (/^~\/\.jishushell(?:\/|$)/.test(p)) {
|
|
165
|
+
return p.replace(/^~\/\.jishushell(?=\/|$)/, JISHUSHELL_HOME);
|
|
166
|
+
}
|
|
167
|
+
return p.replace(/^~(?=\/|$)/, process.env.HOME ?? homedir());
|
|
168
|
+
}
|
|
169
|
+
function buildLifecycleEnv() {
|
|
170
|
+
const mergedPath = `${process.env.PATH ?? ""}:${DEFAULT_LIFECYCLE_PATH}`
|
|
171
|
+
.split(":")
|
|
172
|
+
.map((entry) => entry.trim())
|
|
173
|
+
.filter(Boolean);
|
|
174
|
+
return {
|
|
175
|
+
...process.env,
|
|
176
|
+
HOME: process.env.HOME ?? homedir(),
|
|
177
|
+
PATH: [...new Set(mergedPath)].join(":"),
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
const SUDO_PASSTHROUGH_ENV_KEYS = ["HOME", "PATH", "TMPDIR", "TMP", "TEMP", "XDG_RUNTIME_DIR"];
|
|
181
|
+
function isSudoAuthenticationError(message) {
|
|
182
|
+
return /incorrect password|try again|authentication failure|密码错误|抱歉,请重试/i.test(message);
|
|
183
|
+
}
|
|
184
|
+
function isSudoNoNewPrivilegesError(message) {
|
|
185
|
+
return /no new privileges/i.test(message);
|
|
186
|
+
}
|
|
187
|
+
function isSudoPasswordRequiredError(message) {
|
|
188
|
+
return /password is required|a password is required/i.test(message);
|
|
189
|
+
}
|
|
190
|
+
function buildSudoWrappedCommand(cmd, args, env, execOptions) {
|
|
191
|
+
const sudoArgs = execOptions?.sudoPassword ? ["-k", "-A"] : ["-n"];
|
|
192
|
+
const envArgs = SUDO_PASSTHROUGH_ENV_KEYS.flatMap((key) => {
|
|
193
|
+
const value = env[key];
|
|
194
|
+
return typeof value === "string" && value.length > 0 ? [`${key}=${value}`] : [];
|
|
195
|
+
});
|
|
196
|
+
return {
|
|
197
|
+
command: "sudo",
|
|
198
|
+
args: [...sudoArgs, "--", "env", ...envArgs, cmd, ...args],
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
function createLifecycleSudoError(stderr, fallbackDisplay, hasPassword) {
|
|
202
|
+
const message = sanitizeTaskLine(stderr).trim();
|
|
203
|
+
if (isSudoNoNewPrivilegesError(message)) {
|
|
204
|
+
return createNoNewPrivilegesSudoError();
|
|
205
|
+
}
|
|
206
|
+
if (isSudoAuthenticationError(message)) {
|
|
207
|
+
return new Error("sudo 密码错误,请重新输入。");
|
|
208
|
+
}
|
|
209
|
+
if (!hasPassword && isSudoPasswordRequiredError(message)) {
|
|
210
|
+
return new Error("该生命周期步骤需要 sudo 密码;请在页面弹窗中输入后重试。");
|
|
211
|
+
}
|
|
212
|
+
if (message) {
|
|
213
|
+
return new Error(message);
|
|
214
|
+
}
|
|
215
|
+
return new Error(`lifecycle sudo step failed: ${fallbackDisplay}`);
|
|
216
|
+
}
|
|
217
|
+
function panelSystemdServicePath() {
|
|
218
|
+
const override = process.env.JISHUSHELL_PANEL_SYSTEMD_SERVICE_PATH?.trim();
|
|
219
|
+
return override || "/etc/systemd/system/jishushell.service";
|
|
220
|
+
}
|
|
221
|
+
function isLikelySystemdServiceProcess() {
|
|
222
|
+
return process.platform === "linux"
|
|
223
|
+
&& Boolean(process.env.INVOCATION_ID || process.env.JOURNAL_STREAM || process.env.NOTIFY_SOCKET);
|
|
224
|
+
}
|
|
225
|
+
function maybeRepairPanelAutostartNoNewPrivileges() {
|
|
226
|
+
if (!isLikelySystemdServiceProcess())
|
|
227
|
+
return null;
|
|
228
|
+
const servicePath = panelSystemdServicePath();
|
|
229
|
+
if (!existsSync(servicePath))
|
|
230
|
+
return null;
|
|
231
|
+
let unitText = "";
|
|
232
|
+
try {
|
|
233
|
+
unitText = readFileSync(servicePath, "utf-8");
|
|
234
|
+
}
|
|
235
|
+
catch {
|
|
236
|
+
return { servicePath, detected: true, updated: false };
|
|
237
|
+
}
|
|
238
|
+
if (!/^\s*NoNewPrivileges\s*=\s*true\s*$/mi.test(unitText)) {
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
241
|
+
const nextText = unitText.replace(/^\s*NoNewPrivileges\s*=\s*true\s*\n?/gim, "");
|
|
242
|
+
if (nextText === unitText) {
|
|
243
|
+
return { servicePath, detected: true, updated: false };
|
|
244
|
+
}
|
|
245
|
+
try {
|
|
246
|
+
writeFileSync(servicePath, nextText);
|
|
247
|
+
return { servicePath, detected: true, updated: true };
|
|
248
|
+
}
|
|
249
|
+
catch {
|
|
250
|
+
return { servicePath, detected: true, updated: false };
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
function manualInstallCommandForSpec(spec) {
|
|
254
|
+
if (spec.id === "ollama-binary") {
|
|
255
|
+
return "jishushell app install ollama";
|
|
256
|
+
}
|
|
257
|
+
const builtin = listBuiltinAppSpecs().find((entry) => entry.id === spec.id);
|
|
258
|
+
if (!builtin)
|
|
259
|
+
return null;
|
|
260
|
+
return `jishushell app install ${spec.id}`;
|
|
261
|
+
}
|
|
262
|
+
function createNoNewPrivilegesSudoError(manualInstallCommand) {
|
|
263
|
+
const repair = maybeRepairPanelAutostartNoNewPrivileges();
|
|
264
|
+
const restartCommand = "sudo systemctl daemon-reload && sudo systemctl restart jishushell";
|
|
265
|
+
const parts = ["当前运行环境禁止 sudo 提权(no new privileges),面板内无法继续后续安装。"];
|
|
266
|
+
if (repair?.updated) {
|
|
267
|
+
parts.push(`已从自启文件 ${repair.servicePath} 移除 NoNewPrivileges=true。请在系统终端执行以下命令后重试:\n${restartCommand}`);
|
|
268
|
+
}
|
|
269
|
+
else if (repair?.detected) {
|
|
270
|
+
parts.push(`检测到自启文件 ${repair.servicePath} 仍包含 NoNewPrivileges=true。请在系统终端删除该行后执行:\n${restartCommand}`);
|
|
271
|
+
}
|
|
272
|
+
if (manualInstallCommand) {
|
|
273
|
+
parts.push(`当前安装已停止。你也可以在系统终端手动执行 ${manualInstallCommand}。`);
|
|
274
|
+
}
|
|
275
|
+
return new Error(parts.join("\n"));
|
|
276
|
+
}
|
|
277
|
+
function decorateInstallError(error, spec) {
|
|
278
|
+
const original = error instanceof Error ? error : new Error(String(error));
|
|
279
|
+
if (!isSudoNoNewPrivilegesError(original.message) && !/NoNewPrivileges=true/i.test(original.message)) {
|
|
280
|
+
return original;
|
|
281
|
+
}
|
|
282
|
+
return createNoNewPrivilegesSudoError(manualInstallCommandForSpec(spec) ?? undefined);
|
|
283
|
+
}
|
|
284
|
+
export async function validateSudoPassword(sudoPassword) {
|
|
285
|
+
if (!sudoPassword) {
|
|
286
|
+
throw new Error("请输入 sudo 密码");
|
|
287
|
+
}
|
|
288
|
+
if (typeof process.getuid === "function" && process.getuid() === 0) {
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
const preparedEnv = prepareLifecycleExecEnv({ sudoPassword });
|
|
292
|
+
try {
|
|
293
|
+
await new Promise((resolve, reject) => {
|
|
294
|
+
const child = spawn("sudo", ["-k", "-A", "true"], {
|
|
295
|
+
stdio: ["ignore", "ignore", "pipe"],
|
|
296
|
+
env: preparedEnv.env,
|
|
297
|
+
timeout: 15_000,
|
|
298
|
+
});
|
|
299
|
+
let stderr = "";
|
|
300
|
+
child.stderr?.on("data", (chunk) => {
|
|
301
|
+
stderr += chunk.toString("utf-8");
|
|
302
|
+
});
|
|
303
|
+
child.on("close", (code) => {
|
|
304
|
+
if (code === 0) {
|
|
305
|
+
resolve();
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
const message = sanitizeTaskLine(stderr).trim();
|
|
309
|
+
if (isSudoNoNewPrivilegesError(message)) {
|
|
310
|
+
reject(createNoNewPrivilegesSudoError());
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
if (/incorrect password|try again|authentication failure|密码错误|抱歉,请重试/i.test(message)) {
|
|
314
|
+
reject(new Error("sudo 密码错误,请重新输入。"));
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
resolve();
|
|
318
|
+
});
|
|
319
|
+
child.on("error", (error) => {
|
|
320
|
+
resolve();
|
|
321
|
+
});
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
finally {
|
|
325
|
+
preparedEnv.cleanup();
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
function prepareLifecycleExecEnv(execOptions) {
|
|
329
|
+
const env = buildLifecycleEnv();
|
|
330
|
+
const sudoPassword = execOptions?.sudoPassword;
|
|
331
|
+
if (!sudoPassword) {
|
|
332
|
+
return { env, cleanup: () => undefined };
|
|
333
|
+
}
|
|
334
|
+
const helperDir = mkdtempSync(join(tmpdir(), "jishushell-sudo-askpass-"));
|
|
335
|
+
const passwordPath = join(helperDir, "password.txt");
|
|
336
|
+
const askpassPath = join(helperDir, "askpass.sh");
|
|
337
|
+
writeFileSync(passwordPath, sudoPassword, { mode: 0o600 });
|
|
338
|
+
// Use an absolute password-file path in the helper script so sudo env
|
|
339
|
+
// filtering cannot drop the file path and cause false password failures.
|
|
340
|
+
writeFileSync(askpassPath, `#!/bin/sh\ncat '${passwordPath}'\nprintf '\\n'\n`, { mode: 0o700 });
|
|
341
|
+
chmodSync(askpassPath, 0o700);
|
|
342
|
+
return {
|
|
343
|
+
env: {
|
|
344
|
+
...env,
|
|
345
|
+
SUDO_ASKPASS: askpassPath,
|
|
346
|
+
JISHUSHELL_SUDO_ASKPASS: "1",
|
|
347
|
+
},
|
|
348
|
+
cleanup: () => {
|
|
349
|
+
try {
|
|
350
|
+
rmSync(helperDir, { recursive: true, force: true });
|
|
351
|
+
}
|
|
352
|
+
catch {
|
|
353
|
+
// best effort cleanup for one-shot askpass helper files
|
|
354
|
+
}
|
|
355
|
+
},
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
const ANSI_ESCAPE_RE = /\u001B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g;
|
|
359
|
+
function sanitizeTaskLine(line) {
|
|
360
|
+
return line
|
|
361
|
+
.replace(ANSI_ESCAPE_RE, "")
|
|
362
|
+
.replace(/[\u0000-\u0008\u000B-\u001F\u007F]/g, "")
|
|
363
|
+
.trimEnd();
|
|
364
|
+
}
|
|
365
|
+
function emitInstallTaskLog(task, message) {
|
|
366
|
+
if (!task)
|
|
367
|
+
return;
|
|
368
|
+
const line = sanitizeTaskLine(message).trim();
|
|
369
|
+
if (!line)
|
|
370
|
+
return;
|
|
371
|
+
emitTask(task, { type: "log", message: line });
|
|
372
|
+
}
|
|
373
|
+
function emitAppTaskProgress(task, message, progress) {
|
|
374
|
+
if (!task)
|
|
375
|
+
return;
|
|
376
|
+
const line = sanitizeTaskLine(message).trim();
|
|
377
|
+
if (!line)
|
|
378
|
+
return;
|
|
379
|
+
emitTask(task, { type: "progress", message: line, ...(typeof progress === "number" ? { progress } : {}) });
|
|
380
|
+
}
|
|
381
|
+
function normalizePortVisibility(visibility) {
|
|
382
|
+
if (!visibility || visibility === "external" || visibility === "public") {
|
|
383
|
+
return "external";
|
|
384
|
+
}
|
|
385
|
+
if (visibility === "internal") {
|
|
386
|
+
return visibility;
|
|
387
|
+
}
|
|
388
|
+
throw new Error(`port visibility '${visibility}' 仅支持 external 或 internal`);
|
|
389
|
+
}
|
|
390
|
+
function normalizeAppSpec(spec) {
|
|
391
|
+
return {
|
|
392
|
+
...spec,
|
|
393
|
+
tasks: (spec.tasks ?? []).map((task) => {
|
|
394
|
+
const rawTask = { ...task };
|
|
395
|
+
if (!rawTask.role) {
|
|
396
|
+
rawTask.role = "service";
|
|
397
|
+
}
|
|
398
|
+
if (!rawTask.command && rawTask.binary) {
|
|
399
|
+
rawTask.command = rawTask.binary;
|
|
400
|
+
}
|
|
401
|
+
if (Array.isArray(rawTask.ports)) {
|
|
402
|
+
rawTask.ports = rawTask.ports.map((port) => ({
|
|
403
|
+
...port,
|
|
404
|
+
visibility: normalizePortVisibility(port.visibility),
|
|
405
|
+
}));
|
|
406
|
+
}
|
|
407
|
+
return rawTask;
|
|
408
|
+
}),
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
function imageReferencedByOtherInstalledApps(currentAppId, imagePath) {
|
|
412
|
+
return listApps().some((app) => app.manifest.id !== currentAppId && (app.spec.tasks.some((task) => task.image === imagePath)
|
|
413
|
+
|| app.manifest.artifacts?.some((artifact) => artifact.type === "image" && artifact.path === imagePath)
|
|
414
|
+
|| app.spec.lifecycle?.install?.some((step) => "downloadImage" in step && step.downloadImage === imagePath)));
|
|
415
|
+
}
|
|
416
|
+
function shouldDeleteImageForApp(appData, imagePath) {
|
|
417
|
+
if (appData.spec.singleInstance === false)
|
|
418
|
+
return false;
|
|
419
|
+
return !imageReferencedByOtherInstalledApps(appData.manifest.id, imagePath);
|
|
420
|
+
}
|
|
421
|
+
function selectUninstallLifecycleSteps(appData) {
|
|
422
|
+
if (!appData?.spec.lifecycle?.uninstall?.length)
|
|
423
|
+
return undefined;
|
|
424
|
+
return appData.spec.lifecycle.uninstall.filter((step) => !("deleteImage" in step) || shouldDeleteImageForApp(appData, step.deleteImage));
|
|
425
|
+
}
|
|
426
|
+
function selectCleanupArtifacts(appData) {
|
|
427
|
+
if (!appData?.manifest.artifacts?.length)
|
|
428
|
+
return [];
|
|
429
|
+
const lifecycleImageDeletes = new Set((selectUninstallLifecycleSteps(appData) ?? [])
|
|
430
|
+
.flatMap((step) => ("deleteImage" in step ? [step.deleteImage] : [])));
|
|
431
|
+
return appData.manifest.artifacts.filter((artifact) => {
|
|
432
|
+
if (artifact.type !== "image")
|
|
433
|
+
return true;
|
|
434
|
+
if (lifecycleImageDeletes.has(artifact.path))
|
|
435
|
+
return false;
|
|
436
|
+
return shouldDeleteImageForApp(appData, artifact.path);
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
async function deleteLinkedInstances(appId) {
|
|
440
|
+
const warnings = [];
|
|
441
|
+
const instanceManager = await import("../instance-manager.js");
|
|
442
|
+
const linkedInstances = instanceManager
|
|
443
|
+
.listInstances()
|
|
444
|
+
.filter((instance) => instance?.id && instance.id !== appId && instance.app_id === appId);
|
|
445
|
+
if (linkedInstances.length === 0)
|
|
446
|
+
return warnings;
|
|
447
|
+
const { stopNomadJobInstance: stopInstance } = await import("../nomad-manager.js");
|
|
448
|
+
let processManager = null;
|
|
449
|
+
try {
|
|
450
|
+
processManager = await import("../process-manager.js");
|
|
451
|
+
}
|
|
452
|
+
catch {
|
|
453
|
+
processManager = null;
|
|
454
|
+
}
|
|
455
|
+
for (const instance of linkedInstances) {
|
|
456
|
+
const instanceId = String(instance.id);
|
|
457
|
+
let stopFailed = false;
|
|
458
|
+
try {
|
|
459
|
+
const stopped = await stopInstance(instanceId, true);
|
|
460
|
+
if (!stopped.ok) {
|
|
461
|
+
const fallback = await stopInstance(instanceId);
|
|
462
|
+
if (!fallback.ok)
|
|
463
|
+
stopFailed = true;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
catch {
|
|
467
|
+
stopFailed = true;
|
|
468
|
+
}
|
|
469
|
+
if (processManager) {
|
|
470
|
+
try {
|
|
471
|
+
if ((await processManager.getLegacyStatus(instanceId)).status === "running") {
|
|
472
|
+
await processManager.stopInstance(instanceId);
|
|
473
|
+
stopFailed = false;
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
catch {
|
|
477
|
+
// best effort
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
const result = await instanceManager.deleteInstance(instanceId);
|
|
481
|
+
if (!result.ok) {
|
|
482
|
+
warnings.push(`关联实例 '${instanceId}' 删除失败`);
|
|
483
|
+
}
|
|
484
|
+
if (result.warnings?.length) {
|
|
485
|
+
warnings.push(...result.warnings.map((warning) => `关联实例 '${instanceId}': ${warning}`));
|
|
486
|
+
}
|
|
487
|
+
if (stopFailed) {
|
|
488
|
+
warnings.push(`关联实例 '${instanceId}' 停止失败,可能残留进程`);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
return warnings;
|
|
492
|
+
}
|
|
493
|
+
function getProvidePort(spec, provide) {
|
|
494
|
+
if (typeof provide.url === "string" && provide.url.trim()) {
|
|
495
|
+
return null;
|
|
496
|
+
}
|
|
497
|
+
if (typeof provide.port === "number") {
|
|
498
|
+
return provide.port;
|
|
499
|
+
}
|
|
500
|
+
const firstPort = spec.tasks.find((task) => task.role === "service")?.ports?.[0];
|
|
501
|
+
if (!firstPort)
|
|
502
|
+
return null;
|
|
503
|
+
return firstPort.host_port ?? firstPort.port;
|
|
504
|
+
}
|
|
505
|
+
function getProvideUrl(provide) {
|
|
506
|
+
const raw = typeof provide.url === "string" ? provide.url.trim() : "";
|
|
507
|
+
if (!raw)
|
|
508
|
+
return null;
|
|
509
|
+
try {
|
|
510
|
+
const parsed = new URL(raw);
|
|
511
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
512
|
+
return null;
|
|
513
|
+
}
|
|
514
|
+
return parsed.toString();
|
|
515
|
+
}
|
|
516
|
+
catch {
|
|
517
|
+
return null;
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
function buildCapabilityAddress(port, path) {
|
|
521
|
+
const host = legacyInstanceManager.getAdvertisedHostForPort(port);
|
|
522
|
+
if (!path) {
|
|
523
|
+
return `${host}:${port}`;
|
|
524
|
+
}
|
|
525
|
+
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
|
|
526
|
+
return `${host}:${port}${normalizedPath}`;
|
|
527
|
+
}
|
|
528
|
+
function normalizeProvideProtocol(protocol) {
|
|
529
|
+
const raw = typeof protocol === "string" ? protocol.trim().toLowerCase() : "";
|
|
530
|
+
return raw || "http";
|
|
531
|
+
}
|
|
532
|
+
function resolveProvideProtocol(provide) {
|
|
533
|
+
const url = getProvideUrl(provide);
|
|
534
|
+
if (url) {
|
|
535
|
+
try {
|
|
536
|
+
return new URL(url).protocol.replace(/:$/, "").toLowerCase();
|
|
537
|
+
}
|
|
538
|
+
catch {
|
|
539
|
+
return normalizeProvideProtocol(provide.protocol);
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
return normalizeProvideProtocol(provide.protocol);
|
|
543
|
+
}
|
|
544
|
+
function collectDeclaredPorts(spec) {
|
|
545
|
+
return spec.tasks.flatMap((task) => (task.ports ?? [])
|
|
546
|
+
.map((port) => port.host_port ?? port.port)
|
|
547
|
+
.filter((port) => Number.isInteger(port) && port > 0 && port <= 65535));
|
|
548
|
+
}
|
|
549
|
+
function buildPortShiftMap(spec, offset) {
|
|
550
|
+
const portShiftMap = new Map();
|
|
551
|
+
for (const port of collectDeclaredPorts(spec)) {
|
|
552
|
+
const shiftedPort = port + offset;
|
|
553
|
+
if (shiftedPort < 1 || shiftedPort > 65535) {
|
|
554
|
+
throw new Error(`App '${spec.id}' 没有可用端口,端口递增已超出 65535`);
|
|
555
|
+
}
|
|
556
|
+
portShiftMap.set(port, shiftedPort);
|
|
557
|
+
}
|
|
558
|
+
return portShiftMap;
|
|
559
|
+
}
|
|
560
|
+
function replaceAppScopedPaths(value, baseId, appId) {
|
|
561
|
+
if (baseId === appId)
|
|
562
|
+
return value;
|
|
563
|
+
const homeDir = homedir();
|
|
564
|
+
const replacements = [
|
|
565
|
+
[`~/.jishushell/apps/${baseId}`, `~/.jishushell/apps/${appId}`],
|
|
566
|
+
[`$HOME/.jishushell/apps/${baseId}`, `$HOME/.jishushell/apps/${appId}`],
|
|
567
|
+
[`${homeDir}/.jishushell/apps/${baseId}`, `${homeDir}/.jishushell/apps/${appId}`],
|
|
568
|
+
[`\${HOME}/.jishushell/apps/${baseId}`, `\${HOME}/.jishushell/apps/${appId}`],
|
|
569
|
+
];
|
|
570
|
+
let rewritten = value;
|
|
571
|
+
for (const [from, to] of replacements) {
|
|
572
|
+
rewritten = rewritten.split(from).join(to);
|
|
573
|
+
}
|
|
574
|
+
return rewritten;
|
|
575
|
+
}
|
|
576
|
+
function replaceAppIdTokens(value, appId) {
|
|
577
|
+
return value
|
|
578
|
+
.replace(/\$\{app_id\}/g, appId)
|
|
579
|
+
.replace(/\$\{app\.id\}/g, appId);
|
|
580
|
+
}
|
|
581
|
+
function replacePortTokens(value, portShiftMap) {
|
|
582
|
+
let rewritten = value;
|
|
583
|
+
const replacements = [...portShiftMap.entries()].sort((left, right) => right[0] - left[0]);
|
|
584
|
+
for (const [fromPort, toPort] of replacements) {
|
|
585
|
+
const pattern = new RegExp(`(^|[^0-9A-Za-z])${fromPort}($|[^0-9A-Za-z])`, "g");
|
|
586
|
+
rewritten = rewritten.replace(pattern, (_match, prefix, suffix) => `${prefix}${toPort}${suffix}`);
|
|
587
|
+
}
|
|
588
|
+
return rewritten;
|
|
589
|
+
}
|
|
590
|
+
function rewriteInstalledSpecValue(value, baseId, appId, portShiftMap) {
|
|
591
|
+
if (typeof value === "string") {
|
|
592
|
+
const hasAppIdToken = value.includes("${app_id}") || value.includes("${app.id}");
|
|
593
|
+
return replacePortTokens(hasAppIdToken
|
|
594
|
+
? replaceAppIdTokens(value, appId)
|
|
595
|
+
: replaceAppScopedPaths(value, baseId, appId), portShiftMap);
|
|
596
|
+
}
|
|
597
|
+
if (typeof value === "number") {
|
|
598
|
+
return portShiftMap.get(value) ?? value;
|
|
599
|
+
}
|
|
600
|
+
if (Array.isArray(value)) {
|
|
601
|
+
return value.map((entry) => rewriteInstalledSpecValue(entry, baseId, appId, portShiftMap));
|
|
602
|
+
}
|
|
603
|
+
if (value && typeof value === "object") {
|
|
604
|
+
const rewritten = {};
|
|
605
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
606
|
+
rewritten[key] = rewriteInstalledSpecValue(entry, baseId, appId, portShiftMap);
|
|
607
|
+
}
|
|
608
|
+
return rewritten;
|
|
609
|
+
}
|
|
610
|
+
return value;
|
|
611
|
+
}
|
|
612
|
+
function materializeInstalledSpec(spec, appId, offset) {
|
|
613
|
+
const portShiftMap = buildPortShiftMap(spec, offset);
|
|
614
|
+
const rewritten = rewriteInstalledSpecValue(spec, spec.id, appId, portShiftMap);
|
|
615
|
+
const derivedName = deriveInstalledDisplayName(spec, appId);
|
|
616
|
+
return normalizeAppSpec({
|
|
617
|
+
...rewritten,
|
|
618
|
+
id: spec.id,
|
|
619
|
+
...(derivedName ? { name: derivedName } : {}),
|
|
620
|
+
});
|
|
621
|
+
}
|
|
622
|
+
function deriveInstalledDisplayName(spec, appId) {
|
|
623
|
+
const baseName = typeof spec.name === "string" && spec.name.trim() ? spec.name.trim() : spec.id;
|
|
624
|
+
if (!baseName)
|
|
625
|
+
return appId;
|
|
626
|
+
if (appId === spec.id)
|
|
627
|
+
return baseName;
|
|
628
|
+
if (!appId.startsWith(`${spec.id}-`))
|
|
629
|
+
return baseName;
|
|
630
|
+
const suffix = appId.slice(spec.id.length + 1).trim();
|
|
631
|
+
return suffix ? `${baseName}-${suffix}` : baseName;
|
|
632
|
+
}
|
|
633
|
+
function formatInstalledAppId(baseId, instancePrefix, offset) {
|
|
634
|
+
if (offset === 0)
|
|
635
|
+
return baseId;
|
|
636
|
+
return `${baseId}-${instancePrefix}${offset}`;
|
|
637
|
+
}
|
|
638
|
+
// Delegate to the canonical multi-locus probe in instance-manager so macOS
|
|
639
|
+
// loopback-only listeners (e.g. a natively-installed openclaw bound to
|
|
640
|
+
// 127.0.0.1:18789) are visible to AppSpec port conflict detection as well.
|
|
641
|
+
// Wrap in a thunk so the lookup happens at call time — some test suites
|
|
642
|
+
// partially mock instance-manager without re-exporting this symbol.
|
|
643
|
+
function isPortInUse(port) {
|
|
644
|
+
return legacyInstanceManager.isPortInUse(port);
|
|
645
|
+
}
|
|
646
|
+
async function resolveInstallTarget(spec, originalSpecYaml, requestedAppId) {
|
|
647
|
+
const baseId = spec.id;
|
|
648
|
+
const multiInstance = spec.singleInstance === false;
|
|
649
|
+
const explicitAppId = requestedAppId?.trim();
|
|
650
|
+
if (explicitAppId && !APP_ID_RE.test(explicitAppId)) {
|
|
651
|
+
throw new Error(`App id '${explicitAppId}' 格式无效,必须符合 /^[a-z0-9][a-z0-9-]{0,62}$/`);
|
|
652
|
+
}
|
|
653
|
+
const installedSameSpec = listApps().find((app) => app.spec.id === baseId);
|
|
654
|
+
if (!multiInstance) {
|
|
655
|
+
if (installedSameSpec) {
|
|
656
|
+
const err = new Error(`App '${baseId}' 已安装为 '${installedSameSpec.manifest.id}',如需多实例请设置 singleInstance: false`);
|
|
657
|
+
err.code = 409;
|
|
658
|
+
throw err;
|
|
659
|
+
}
|
|
660
|
+
const appId = explicitAppId ?? baseId;
|
|
661
|
+
if (appId !== baseId && resolveAppDir(appId)) {
|
|
662
|
+
const err = new Error(`App '${appId}' 已安装`);
|
|
663
|
+
err.code = 409;
|
|
664
|
+
throw err;
|
|
665
|
+
}
|
|
666
|
+
const installedSpec = materializeInstalledSpec(spec, appId, 0);
|
|
667
|
+
return {
|
|
668
|
+
appId,
|
|
669
|
+
installedSpec,
|
|
670
|
+
installedSpecYaml: stringify(installedSpec),
|
|
671
|
+
};
|
|
672
|
+
}
|
|
673
|
+
const usedPorts = new Set();
|
|
674
|
+
for (const installedApp of listApps()) {
|
|
675
|
+
for (const port of collectDeclaredPorts(installedApp.spec)) {
|
|
676
|
+
usedPorts.add(port);
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
const instancePrefix = spec.instancePrefix ?? "";
|
|
680
|
+
const declaredPorts = collectDeclaredPorts(spec);
|
|
681
|
+
const maxBasePort = declaredPorts.length > 0 ? Math.max(...declaredPorts) : 0;
|
|
682
|
+
const maxOffset = declaredPorts.length > 0 ? 65535 - maxBasePort : 9999;
|
|
683
|
+
if (explicitAppId) {
|
|
684
|
+
if (resolveAppDir(explicitAppId)) {
|
|
685
|
+
const err = new Error(`App '${explicitAppId}' 已安装`);
|
|
686
|
+
err.code = 409;
|
|
687
|
+
throw err;
|
|
688
|
+
}
|
|
689
|
+
for (let offset = 0; offset <= maxOffset; offset++) {
|
|
690
|
+
const installedSpec = materializeInstalledSpec(spec, explicitAppId, offset);
|
|
691
|
+
const candidatePorts = collectDeclaredPorts(installedSpec);
|
|
692
|
+
let portConflict = false;
|
|
693
|
+
for (const port of candidatePorts) {
|
|
694
|
+
if (usedPorts.has(port) || await isPortInUse(port)) {
|
|
695
|
+
portConflict = true;
|
|
696
|
+
break;
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
if (portConflict)
|
|
700
|
+
continue;
|
|
701
|
+
return {
|
|
702
|
+
appId: explicitAppId,
|
|
703
|
+
installedSpec,
|
|
704
|
+
installedSpecYaml: stringify(installedSpec),
|
|
705
|
+
};
|
|
706
|
+
}
|
|
707
|
+
throw new Error(`App '${baseId}' 使用指定 id '${explicitAppId}' 时没有可用端口槽位`);
|
|
708
|
+
}
|
|
709
|
+
for (let offset = 0; offset <= maxOffset; offset++) {
|
|
710
|
+
const appId = formatInstalledAppId(baseId, instancePrefix, offset);
|
|
711
|
+
if (!APP_ID_RE.test(appId)) {
|
|
712
|
+
throw new Error(`自动生成的 App id '${appId}' 超出格式限制,请缩短 id 或 instancePrefix`);
|
|
713
|
+
}
|
|
714
|
+
if (existsSync(appDirForId(appId)))
|
|
715
|
+
continue;
|
|
716
|
+
const installedSpec = materializeInstalledSpec(spec, appId, offset);
|
|
717
|
+
const candidatePorts = collectDeclaredPorts(installedSpec);
|
|
718
|
+
let portConflict = false;
|
|
719
|
+
for (const port of candidatePorts) {
|
|
720
|
+
if (usedPorts.has(port) || await isPortInUse(port)) {
|
|
721
|
+
portConflict = true;
|
|
722
|
+
break;
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
if (portConflict)
|
|
726
|
+
continue;
|
|
727
|
+
return {
|
|
728
|
+
appId,
|
|
729
|
+
installedSpec,
|
|
730
|
+
installedSpecYaml: offset === 0 ? originalSpecYaml : stringify(installedSpec),
|
|
731
|
+
};
|
|
732
|
+
}
|
|
733
|
+
throw new Error(`App '${baseId}' 没有可用安装槽位,目录名或端口已全部占用`);
|
|
734
|
+
}
|
|
735
|
+
function spawnStep(label, display, cmd, args, task) {
|
|
736
|
+
return spawnStepWithTimeout(label, display, display, cmd, args, 300_000, task);
|
|
737
|
+
}
|
|
738
|
+
const DOCKER_PULL_RETRY_ATTEMPTS = 3;
|
|
739
|
+
// spawnStep's 5-min default is fine for small CLI calls but starves real-world
|
|
740
|
+
// image pulls on ARM + SD-card hardware. Playwright v1.55.0-noble is ~2.3 GB
|
|
741
|
+
// compressed (bundles Chromium + Firefox + WebKit), Hermes ~2.4 GB — neither
|
|
742
|
+
// extracts in 5 min on a Raspberry Pi. 30 min clears both with headroom while
|
|
743
|
+
// still capping runaway failures (total retry budget 90 min).
|
|
744
|
+
const DOCKER_PULL_TIMEOUT_MS = 1_800_000;
|
|
745
|
+
async function pullDockerImageStep(label, image, display, task, timeoutMs = DOCKER_PULL_TIMEOUT_MS) {
|
|
746
|
+
let lastError;
|
|
747
|
+
for (let attempt = 1; attempt <= DOCKER_PULL_RETRY_ATTEMPTS; attempt++) {
|
|
748
|
+
try {
|
|
749
|
+
await spawnStepWithTimeout(label, display, display, "docker", ["pull", image], timeoutMs, task);
|
|
750
|
+
return;
|
|
751
|
+
}
|
|
752
|
+
catch (error) {
|
|
753
|
+
if (await dockerImageExists(image)) {
|
|
754
|
+
const recoveredMessage = `[lifecycle:${label}] docker image '${image}' is present locally after pull failure/timeout; treating step as successful`;
|
|
755
|
+
process.stdout.write(` ${recoveredMessage}\n`);
|
|
756
|
+
emitInstallTaskLog(task, recoveredMessage);
|
|
757
|
+
return;
|
|
758
|
+
}
|
|
759
|
+
lastError = error;
|
|
760
|
+
if (attempt === DOCKER_PULL_RETRY_ATTEMPTS) {
|
|
761
|
+
break;
|
|
762
|
+
}
|
|
763
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
764
|
+
const retryMessage = `[lifecycle:${label}] docker pull failed for ${image} (attempt ${attempt}/${DOCKER_PULL_RETRY_ATTEMPTS}): ${reason}; retrying`;
|
|
765
|
+
process.stdout.write(` ${retryMessage}\n`);
|
|
766
|
+
emitInstallTaskLog(task, retryMessage);
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
throw (lastError instanceof Error ? lastError : new Error(String(lastError)));
|
|
770
|
+
}
|
|
771
|
+
async function dockerImageExists(image) {
|
|
772
|
+
return new Promise((resolve) => {
|
|
773
|
+
const child = spawn("docker", ["image", "inspect", image], {
|
|
774
|
+
stdio: "ignore",
|
|
775
|
+
env: buildLifecycleEnv(),
|
|
776
|
+
});
|
|
777
|
+
child.on("close", (code) => resolve(code === 0));
|
|
778
|
+
child.on("error", () => resolve(false));
|
|
779
|
+
});
|
|
780
|
+
}
|
|
781
|
+
function spawnStepWithTimeout(label, display, taskDisplay, cmd, args, timeoutMs, task, execOptions, sudo) {
|
|
782
|
+
process.stdout.write(` [lifecycle:${label}] ${display}\n`);
|
|
783
|
+
emitInstallTaskLog(task, `[lifecycle:${label}] ${taskDisplay}`);
|
|
784
|
+
return new Promise((resolve, reject) => {
|
|
785
|
+
const preparedEnv = prepareLifecycleExecEnv(sudo ? execOptions : undefined);
|
|
786
|
+
let cleaned = false;
|
|
787
|
+
let heartbeatTimer = null;
|
|
788
|
+
let stdoutPending = "";
|
|
789
|
+
let stderrPending = "";
|
|
790
|
+
let capturedStderr = "";
|
|
791
|
+
const cleanupPreparedEnv = () => {
|
|
792
|
+
if (cleaned)
|
|
793
|
+
return;
|
|
794
|
+
cleaned = true;
|
|
795
|
+
if (heartbeatTimer) {
|
|
796
|
+
clearInterval(heartbeatTimer);
|
|
797
|
+
heartbeatTimer = null;
|
|
798
|
+
}
|
|
799
|
+
preparedEnv.cleanup();
|
|
800
|
+
};
|
|
801
|
+
const spawnTarget = sudo
|
|
802
|
+
? buildSudoWrappedCommand(cmd, args, preparedEnv.env, execOptions)
|
|
803
|
+
: { command: cmd, args };
|
|
804
|
+
const captureOutput = Boolean(task) || Boolean(sudo);
|
|
805
|
+
const child = spawn(spawnTarget.command, spawnTarget.args, {
|
|
806
|
+
stdio: captureOutput ? ["ignore", "pipe", "pipe"] : "inherit",
|
|
807
|
+
timeout: timeoutMs,
|
|
808
|
+
env: preparedEnv.env,
|
|
809
|
+
});
|
|
810
|
+
if (captureOutput) {
|
|
811
|
+
const startedAt = Date.now();
|
|
812
|
+
const flushPendingLine = (line) => {
|
|
813
|
+
if (!task)
|
|
814
|
+
return;
|
|
815
|
+
emitInstallTaskLog(task, line);
|
|
816
|
+
};
|
|
817
|
+
const handleChunk = (chunk, stream) => {
|
|
818
|
+
const text = typeof chunk === "string" ? chunk : chunk.toString("utf-8");
|
|
819
|
+
if (stream === "stderr") {
|
|
820
|
+
capturedStderr += text;
|
|
821
|
+
}
|
|
822
|
+
if (!task) {
|
|
823
|
+
if (stream === "stdout")
|
|
824
|
+
process.stdout.write(text);
|
|
825
|
+
else
|
|
826
|
+
process.stderr.write(text);
|
|
827
|
+
return;
|
|
828
|
+
}
|
|
829
|
+
const normalized = `${stream === "stdout" ? stdoutPending : stderrPending}${text}`
|
|
830
|
+
.replace(/\r\n/g, "\n")
|
|
831
|
+
.replace(/\r/g, "\n");
|
|
832
|
+
const lines = normalized.split("\n");
|
|
833
|
+
const pending = lines.pop() ?? "";
|
|
834
|
+
if (stream === "stdout")
|
|
835
|
+
stdoutPending = pending;
|
|
836
|
+
else
|
|
837
|
+
stderrPending = pending;
|
|
838
|
+
for (const line of lines) {
|
|
839
|
+
flushPendingLine(line);
|
|
840
|
+
}
|
|
841
|
+
};
|
|
842
|
+
child.stdout?.on("data", (data) => handleChunk(data, "stdout"));
|
|
843
|
+
child.stderr?.on("data", (data) => handleChunk(data, "stderr"));
|
|
844
|
+
if (task) {
|
|
845
|
+
heartbeatTimer = setInterval(() => {
|
|
846
|
+
const elapsedSeconds = Math.max(1, Math.round((Date.now() - startedAt) / 1000));
|
|
847
|
+
emitInstallTaskLog(task, `[lifecycle:${label}] still running (${elapsedSeconds}s): ${taskDisplay}`);
|
|
848
|
+
}, 10_000);
|
|
849
|
+
child.on("close", () => {
|
|
850
|
+
flushPendingLine(stdoutPending);
|
|
851
|
+
flushPendingLine(stderrPending);
|
|
852
|
+
});
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
child.on("close", (code) => {
|
|
856
|
+
cleanupPreparedEnv();
|
|
857
|
+
if (code === 0)
|
|
858
|
+
resolve();
|
|
859
|
+
else if (sudo)
|
|
860
|
+
reject(createLifecycleSudoError(capturedStderr, display, Boolean(execOptions?.sudoPassword)));
|
|
861
|
+
else
|
|
862
|
+
reject(new Error(`lifecycle '${label}' step failed (exit ${code ?? 1}): ${display}`));
|
|
863
|
+
});
|
|
864
|
+
child.on("error", (err) => {
|
|
865
|
+
cleanupPreparedEnv();
|
|
866
|
+
if (sudo && err.code === "ENOENT") {
|
|
867
|
+
reject(new Error("当前环境未检测到 sudo,无法执行需要 sudo 的生命周期步骤。请以 root 身份重试。"));
|
|
868
|
+
return;
|
|
869
|
+
}
|
|
870
|
+
reject(new Error(`lifecycle '${label}' step error: ${err.message}`));
|
|
871
|
+
});
|
|
872
|
+
});
|
|
873
|
+
}
|
|
874
|
+
function lifecycleRunStepDisplay(label, index) {
|
|
875
|
+
if (label === "pre_install") {
|
|
876
|
+
return `run step ${index + 1}`;
|
|
877
|
+
}
|
|
878
|
+
return "$";
|
|
879
|
+
}
|
|
880
|
+
async function commandExists(command) {
|
|
881
|
+
return new Promise((resolve) => {
|
|
882
|
+
const child = spawn("sh", ["-c", `command -v '${command}' > /dev/null 2>&1`], {
|
|
883
|
+
stdio: "ignore",
|
|
884
|
+
env: buildLifecycleEnv(),
|
|
885
|
+
});
|
|
886
|
+
child.on("close", (code) => resolve(code === 0));
|
|
887
|
+
child.on("error", () => resolve(false));
|
|
888
|
+
});
|
|
889
|
+
}
|
|
890
|
+
async function downloadBinaryStep(label, url, dest, chmod, task) {
|
|
891
|
+
const expanded = expandPath(dest);
|
|
892
|
+
process.stdout.write(` [lifecycle:${label}] downloadBinary: ${url} → ${expanded}\n`);
|
|
893
|
+
emitInstallTaskLog(task, `[lifecycle:${label}] downloadBinary: ${url} -> ${expanded}`);
|
|
894
|
+
mkdirSync(dirname(expanded), { recursive: true });
|
|
895
|
+
const tmp = expanded + ".tmp";
|
|
896
|
+
const res = await fetch(url);
|
|
897
|
+
if (!res.ok)
|
|
898
|
+
throw new Error(`downloadBinary: HTTP ${res.status} ${url}`);
|
|
899
|
+
const buf = await res.arrayBuffer();
|
|
900
|
+
writeFileSync(tmp, Buffer.from(buf), { mode: 0o644 });
|
|
901
|
+
renameSync(tmp, expanded);
|
|
902
|
+
if (chmod)
|
|
903
|
+
chmodSync(expanded, parseInt(chmod, 8));
|
|
904
|
+
}
|
|
905
|
+
async function runLifecycleSteps(steps, label, artifacts, task, execOptions) {
|
|
906
|
+
if (!steps || steps.length === 0)
|
|
907
|
+
return;
|
|
908
|
+
for (const [index, step] of steps.entries()) {
|
|
909
|
+
if ("run" in step) {
|
|
910
|
+
if (step.ifFileExists && !existsSync(expandPath(step.ifFileExists))) {
|
|
911
|
+
continue;
|
|
912
|
+
}
|
|
913
|
+
const timeoutMs = step.timeout_ms ?? 300_000;
|
|
914
|
+
const display = label === "pre_install"
|
|
915
|
+
? lifecycleRunStepDisplay(label, index)
|
|
916
|
+
: `${lifecycleRunStepDisplay(label, index)} ${step.run}`;
|
|
917
|
+
const taskDisplay = `run step ${index + 1}`;
|
|
918
|
+
try {
|
|
919
|
+
await spawnStepWithTimeout(label, display, taskDisplay, "sh", ["-c", step.run], timeoutMs, task, execOptions, step.sudo === true);
|
|
920
|
+
}
|
|
921
|
+
catch (error) {
|
|
922
|
+
if (step.successIfCommandExists && await commandExists(step.successIfCommandExists)) {
|
|
923
|
+
process.stdout.write(` [lifecycle:${label}] command '${step.successIfCommandExists}' detected after failure/timeout; treating step as successful\n`);
|
|
924
|
+
emitInstallTaskLog(task, `[lifecycle:${label}] command '${step.successIfCommandExists}' detected after failure/timeout; treating step as successful`);
|
|
925
|
+
continue;
|
|
926
|
+
}
|
|
927
|
+
throw error;
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
else if ("checkNotCommand" in step) {
|
|
931
|
+
const cmd = step.checkNotCommand;
|
|
932
|
+
const found = await commandExists(cmd);
|
|
933
|
+
if (found) {
|
|
934
|
+
throw new Error(step.message ??
|
|
935
|
+
`系统中已检测到 '${cmd}',建议直接使用系统版本,无需重复安装。如需由 Nomad 统一管理,请先卸载系统版本`);
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
else if ("downloadImage" in step) {
|
|
939
|
+
await pullDockerImageStep(label, step.downloadImage, `downloadImage: ${step.downloadImage}`, task, step.timeout_ms);
|
|
940
|
+
artifacts?.push({ type: "image", path: step.downloadImage });
|
|
941
|
+
}
|
|
942
|
+
else if ("deleteImage" in step) {
|
|
943
|
+
await spawnStep(label, `deleteImage: ${step.deleteImage}`, "sh", [
|
|
944
|
+
"-c",
|
|
945
|
+
`docker rmi -f ${step.deleteImage} 2>/dev/null || true`,
|
|
946
|
+
], task);
|
|
947
|
+
}
|
|
948
|
+
else if ("downloadBinary" in step) {
|
|
949
|
+
const { url, dest, chmod } = step.downloadBinary;
|
|
950
|
+
await downloadBinaryStep(label, url, dest, chmod, task);
|
|
951
|
+
artifacts?.push({ type: "binary", path: expandPath(dest) });
|
|
952
|
+
}
|
|
953
|
+
else if ("deleteBinary" in step) {
|
|
954
|
+
const p = expandPath(step.deleteBinary);
|
|
955
|
+
process.stdout.write(` [lifecycle:${label}] deleteBinary: ${p}\n`);
|
|
956
|
+
emitInstallTaskLog(task, `[lifecycle:${label}] deleteBinary: ${p}`);
|
|
957
|
+
try {
|
|
958
|
+
unlinkSync(p);
|
|
959
|
+
}
|
|
960
|
+
catch (e) {
|
|
961
|
+
if (e.code !== "ENOENT")
|
|
962
|
+
throw e;
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
else if ("mkdir" in step) {
|
|
966
|
+
const p = expandPath(step.mkdir);
|
|
967
|
+
process.stdout.write(` [lifecycle:${label}] mkdir: ${p}\n`);
|
|
968
|
+
emitInstallTaskLog(task, `[lifecycle:${label}] mkdir: ${p}`);
|
|
969
|
+
mkdirSync(p, { recursive: true });
|
|
970
|
+
artifacts?.push({ type: "dir", path: p });
|
|
971
|
+
}
|
|
972
|
+
else if ("deleteDir" in step) {
|
|
973
|
+
const p = expandPath(step.deleteDir);
|
|
974
|
+
process.stdout.write(` [lifecycle:${label}] deleteDir: ${p}\n`);
|
|
975
|
+
emitInstallTaskLog(task, `[lifecycle:${label}] deleteDir: ${p}`);
|
|
976
|
+
rmSync(p, { recursive: true, force: true });
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
function cleanupArtifacts(artifacts, task) {
|
|
981
|
+
for (let i = artifacts.length - 1; i >= 0; i--) {
|
|
982
|
+
const a = artifacts[i];
|
|
983
|
+
try {
|
|
984
|
+
switch (a.type) {
|
|
985
|
+
case "image":
|
|
986
|
+
process.stdout.write(` [cleanup] removing image: ${a.path}\n`);
|
|
987
|
+
emitInstallTaskLog(task, `[cleanup] removing image: ${a.path}`);
|
|
988
|
+
spawn("docker", ["rmi", "-f", a.path], { stdio: "ignore" }).unref();
|
|
989
|
+
break;
|
|
990
|
+
case "binary":
|
|
991
|
+
process.stdout.write(` [cleanup] removing binary: ${a.path}\n`);
|
|
992
|
+
emitInstallTaskLog(task, `[cleanup] removing binary: ${a.path}`);
|
|
993
|
+
unlinkSync(a.path);
|
|
994
|
+
break;
|
|
995
|
+
case "dir":
|
|
996
|
+
process.stdout.write(` [cleanup] removing dir: ${a.path}\n`);
|
|
997
|
+
emitInstallTaskLog(task, `[cleanup] removing dir: ${a.path}`);
|
|
998
|
+
rmSync(a.path, { recursive: true, force: true });
|
|
999
|
+
break;
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
catch {
|
|
1003
|
+
// best-effort
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
const DOCKER_IMAGE_RE = /^[a-zA-Z0-9][a-zA-Z0-9\-_.:/@]*$/;
|
|
1008
|
+
const APP_ID_RE = /^[a-z0-9][a-z0-9-]{0,62}$/;
|
|
1009
|
+
const REGISTRY_PATH = join(APPS_DIR, "capability-registry.json");
|
|
1010
|
+
const INSTALL_LOCK_FILENAME = "install.lock";
|
|
1011
|
+
// ── Directory helpers ─────────────────────────────────────────────────────
|
|
1012
|
+
function appDirForId(appId) {
|
|
1013
|
+
return join(APPS_DIR, appId);
|
|
1014
|
+
}
|
|
1015
|
+
function installLockPathForDir(appDir) {
|
|
1016
|
+
return join(appDir, INSTALL_LOCK_FILENAME);
|
|
1017
|
+
}
|
|
1018
|
+
function hasInstallLock(appDir) {
|
|
1019
|
+
return existsSync(installLockPathForDir(appDir));
|
|
1020
|
+
}
|
|
1021
|
+
function readInstallLockMetadata(appDir) {
|
|
1022
|
+
const lockPath = installLockPathForDir(appDir);
|
|
1023
|
+
if (!existsSync(lockPath))
|
|
1024
|
+
return null;
|
|
1025
|
+
try {
|
|
1026
|
+
const parsed = JSON.parse(readFileSync(lockPath, "utf-8"));
|
|
1027
|
+
return parsed && typeof parsed === "object" ? parsed : null;
|
|
1028
|
+
}
|
|
1029
|
+
catch {
|
|
1030
|
+
return null;
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
function installDirLooksComplete(appDir) {
|
|
1034
|
+
return (existsSync(join(appDir, "app-spec.yaml"))
|
|
1035
|
+
&& existsSync(join(appDir, "manifest.json"))
|
|
1036
|
+
&& existsSync(join(appDir, "instance.json")));
|
|
1037
|
+
}
|
|
1038
|
+
function isProcessAlive(pid) {
|
|
1039
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
1040
|
+
return false;
|
|
1041
|
+
try {
|
|
1042
|
+
process.kill(pid, 0);
|
|
1043
|
+
return true;
|
|
1044
|
+
}
|
|
1045
|
+
catch {
|
|
1046
|
+
return false;
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
function isStaleInstallLock(appDir) {
|
|
1050
|
+
if (!hasInstallLock(appDir))
|
|
1051
|
+
return false;
|
|
1052
|
+
const metadata = readInstallLockMetadata(appDir);
|
|
1053
|
+
if (!metadata)
|
|
1054
|
+
return true;
|
|
1055
|
+
const taskId = typeof metadata.taskId === "string" ? metadata.taskId.trim() : "";
|
|
1056
|
+
if (taskId) {
|
|
1057
|
+
return getTask(taskId)?.status !== "running";
|
|
1058
|
+
}
|
|
1059
|
+
if (typeof metadata.pid === "number") {
|
|
1060
|
+
if (isProcessAlive(metadata.pid)) {
|
|
1061
|
+
const specId = typeof metadata.specId === "string" ? metadata.specId.trim() : "";
|
|
1062
|
+
if (installDirLooksComplete(appDir) && (!specId || getRunningTasks(`app-install-${specId}`).length === 0)) {
|
|
1063
|
+
return true;
|
|
1064
|
+
}
|
|
1065
|
+
return false;
|
|
1066
|
+
}
|
|
1067
|
+
return !isProcessAlive(metadata.pid);
|
|
1068
|
+
}
|
|
1069
|
+
const specId = typeof metadata.specId === "string" ? metadata.specId.trim() : "";
|
|
1070
|
+
return specId ? getRunningTasks(`app-install-${specId}`).length === 0 : true;
|
|
1071
|
+
}
|
|
1072
|
+
function cleanupStaleInstallDir(appDir) {
|
|
1073
|
+
if (!isStaleInstallLock(appDir))
|
|
1074
|
+
return false;
|
|
1075
|
+
if (installDirLooksComplete(appDir)) {
|
|
1076
|
+
process.stdout.write(` [app-manager] removing stale install lock: ${appDir}\n`);
|
|
1077
|
+
removeInstallLock(appDir);
|
|
1078
|
+
return false;
|
|
1079
|
+
}
|
|
1080
|
+
process.stdout.write(` [app-manager] removing stale install dir: ${appDir}\n`);
|
|
1081
|
+
rmSync(appDir, { recursive: true, force: true });
|
|
1082
|
+
return true;
|
|
1083
|
+
}
|
|
1084
|
+
function createInstallLock(appDir, appId, specId, task) {
|
|
1085
|
+
writeFileSync(installLockPathForDir(appDir), JSON.stringify({ appId, specId, started_at: new Date().toISOString(), pid: process.pid, taskId: task?.id }, null, 2) + "\n", { mode: 0o644 });
|
|
1086
|
+
}
|
|
1087
|
+
function removeInstallLock(appDir) {
|
|
1088
|
+
const lockPath = installLockPathForDir(appDir);
|
|
1089
|
+
if (existsSync(lockPath)) {
|
|
1090
|
+
unlinkSync(lockPath);
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
function appTaskName(kind, appId) {
|
|
1094
|
+
return `app-${kind}:${appId}`;
|
|
1095
|
+
}
|
|
1096
|
+
function findRunningInstallTask(appDir) {
|
|
1097
|
+
const metadata = readInstallLockMetadata(appDir);
|
|
1098
|
+
const taskId = typeof metadata?.taskId === "string" ? metadata.taskId.trim() : "";
|
|
1099
|
+
if (!taskId)
|
|
1100
|
+
return undefined;
|
|
1101
|
+
const status = getTask(taskId)?.status;
|
|
1102
|
+
if (status !== "running")
|
|
1103
|
+
return undefined;
|
|
1104
|
+
return { id: taskId, kind: "install", status };
|
|
1105
|
+
}
|
|
1106
|
+
function findRunningNamedTask(taskName, kind) {
|
|
1107
|
+
const running = getRunningTasks(taskName)[0];
|
|
1108
|
+
if (!running)
|
|
1109
|
+
return undefined;
|
|
1110
|
+
const status = getTask(running.id)?.status;
|
|
1111
|
+
if (status !== "running")
|
|
1112
|
+
return undefined;
|
|
1113
|
+
return { id: running.id, kind, status };
|
|
1114
|
+
}
|
|
1115
|
+
export function getRunningAppTask(appId, appDir) {
|
|
1116
|
+
const resolvedAppDir = appDir ?? resolveAppLocation(appId)?.dir;
|
|
1117
|
+
if (resolvedAppDir && hasInstallLock(resolvedAppDir)) {
|
|
1118
|
+
const installTask = findRunningInstallTask(resolvedAppDir);
|
|
1119
|
+
if (installTask)
|
|
1120
|
+
return installTask;
|
|
1121
|
+
}
|
|
1122
|
+
for (const kind of ["uninstall", "restart", "stop", "start"]) {
|
|
1123
|
+
const task = findRunningNamedTask(appTaskName(kind, appId), kind);
|
|
1124
|
+
if (task)
|
|
1125
|
+
return task;
|
|
1126
|
+
}
|
|
1127
|
+
return undefined;
|
|
1128
|
+
}
|
|
1129
|
+
function startAppLifecycleTask(appId, kind, startMessage, doneMessage, action) {
|
|
1130
|
+
const currentTask = getRunningAppTask(appId);
|
|
1131
|
+
if (currentTask) {
|
|
1132
|
+
if (currentTask.kind === kind) {
|
|
1133
|
+
return { ok: true, taskId: currentTask.id, reused: true, kind };
|
|
1134
|
+
}
|
|
1135
|
+
return {
|
|
1136
|
+
ok: false,
|
|
1137
|
+
error: `App '${appId}' 正在执行 ${currentTask.kind} 操作,请等待完成后再试`,
|
|
1138
|
+
kind,
|
|
1139
|
+
};
|
|
1140
|
+
}
|
|
1141
|
+
const task = createTask(appTaskName(kind, appId));
|
|
1142
|
+
emitAppTaskProgress(task, startMessage, 0);
|
|
1143
|
+
void (async () => {
|
|
1144
|
+
try {
|
|
1145
|
+
await action();
|
|
1146
|
+
task.status = "done";
|
|
1147
|
+
emitTask(task, { type: "done", message: doneMessage, progress: 100 });
|
|
1148
|
+
}
|
|
1149
|
+
catch (e) {
|
|
1150
|
+
task.status = "error";
|
|
1151
|
+
emitTask(task, {
|
|
1152
|
+
type: "error",
|
|
1153
|
+
message: e?.message || `App '${appId}' ${kind} 失败`,
|
|
1154
|
+
});
|
|
1155
|
+
}
|
|
1156
|
+
})();
|
|
1157
|
+
return { ok: true, taskId: task.id, kind };
|
|
1158
|
+
}
|
|
1159
|
+
function instanceAppDirForId(appId) {
|
|
1160
|
+
return join(INSTANCES_DIR, appId);
|
|
1161
|
+
}
|
|
1162
|
+
function resolveAppLocation(appId) {
|
|
1163
|
+
const candidates = [
|
|
1164
|
+
{ dir: appDirForId(appId), installMode: "app-dir" },
|
|
1165
|
+
{ dir: instanceAppDirForId(appId), installMode: "instance-dir" },
|
|
1166
|
+
];
|
|
1167
|
+
for (const candidate of candidates) {
|
|
1168
|
+
cleanupStaleInstallDir(candidate.dir);
|
|
1169
|
+
if (existsSync(join(candidate.dir, "app-spec.yaml"))
|
|
1170
|
+
&& existsSync(join(candidate.dir, "manifest.json"))) {
|
|
1171
|
+
return candidate;
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
return null;
|
|
1175
|
+
}
|
|
1176
|
+
function isInstanceBackedApp(appData) {
|
|
1177
|
+
return appData?.manifest.install_mode === "instance-dir";
|
|
1178
|
+
}
|
|
1179
|
+
function getSingleServiceTask(spec) {
|
|
1180
|
+
const serviceTasks = spec.tasks.filter((task) => (task.role ?? "service") === "service");
|
|
1181
|
+
if (spec.tasks.length !== 1 || serviceTasks.length !== 1)
|
|
1182
|
+
return null;
|
|
1183
|
+
return serviceTasks[0];
|
|
1184
|
+
}
|
|
1185
|
+
function inferLegacyAppType(spec) {
|
|
1186
|
+
return /ollama/i.test(`${spec.id} ${spec.name ?? ""}`) ? "ollama" : "custom";
|
|
1187
|
+
}
|
|
1188
|
+
function rewriteInstanceScopedPathText(value, appId) {
|
|
1189
|
+
return value
|
|
1190
|
+
.split(`~/.jishushell/apps/${appId}`).join(`~/.jishushell/instances/${appId}`)
|
|
1191
|
+
.split(`$HOME/.jishushell/apps/${appId}`).join(`$HOME/.jishushell/instances/${appId}`)
|
|
1192
|
+
.split(`${homedir()}/.jishushell/apps/${appId}`).join(`${homedir()}/.jishushell/instances/${appId}`);
|
|
1193
|
+
}
|
|
1194
|
+
function rewriteInstanceScopedPaths(value, appId) {
|
|
1195
|
+
if (typeof value === "string") {
|
|
1196
|
+
return rewriteInstanceScopedPathText(value, appId);
|
|
1197
|
+
}
|
|
1198
|
+
if (Array.isArray(value)) {
|
|
1199
|
+
return value.map((entry) => rewriteInstanceScopedPaths(entry, appId));
|
|
1200
|
+
}
|
|
1201
|
+
if (value && typeof value === "object") {
|
|
1202
|
+
return Object.fromEntries(Object.entries(value).map(([key, entry]) => [
|
|
1203
|
+
key,
|
|
1204
|
+
rewriteInstanceScopedPaths(entry, appId),
|
|
1205
|
+
]));
|
|
1206
|
+
}
|
|
1207
|
+
return value;
|
|
1208
|
+
}
|
|
1209
|
+
function buildGenericInstanceBackedMeta(appId, spec) {
|
|
1210
|
+
return {
|
|
1211
|
+
id: appId,
|
|
1212
|
+
name: spec.name || appId,
|
|
1213
|
+
description: spec.description || "",
|
|
1214
|
+
// See buildGenericAppMeta for the dual-write rationale: legacy
|
|
1215
|
+
// `app_type` keeps V1 dispatch alive, `app_spec_ref` is the V2
|
|
1216
|
+
// discriminator recognized by resolveAgentType.
|
|
1217
|
+
app_type: inferLegacyAppType(spec),
|
|
1218
|
+
app_spec_ref: spec.id,
|
|
1219
|
+
...(spec.singleInstance ? { singleInstance: true } : {}),
|
|
1220
|
+
runtime: {},
|
|
1221
|
+
created_at: new Date().toISOString(),
|
|
1222
|
+
app_id: spec.app_id ?? appId,
|
|
1223
|
+
};
|
|
1224
|
+
}
|
|
1225
|
+
function explicitAgentTypeForSpec(spec) {
|
|
1226
|
+
if (typeof spec.agentType === "string" && spec.agentType.trim()) {
|
|
1227
|
+
return spec.agentType.trim();
|
|
1228
|
+
}
|
|
1229
|
+
if (typeof spec._engine?.agentType === "string" && spec._engine.agentType.trim()) {
|
|
1230
|
+
return spec._engine.agentType.trim();
|
|
1231
|
+
}
|
|
1232
|
+
return "";
|
|
1233
|
+
}
|
|
1234
|
+
function buildGenericAppRuntime(spec, appId) {
|
|
1235
|
+
const serviceTask = spec.tasks.find((task) => (task.role ?? "service") === "service");
|
|
1236
|
+
if (!serviceTask)
|
|
1237
|
+
return {};
|
|
1238
|
+
const runtime = compileTaskRuntime(serviceTask, appId);
|
|
1239
|
+
if (Array.isArray(serviceTask.ports) && serviceTask.ports.length > 0) {
|
|
1240
|
+
runtime.ports = serviceTask.ports.map((port) => ({
|
|
1241
|
+
name: port.name,
|
|
1242
|
+
containerPort: port.container_port ?? port.port,
|
|
1243
|
+
hostPort: port.host_port ?? port.port,
|
|
1244
|
+
visibility: port.visibility ?? "external",
|
|
1245
|
+
}));
|
|
1246
|
+
}
|
|
1247
|
+
if (serviceTask.health?.http) {
|
|
1248
|
+
const healthPortEntry = serviceTask.ports?.find((port) => port.port === serviceTask.health?.http?.port
|
|
1249
|
+
|| port.host_port === serviceTask.health?.http?.port
|
|
1250
|
+
|| port.container_port === serviceTask.health?.http?.port);
|
|
1251
|
+
runtime.health = {
|
|
1252
|
+
type: "http",
|
|
1253
|
+
path: serviceTask.health.http.path,
|
|
1254
|
+
port: healthPortEntry?.host_port ?? healthPortEntry?.port ?? serviceTask.health.http.port,
|
|
1255
|
+
interval: serviceTask.health.interval,
|
|
1256
|
+
timeout: serviceTask.health.timeout,
|
|
1257
|
+
retries: serviceTask.health.retries,
|
|
1258
|
+
};
|
|
1259
|
+
}
|
|
1260
|
+
if (Array.isArray(serviceTask.volumes)) {
|
|
1261
|
+
runtime.volumes = serviceTask.volumes.map((volume) => ({
|
|
1262
|
+
hostPath: expandPath(String(volume.source ?? "")),
|
|
1263
|
+
containerPath: String(volume.target ?? ""),
|
|
1264
|
+
mode: volume.readonly ? "ro" : "rw",
|
|
1265
|
+
}));
|
|
1266
|
+
}
|
|
1267
|
+
return runtime;
|
|
1268
|
+
}
|
|
1269
|
+
function buildGenericAppMeta(appId, spec, createdAt) {
|
|
1270
|
+
const explicitAgentType = explicitAgentTypeForSpec(spec);
|
|
1271
|
+
return {
|
|
1272
|
+
id: appId,
|
|
1273
|
+
name: spec.name || appId,
|
|
1274
|
+
description: spec.description || "",
|
|
1275
|
+
runtime: buildGenericAppRuntime(spec, appId),
|
|
1276
|
+
created_at: createdAt,
|
|
1277
|
+
app_id: spec.app_id ?? appId,
|
|
1278
|
+
...(explicitAgentType
|
|
1279
|
+
? { agentType: explicitAgentType }
|
|
1280
|
+
: {
|
|
1281
|
+
// `app_type: "custom"` is the V1 legacy marker; `app_spec_ref`
|
|
1282
|
+
// is the V2 discriminator that resolveAgentType (runtime/
|
|
1283
|
+
// instance.ts) uses to route to the custom adapter. Writing
|
|
1284
|
+
// both makes dispatch survive the eventual removal of the
|
|
1285
|
+
// resolveLegacyAppType bridge without touching each instance.
|
|
1286
|
+
app_type: inferLegacyAppType(spec),
|
|
1287
|
+
app_spec_ref: spec.id,
|
|
1288
|
+
...(spec.singleInstance ? { singleInstance: true } : {}),
|
|
1289
|
+
}),
|
|
1290
|
+
};
|
|
1291
|
+
}
|
|
1292
|
+
async function bootstrapAdapterManagedApp(appId, spec, options) {
|
|
1293
|
+
const agentType = explicitAgentTypeForSpec(spec);
|
|
1294
|
+
if (!agentType)
|
|
1295
|
+
return false;
|
|
1296
|
+
const { hasAdapter, getAdapter } = await import("../runtime/index.js");
|
|
1297
|
+
if (!hasAdapter(agentType))
|
|
1298
|
+
return false;
|
|
1299
|
+
const adapter = getAdapter(agentType);
|
|
1300
|
+
if (!adapter.createInstance)
|
|
1301
|
+
return false;
|
|
1302
|
+
await adapter.createInstance({
|
|
1303
|
+
instanceId: appId,
|
|
1304
|
+
name: options.bootstrap?.name ?? spec.name ?? appId,
|
|
1305
|
+
description: options.bootstrap?.description ?? spec.description ?? "",
|
|
1306
|
+
cloneFrom: options.bootstrap?.cloneFrom,
|
|
1307
|
+
agentHome: options.bootstrap?.agentHome,
|
|
1308
|
+
cloneOptions: options.bootstrap?.cloneOptions,
|
|
1309
|
+
appSpec: spec,
|
|
1310
|
+
});
|
|
1311
|
+
return true;
|
|
1312
|
+
}
|
|
1313
|
+
function getAdapterManagedAgentType(appData) {
|
|
1314
|
+
if (!appData || appData.manifest.install_mode !== "app-dir")
|
|
1315
|
+
return null;
|
|
1316
|
+
const meta = legacyInstanceManager.getInstance(appData.manifest.id);
|
|
1317
|
+
const agentType = typeof meta?.agentType === "string" ? meta.agentType.trim() : "";
|
|
1318
|
+
const legacyAppType = typeof meta?.app_type === "string" ? meta.app_type.trim() : "";
|
|
1319
|
+
if (!agentType || legacyAppType)
|
|
1320
|
+
return null;
|
|
1321
|
+
return agentType;
|
|
1322
|
+
}
|
|
1323
|
+
async function installIntoInstanceDir(spec, specYaml, requestedAppId, options = {}) {
|
|
1324
|
+
if (options.preferInstanceDir !== true)
|
|
1325
|
+
return null;
|
|
1326
|
+
const serviceTask = getSingleServiceTask(spec);
|
|
1327
|
+
if (!serviceTask)
|
|
1328
|
+
return null;
|
|
1329
|
+
const explicitAgentType = typeof spec.agentType === "string" && spec.agentType.trim()
|
|
1330
|
+
? spec.agentType.trim()
|
|
1331
|
+
: typeof spec._engine?.agentType === "string" && spec._engine.agentType.trim()
|
|
1332
|
+
? spec._engine.agentType.trim()
|
|
1333
|
+
: "";
|
|
1334
|
+
const { hasAdapter, getAdapter } = await import("../runtime/index.js");
|
|
1335
|
+
let installTarget = null;
|
|
1336
|
+
if (explicitAgentType) {
|
|
1337
|
+
if (hasAdapter(explicitAgentType) && getAdapter(explicitAgentType).createInstance) {
|
|
1338
|
+
installTarget = { kind: "adapter", agentType: explicitAgentType };
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
else {
|
|
1342
|
+
const hintText = `${spec.id} ${spec.name ?? ""}`.toLowerCase();
|
|
1343
|
+
for (const hintedAgentType of ["openclaw", "hermes"]) {
|
|
1344
|
+
if (hintText.includes(hintedAgentType) && hasAdapter(hintedAgentType) && getAdapter(hintedAgentType).createInstance) {
|
|
1345
|
+
installTarget = { kind: "adapter", agentType: hintedAgentType };
|
|
1346
|
+
break;
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
if (!installTarget && serviceTask.runtime === "container") {
|
|
1350
|
+
installTarget = { kind: "legacy", appType: inferLegacyAppType(spec) };
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
if (!installTarget)
|
|
1354
|
+
return null;
|
|
1355
|
+
const { appId, installedSpec, } = await resolveInstallTarget(spec, specYaml, requestedAppId);
|
|
1356
|
+
let instanceSpec = rewriteInstanceScopedPaths(installedSpec, appId);
|
|
1357
|
+
const resolvedRequires = resolveRequires(installedSpec);
|
|
1358
|
+
if (Object.keys(resolvedRequires).length > 0) {
|
|
1359
|
+
instanceSpec = {
|
|
1360
|
+
...installedSpec,
|
|
1361
|
+
tasks: installedSpec.tasks.map((task) => (task.role ?? "service") === "service"
|
|
1362
|
+
? { ...task, env: { ...task.env, ...resolvedRequires } }
|
|
1363
|
+
: task),
|
|
1364
|
+
};
|
|
1365
|
+
instanceSpec = rewriteInstanceScopedPaths(instanceSpec, appId);
|
|
1366
|
+
}
|
|
1367
|
+
const persistedSpecYaml = stringify(instanceSpec);
|
|
1368
|
+
const instanceDir = instanceAppDirForId(appId);
|
|
1369
|
+
const manifest = {
|
|
1370
|
+
id: appId,
|
|
1371
|
+
installed_at: new Date().toISOString(),
|
|
1372
|
+
spec_hash: createHash("sha256").update(persistedSpecYaml).digest("hex"),
|
|
1373
|
+
install_mode: "instance-dir",
|
|
1374
|
+
};
|
|
1375
|
+
const { getAppManager } = await import("./registry.js");
|
|
1376
|
+
const instanceName = instanceSpec.name || appId;
|
|
1377
|
+
const instanceDescription = instanceSpec.description || "";
|
|
1378
|
+
const artifacts = [];
|
|
1379
|
+
try {
|
|
1380
|
+
if (installTarget?.kind === "adapter") {
|
|
1381
|
+
const adapter = getAdapter(installTarget.agentType);
|
|
1382
|
+
if (!adapter.createInstance)
|
|
1383
|
+
return null;
|
|
1384
|
+
await adapter.createInstance({
|
|
1385
|
+
instanceId: appId,
|
|
1386
|
+
name: instanceName,
|
|
1387
|
+
description: instanceDescription,
|
|
1388
|
+
appSpec: instanceSpec,
|
|
1389
|
+
});
|
|
1390
|
+
}
|
|
1391
|
+
else if (installTarget?.kind === "legacy") {
|
|
1392
|
+
await getAppManager(installTarget.appType).createInstance(appId, instanceName, instanceDescription, { appSpec: instanceSpec });
|
|
1393
|
+
}
|
|
1394
|
+
else {
|
|
1395
|
+
ensureDirHost(instanceDir);
|
|
1396
|
+
safeWriteJson(join(instanceDir, "instance.json"), buildGenericInstanceBackedMeta(appId, instanceSpec), true);
|
|
1397
|
+
}
|
|
1398
|
+
ensureDirHost(instanceDir);
|
|
1399
|
+
const yamlPath = join(instanceDir, "app-spec.yaml");
|
|
1400
|
+
const yamlTmp = yamlPath + ".tmp";
|
|
1401
|
+
writeFileSync(yamlTmp, persistedSpecYaml, { mode: 0o644 });
|
|
1402
|
+
renameSync(yamlTmp, yamlPath);
|
|
1403
|
+
safeWriteJson(join(instanceDir, "manifest.json"), manifest, true);
|
|
1404
|
+
await runLifecycleSteps(instanceSpec.lifecycle?.pre_install, "pre_install", artifacts, options.task, options.exec);
|
|
1405
|
+
}
|
|
1406
|
+
catch (e) {
|
|
1407
|
+
cleanupArtifacts(artifacts, options.task);
|
|
1408
|
+
try {
|
|
1409
|
+
const instanceManager = await import("../instance-manager.js");
|
|
1410
|
+
await instanceManager.deleteInstance(appId);
|
|
1411
|
+
}
|
|
1412
|
+
catch {
|
|
1413
|
+
rmSync(instanceDir, { recursive: true, force: true });
|
|
1414
|
+
}
|
|
1415
|
+
throw decorateInstallError(e, instanceSpec);
|
|
1416
|
+
}
|
|
1417
|
+
try {
|
|
1418
|
+
await runLifecycleSteps(instanceSpec.lifecycle?.install, "install", artifacts, options.task, options.exec);
|
|
1419
|
+
const pulledImages = new Set(artifacts.filter((artifact) => artifact.type === "image").map((artifact) => artifact.path));
|
|
1420
|
+
const imagesToPull = [...new Set(instanceSpec.tasks.filter((task) => task.image).map((task) => task.image))];
|
|
1421
|
+
for (const image of imagesToPull) {
|
|
1422
|
+
if (!pulledImages.has(image)) {
|
|
1423
|
+
await pullDockerImageStep("install", image, `docker pull ${image}`, options.task);
|
|
1424
|
+
artifacts.push({ type: "image", path: image });
|
|
1425
|
+
}
|
|
1426
|
+
}
|
|
1427
|
+
}
|
|
1428
|
+
catch (e) {
|
|
1429
|
+
try {
|
|
1430
|
+
await runLifecycleSteps(instanceSpec.lifecycle?.uninstall, "rollback_uninstall", undefined, options.task, options.exec);
|
|
1431
|
+
}
|
|
1432
|
+
catch (rollbackError) {
|
|
1433
|
+
process.stdout.write(` [app-manager] rollback uninstall failed: ${rollbackError.message}\n`);
|
|
1434
|
+
emitInstallTaskLog(options.task, `[app-manager] rollback uninstall failed: ${rollbackError.message}`);
|
|
1435
|
+
}
|
|
1436
|
+
cleanupArtifacts(artifacts, options.task);
|
|
1437
|
+
try {
|
|
1438
|
+
const instanceManager = await import("../instance-manager.js");
|
|
1439
|
+
await instanceManager.deleteInstance(appId);
|
|
1440
|
+
}
|
|
1441
|
+
catch {
|
|
1442
|
+
rmSync(instanceDir, { recursive: true, force: true });
|
|
1443
|
+
}
|
|
1444
|
+
throw decorateInstallError(e, instanceSpec);
|
|
1445
|
+
}
|
|
1446
|
+
if (artifacts.length > 0) {
|
|
1447
|
+
manifest.artifacts = artifacts;
|
|
1448
|
+
safeWriteJson(join(instanceDir, "manifest.json"), manifest, true);
|
|
1449
|
+
}
|
|
1450
|
+
return { spec: instanceSpec, manifest };
|
|
1451
|
+
}
|
|
1452
|
+
/**
|
|
1453
|
+
* Check whether an app with this id is installed (has manifest.json + app-spec.yaml).
|
|
1454
|
+
* Exported so nomad-manager can use it for routing without the old prefix convention.
|
|
1455
|
+
*/
|
|
1456
|
+
export function isAppInstalled(appId) {
|
|
1457
|
+
const location = resolveAppLocation(appId);
|
|
1458
|
+
return location != null && !hasInstallLock(location.dir);
|
|
1459
|
+
}
|
|
1460
|
+
function resolveAppDir(appId) {
|
|
1461
|
+
return resolveAppLocation(appId)?.dir ?? null;
|
|
1462
|
+
}
|
|
1463
|
+
function readAppFromDir(appDir, appId, installModeHint = "app-dir") {
|
|
1464
|
+
if (cleanupStaleInstallDir(appDir))
|
|
1465
|
+
return null;
|
|
1466
|
+
const yamlPath = join(appDir, "app-spec.yaml");
|
|
1467
|
+
const manifestPath = join(appDir, "manifest.json");
|
|
1468
|
+
if (!existsSync(yamlPath) || !existsSync(manifestPath))
|
|
1469
|
+
return null;
|
|
1470
|
+
try {
|
|
1471
|
+
const spec = normalizeAppSpec(parse(readFileSync(yamlPath, "utf-8")));
|
|
1472
|
+
const manifest = safeReadJson(manifestPath, `app:${appId}`);
|
|
1473
|
+
if (!manifest)
|
|
1474
|
+
return null;
|
|
1475
|
+
return {
|
|
1476
|
+
spec,
|
|
1477
|
+
manifest: {
|
|
1478
|
+
...manifest,
|
|
1479
|
+
install_mode: manifest.install_mode ?? installModeHint,
|
|
1480
|
+
},
|
|
1481
|
+
install_state: hasInstallLock(appDir) ? "installing" : "installed",
|
|
1482
|
+
...(getRunningAppTask(appId, appDir) ? { current_task: getRunningAppTask(appId, appDir) } : {}),
|
|
1483
|
+
};
|
|
1484
|
+
}
|
|
1485
|
+
catch {
|
|
1486
|
+
return null;
|
|
1487
|
+
}
|
|
1488
|
+
}
|
|
1489
|
+
export function getAppInstallState(appId) {
|
|
1490
|
+
const location = resolveAppLocation(appId);
|
|
1491
|
+
if (!location)
|
|
1492
|
+
return null;
|
|
1493
|
+
return hasInstallLock(location.dir) ? "installing" : "installed";
|
|
1494
|
+
}
|
|
1495
|
+
function readRegistry() {
|
|
1496
|
+
const reg = safeReadJson(REGISTRY_PATH, "capability-registry");
|
|
1497
|
+
return reg ?? { capabilities: {} };
|
|
1498
|
+
}
|
|
1499
|
+
function writeRegistry(reg) {
|
|
1500
|
+
ensureDirHost(APPS_DIR);
|
|
1501
|
+
safeWriteJson(REGISTRY_PATH, reg, true);
|
|
1502
|
+
}
|
|
1503
|
+
function installedProvidersForCapability(capability) {
|
|
1504
|
+
return listApps()
|
|
1505
|
+
.filter((app) => app.spec.provides?.some((provide) => provide.capability === capability))
|
|
1506
|
+
.map((app) => app.manifest.id);
|
|
1507
|
+
}
|
|
1508
|
+
function ensureRequiredCapabilitiesAvailable(spec) {
|
|
1509
|
+
if (!spec.requires?.length)
|
|
1510
|
+
return;
|
|
1511
|
+
const reg = readRegistry();
|
|
1512
|
+
const missing = spec.requires
|
|
1513
|
+
.filter((req) => req.required !== false && !reg.capabilities[req.capability])
|
|
1514
|
+
.map((req) => {
|
|
1515
|
+
const installedProviders = installedProvidersForCapability(req.capability);
|
|
1516
|
+
const providerHint = installedProviders.length > 0
|
|
1517
|
+
? `;已安装但未注册的 provider: ${installedProviders.join(", ")}`
|
|
1518
|
+
: "";
|
|
1519
|
+
return `- ${req.capability} -> ${req.inject_as}${providerHint}`;
|
|
1520
|
+
});
|
|
1521
|
+
if (missing.length === 0)
|
|
1522
|
+
return;
|
|
1523
|
+
throw new Error(`App '${spec.id}' 缺少必需能力,已跳过安装:\n${missing.join("\n")}\n请先启动对应 provider,再执行 jishushell app provides 查看当前可用能力。`);
|
|
1524
|
+
}
|
|
1525
|
+
export function listProvidedCapabilities() {
|
|
1526
|
+
const reg = readRegistry();
|
|
1527
|
+
return listApps().flatMap((app) => (app.spec.provides ?? []).map((provide) => {
|
|
1528
|
+
const url = getProvideUrl(provide) ?? undefined;
|
|
1529
|
+
const port = getProvidePort(app.spec, provide) ?? undefined;
|
|
1530
|
+
const address = !url && typeof port === "number" ? buildCapabilityAddress(port, provide.path) : undefined;
|
|
1531
|
+
const registered = reg.capabilities[provide.capability];
|
|
1532
|
+
const protocol = resolveProvideProtocol(provide);
|
|
1533
|
+
return {
|
|
1534
|
+
appId: app.manifest.id,
|
|
1535
|
+
capability: provide.capability,
|
|
1536
|
+
...(typeof port === "number" ? { port } : {}),
|
|
1537
|
+
...(provide.path ? { path: provide.path } : {}),
|
|
1538
|
+
...(url ? { url } : {}),
|
|
1539
|
+
protocol,
|
|
1540
|
+
...(provide.visibility ? { visibility: provide.visibility } : {}),
|
|
1541
|
+
...(provide.description ? { description: provide.description } : {}),
|
|
1542
|
+
...(provide.terminal ? { terminal: provide.terminal } : {}),
|
|
1543
|
+
...(address ? { address } : {}),
|
|
1544
|
+
registered: Boolean(registered),
|
|
1545
|
+
...(registered?.address ? { registeredAddress: registered.address } : {}),
|
|
1546
|
+
...(registered?.instanceId ? { providerInstanceId: registered.instanceId } : {}),
|
|
1547
|
+
};
|
|
1548
|
+
}));
|
|
1549
|
+
}
|
|
1550
|
+
export function getProvidedCapabilitiesForApp(appId) {
|
|
1551
|
+
return listProvidedCapabilities().filter((entry) => entry.appId === appId);
|
|
1552
|
+
}
|
|
1553
|
+
export function getEmbeddedUiHintForApp(appId) {
|
|
1554
|
+
const provides = getProvidedCapabilitiesForApp(appId);
|
|
1555
|
+
if (!provides.length)
|
|
1556
|
+
return null;
|
|
1557
|
+
for (const provide of provides) {
|
|
1558
|
+
const protocol = normalizeProvideProtocol(provide.protocol);
|
|
1559
|
+
if (provide.visibility === "internal")
|
|
1560
|
+
continue;
|
|
1561
|
+
if (protocol !== "http" && protocol !== "https")
|
|
1562
|
+
continue;
|
|
1563
|
+
if (typeof provide.url === "string" && provide.url.trim()) {
|
|
1564
|
+
const url = provide.url.trim();
|
|
1565
|
+
return {
|
|
1566
|
+
capability: provide.capability,
|
|
1567
|
+
protocol,
|
|
1568
|
+
url,
|
|
1569
|
+
};
|
|
1570
|
+
}
|
|
1571
|
+
if (typeof provide.port !== "number" || provide.port < 1)
|
|
1572
|
+
continue;
|
|
1573
|
+
const address = typeof provide.address === "string" && provide.address.trim()
|
|
1574
|
+
? provide.address.trim()
|
|
1575
|
+
: buildCapabilityAddress(provide.port, provide.path);
|
|
1576
|
+
return {
|
|
1577
|
+
capability: provide.capability,
|
|
1578
|
+
protocol,
|
|
1579
|
+
port: provide.port,
|
|
1580
|
+
url: `${protocol}://${address}`,
|
|
1581
|
+
};
|
|
1582
|
+
}
|
|
1583
|
+
return null;
|
|
1584
|
+
}
|
|
1585
|
+
export async function installApp(specYaml, requestedAppId, options = {}) {
|
|
1586
|
+
let spec;
|
|
1587
|
+
try {
|
|
1588
|
+
spec = normalizeAppSpec(parse(specYaml));
|
|
1589
|
+
}
|
|
1590
|
+
catch (e) {
|
|
1591
|
+
throw new Error(`YAML 解析失败: ${e.message}`);
|
|
1592
|
+
}
|
|
1593
|
+
if (!spec || !spec.id || !APP_ID_RE.test(spec.id)) {
|
|
1594
|
+
throw new Error(`App id '${spec?.id}' 格式无效,必须符合 /^[a-z0-9][a-z0-9-]{0,62}$/`);
|
|
1595
|
+
}
|
|
1596
|
+
ensureCompatibleJishuShellVersion(spec);
|
|
1597
|
+
if (!Array.isArray(spec.tasks) || spec.tasks.length === 0) {
|
|
1598
|
+
throw new Error("tasks 不能为空");
|
|
1599
|
+
}
|
|
1600
|
+
const hasService = spec.tasks.some((t) => t.role === "service");
|
|
1601
|
+
if (!hasService) {
|
|
1602
|
+
throw new Error("tasks 中至少需要一个 role 为 'service' 的任务");
|
|
1603
|
+
}
|
|
1604
|
+
for (const task of spec.tasks) {
|
|
1605
|
+
if (task.runtime === "vm") {
|
|
1606
|
+
throw new Error(`runtime 'vm' 暂不支持,请使用 runtime: container 或 process`);
|
|
1607
|
+
}
|
|
1608
|
+
if (task.runtime === "container" && !task.image) {
|
|
1609
|
+
throw new Error(`container task '${task.name}' 需要指定 image 字段`);
|
|
1610
|
+
}
|
|
1611
|
+
if (task.runtime === "process" && !task.command) {
|
|
1612
|
+
throw new Error(`process task '${task.name}' 需要指定 command 或 binary 字段`);
|
|
1613
|
+
}
|
|
1614
|
+
if (task.image && !DOCKER_IMAGE_RE.test(task.image)) {
|
|
1615
|
+
throw new Error(`task '${task.name}' 的 image '${task.image}' 格式无效`);
|
|
1616
|
+
}
|
|
1617
|
+
}
|
|
1618
|
+
ensureRequiredCapabilitiesAvailable(spec);
|
|
1619
|
+
const instanceBackedInstall = await installIntoInstanceDir(spec, specYaml, requestedAppId, options);
|
|
1620
|
+
if (instanceBackedInstall) {
|
|
1621
|
+
return instanceBackedInstall;
|
|
1622
|
+
}
|
|
1623
|
+
const { appId, installedSpec, installedSpecYaml, } = await resolveInstallTarget(spec, specYaml, requestedAppId);
|
|
1624
|
+
const appDir = appDirForId(appId);
|
|
1625
|
+
ensureDirHost(appDir);
|
|
1626
|
+
createInstallLock(appDir, appId, spec.id, options.task);
|
|
1627
|
+
emitInstallTaskLog(options.task, `[app-manager] created ${INSTALL_LOCK_FILENAME} for ${appId}`);
|
|
1628
|
+
const yamlPath = join(appDir, "app-spec.yaml");
|
|
1629
|
+
const yamlTmp = yamlPath + ".tmp";
|
|
1630
|
+
writeFileSync(yamlTmp, installedSpecYaml, { mode: 0o644 });
|
|
1631
|
+
renameSync(yamlTmp, yamlPath);
|
|
1632
|
+
const manifest = {
|
|
1633
|
+
id: appId,
|
|
1634
|
+
installed_at: new Date().toISOString(),
|
|
1635
|
+
spec_hash: createHash("sha256").update(installedSpecYaml).digest("hex"),
|
|
1636
|
+
install_mode: "app-dir",
|
|
1637
|
+
};
|
|
1638
|
+
safeWriteJson(join(appDir, "manifest.json"), manifest, true);
|
|
1639
|
+
const artifacts = [];
|
|
1640
|
+
try {
|
|
1641
|
+
await runLifecycleSteps(installedSpec.lifecycle?.pre_install, "pre_install", artifacts, options.task, options.exec);
|
|
1642
|
+
}
|
|
1643
|
+
catch (e) {
|
|
1644
|
+
cleanupArtifacts(artifacts, options.task);
|
|
1645
|
+
rmSync(appDir, { recursive: true, force: true });
|
|
1646
|
+
throw decorateInstallError(e, installedSpec);
|
|
1647
|
+
}
|
|
1648
|
+
try {
|
|
1649
|
+
await runLifecycleSteps(installedSpec.lifecycle?.install, "install", artifacts, options.task, options.exec);
|
|
1650
|
+
// Auto-pull docker images declared in tasks (deduplicated, skip already-pulled by lifecycle steps)
|
|
1651
|
+
const pulledImages = new Set(artifacts.filter(a => a.type === "image").map(a => a.path));
|
|
1652
|
+
const imagesToPull = [...new Set(installedSpec.tasks.filter(t => t.image).map(t => t.image))];
|
|
1653
|
+
for (const image of imagesToPull) {
|
|
1654
|
+
if (!pulledImages.has(image)) {
|
|
1655
|
+
await pullDockerImageStep("install", image, `docker pull ${image}`, options.task);
|
|
1656
|
+
artifacts.push({ type: "image", path: image });
|
|
1657
|
+
}
|
|
1658
|
+
}
|
|
1659
|
+
const bootstrappedByAdapter = await bootstrapAdapterManagedApp(appId, installedSpec, options);
|
|
1660
|
+
if (!bootstrappedByAdapter) {
|
|
1661
|
+
safeWriteJson(join(appDir, "instance.json"), buildGenericAppMeta(appId, installedSpec, manifest.installed_at), true);
|
|
1662
|
+
}
|
|
1663
|
+
}
|
|
1664
|
+
catch (e) {
|
|
1665
|
+
try {
|
|
1666
|
+
await runLifecycleSteps(installedSpec.lifecycle?.uninstall, "rollback_uninstall", undefined, options.task, options.exec);
|
|
1667
|
+
}
|
|
1668
|
+
catch (rollbackError) {
|
|
1669
|
+
process.stdout.write(` [app-manager] rollback uninstall failed: ${rollbackError.message}\n`);
|
|
1670
|
+
emitInstallTaskLog(options.task, `[app-manager] rollback uninstall failed: ${rollbackError.message}`);
|
|
1671
|
+
}
|
|
1672
|
+
cleanupArtifacts(artifacts, options.task);
|
|
1673
|
+
rmSync(appDir, { recursive: true, force: true });
|
|
1674
|
+
throw decorateInstallError(e, installedSpec);
|
|
1675
|
+
}
|
|
1676
|
+
if (artifacts.length > 0) {
|
|
1677
|
+
manifest.artifacts = artifacts;
|
|
1678
|
+
safeWriteJson(join(appDir, "manifest.json"), manifest, true);
|
|
1679
|
+
}
|
|
1680
|
+
removeInstallLock(appDir);
|
|
1681
|
+
emitInstallTaskLog(options.task, `[app-manager] removed ${INSTALL_LOCK_FILENAME} for ${appId}`);
|
|
1682
|
+
return { spec: installedSpec, manifest };
|
|
1683
|
+
}
|
|
1684
|
+
export function listApps() {
|
|
1685
|
+
const results = [];
|
|
1686
|
+
for (const { rootDir, installMode } of [
|
|
1687
|
+
{ rootDir: APPS_DIR, installMode: "app-dir" },
|
|
1688
|
+
{ rootDir: INSTANCES_DIR, installMode: "instance-dir" },
|
|
1689
|
+
]) {
|
|
1690
|
+
if (!existsSync(rootDir))
|
|
1691
|
+
continue;
|
|
1692
|
+
let entries;
|
|
1693
|
+
try {
|
|
1694
|
+
entries = readdirSync(rootDir, { withFileTypes: true });
|
|
1695
|
+
}
|
|
1696
|
+
catch {
|
|
1697
|
+
continue;
|
|
1698
|
+
}
|
|
1699
|
+
for (const entry of entries) {
|
|
1700
|
+
if (!entry.isDirectory())
|
|
1701
|
+
continue;
|
|
1702
|
+
const appId = entry.name;
|
|
1703
|
+
const appDir = join(rootDir, appId);
|
|
1704
|
+
try {
|
|
1705
|
+
const appData = readAppFromDir(appDir, appId, installMode);
|
|
1706
|
+
if (!appData)
|
|
1707
|
+
continue;
|
|
1708
|
+
results.push(appData);
|
|
1709
|
+
}
|
|
1710
|
+
catch (e) {
|
|
1711
|
+
console.warn(`[app-manager] Skipping app '${appId}': ${e.message}`);
|
|
1712
|
+
}
|
|
1713
|
+
}
|
|
1714
|
+
}
|
|
1715
|
+
const deduped = new Map();
|
|
1716
|
+
for (const item of results) {
|
|
1717
|
+
deduped.set(item.manifest.id, item);
|
|
1718
|
+
}
|
|
1719
|
+
return [...deduped.values()];
|
|
1720
|
+
}
|
|
1721
|
+
export function getApp(id) {
|
|
1722
|
+
if (!APP_ID_RE.test(id))
|
|
1723
|
+
return null;
|
|
1724
|
+
const location = resolveAppLocation(id);
|
|
1725
|
+
if (!location)
|
|
1726
|
+
return null;
|
|
1727
|
+
return readAppFromDir(location.dir, id, location.installMode);
|
|
1728
|
+
}
|
|
1729
|
+
function writeAppManagerWarnings(warnings) {
|
|
1730
|
+
for (const warning of warnings) {
|
|
1731
|
+
process.stdout.write(` [app-manager] ${warning}\n`);
|
|
1732
|
+
}
|
|
1733
|
+
}
|
|
1734
|
+
export async function uninstallApp(id, options = {}) {
|
|
1735
|
+
if (!APP_ID_RE.test(id))
|
|
1736
|
+
return;
|
|
1737
|
+
const appData = getApp(id);
|
|
1738
|
+
const warnings = [];
|
|
1739
|
+
if (isInstanceBackedApp(appData)) {
|
|
1740
|
+
const { stopNomadJobInstance } = await import("../nomad-manager.js");
|
|
1741
|
+
const stopResult = await stopNomadJobInstance(id, true);
|
|
1742
|
+
if (!stopResult.ok && stopResult.error && !stopResult.error.includes("not found") && !stopResult.error.includes("not running")) {
|
|
1743
|
+
warnings.push(`应用 '${id}' 停止失败: ${stopResult.error}`);
|
|
1744
|
+
}
|
|
1745
|
+
if (appData) {
|
|
1746
|
+
// For multi-instance container apps the uninstalled app is the base
|
|
1747
|
+
// template and may have spawned children via copyApp. Children carry
|
|
1748
|
+
// `app_id === id`; stop + delete them here so they don't outlive the
|
|
1749
|
+
// template that described their lifecycle.
|
|
1750
|
+
if (appData.spec.singleInstance === false) {
|
|
1751
|
+
warnings.push(...await deleteLinkedInstances(appData.manifest.id));
|
|
1752
|
+
}
|
|
1753
|
+
const uninstallSteps = selectUninstallLifecycleSteps(appData);
|
|
1754
|
+
if (uninstallSteps?.length) {
|
|
1755
|
+
try {
|
|
1756
|
+
await runLifecycleSteps(uninstallSteps, "uninstall", undefined, undefined, options.exec);
|
|
1757
|
+
}
|
|
1758
|
+
catch (e) {
|
|
1759
|
+
writeAppManagerWarnings(warnings);
|
|
1760
|
+
throw new Error(`卸载生命周期执行失败: ${e.message}`);
|
|
1761
|
+
}
|
|
1762
|
+
}
|
|
1763
|
+
const cleanupList = selectCleanupArtifacts(appData);
|
|
1764
|
+
if (cleanupList.length > 0) {
|
|
1765
|
+
cleanupArtifacts(cleanupList);
|
|
1766
|
+
}
|
|
1767
|
+
}
|
|
1768
|
+
unregisterCapabilities(id);
|
|
1769
|
+
const instanceManager = await import("../instance-manager.js");
|
|
1770
|
+
const deleteResult = await instanceManager.deleteInstance(id);
|
|
1771
|
+
if (!deleteResult.ok && deleteResult.warnings?.length) {
|
|
1772
|
+
warnings.push(...deleteResult.warnings);
|
|
1773
|
+
}
|
|
1774
|
+
writeAppManagerWarnings(warnings);
|
|
1775
|
+
return;
|
|
1776
|
+
}
|
|
1777
|
+
const adapterManagedAgentType = getAdapterManagedAgentType(appData);
|
|
1778
|
+
if (adapterManagedAgentType) {
|
|
1779
|
+
const { stopNomadJobInstance } = await import("../nomad-manager.js");
|
|
1780
|
+
const stopResult = await stopNomadJobInstance(id, true);
|
|
1781
|
+
if (!stopResult.ok && stopResult.error && !stopResult.error.includes("not found") && !stopResult.error.includes("not running")) {
|
|
1782
|
+
warnings.push(`应用 '${id}' 停止失败: ${stopResult.error}`);
|
|
1783
|
+
}
|
|
1784
|
+
if (appData) {
|
|
1785
|
+
warnings.push(...await deleteLinkedInstances(appData.manifest.id));
|
|
1786
|
+
const uninstallSteps = selectUninstallLifecycleSteps(appData);
|
|
1787
|
+
if (uninstallSteps?.length) {
|
|
1788
|
+
try {
|
|
1789
|
+
await runLifecycleSteps(uninstallSteps, "uninstall", undefined, undefined, options.exec);
|
|
1790
|
+
}
|
|
1791
|
+
catch (e) {
|
|
1792
|
+
writeAppManagerWarnings(warnings);
|
|
1793
|
+
throw new Error(`卸载生命周期执行失败: ${e.message}`);
|
|
1794
|
+
}
|
|
1795
|
+
}
|
|
1796
|
+
const cleanupList = selectCleanupArtifacts(appData);
|
|
1797
|
+
if (cleanupList.length > 0) {
|
|
1798
|
+
cleanupArtifacts(cleanupList);
|
|
1799
|
+
}
|
|
1800
|
+
}
|
|
1801
|
+
unregisterCapabilities(id);
|
|
1802
|
+
const deleteResult = await legacyInstanceManager.deleteInstance(id);
|
|
1803
|
+
if (!deleteResult.ok && deleteResult.warnings?.length) {
|
|
1804
|
+
warnings.push(...deleteResult.warnings);
|
|
1805
|
+
}
|
|
1806
|
+
writeAppManagerWarnings(warnings);
|
|
1807
|
+
return;
|
|
1808
|
+
}
|
|
1809
|
+
const stopResult = await stopApp(id, true);
|
|
1810
|
+
if (stopResult.ok) {
|
|
1811
|
+
process.stdout.write(` [app-manager] Stopped running job for "${id}"\n`);
|
|
1812
|
+
}
|
|
1813
|
+
else if (stopResult.error && !stopResult.error.includes("not found") && !stopResult.error.includes("not running")) {
|
|
1814
|
+
warnings.push(`应用 '${id}' 停止失败: ${stopResult.error}`);
|
|
1815
|
+
}
|
|
1816
|
+
if (appData) {
|
|
1817
|
+
warnings.push(...await deleteLinkedInstances(appData.manifest.id));
|
|
1818
|
+
const uninstallSteps = selectUninstallLifecycleSteps(appData);
|
|
1819
|
+
if (uninstallSteps?.length) {
|
|
1820
|
+
try {
|
|
1821
|
+
await runLifecycleSteps(uninstallSteps, "uninstall", undefined, undefined, options.exec);
|
|
1822
|
+
}
|
|
1823
|
+
catch (e) {
|
|
1824
|
+
writeAppManagerWarnings(warnings);
|
|
1825
|
+
throw new Error(`卸载生命周期执行失败: ${e.message}`);
|
|
1826
|
+
}
|
|
1827
|
+
}
|
|
1828
|
+
const cleanupList = selectCleanupArtifacts(appData);
|
|
1829
|
+
if (cleanupList.length > 0) {
|
|
1830
|
+
cleanupArtifacts(cleanupList);
|
|
1831
|
+
}
|
|
1832
|
+
}
|
|
1833
|
+
unregisterCapabilities(id);
|
|
1834
|
+
const appDir = resolveAppDir(id) ?? appDirForId(id);
|
|
1835
|
+
rmSync(appDir, { recursive: true, force: true });
|
|
1836
|
+
writeAppManagerWarnings(warnings);
|
|
1837
|
+
}
|
|
1838
|
+
export function uninstallAppTask(id, exec) {
|
|
1839
|
+
if (!getApp(id)) {
|
|
1840
|
+
return { ok: false, error: `App '${id}' not found`, kind: "uninstall" };
|
|
1841
|
+
}
|
|
1842
|
+
return startAppLifecycleTask(id, "uninstall", `开始卸载应用 ${id}...`, `应用 ${id} 卸载完成`, async () => {
|
|
1843
|
+
await uninstallApp(id, { exec });
|
|
1844
|
+
});
|
|
1845
|
+
}
|
|
1846
|
+
export async function runPostStartSteps(spec) {
|
|
1847
|
+
await runLifecycleSteps(spec.lifecycle?.post_start, "post_start");
|
|
1848
|
+
}
|
|
1849
|
+
export function registerCapabilities(instanceId, spec, portOverride) {
|
|
1850
|
+
if (!spec.provides || spec.provides.length === 0)
|
|
1851
|
+
return;
|
|
1852
|
+
const reg = readRegistry();
|
|
1853
|
+
const now = new Date().toISOString();
|
|
1854
|
+
for (const provide of spec.provides) {
|
|
1855
|
+
const hostPort = typeof portOverride === "number" && portOverride > 0
|
|
1856
|
+
? portOverride
|
|
1857
|
+
: getProvidePort(spec, provide);
|
|
1858
|
+
if (hostPort == null) {
|
|
1859
|
+
continue;
|
|
1860
|
+
}
|
|
1861
|
+
reg.capabilities[provide.capability] = {
|
|
1862
|
+
instanceId,
|
|
1863
|
+
hostPort,
|
|
1864
|
+
address: buildCapabilityAddress(hostPort, provide.path),
|
|
1865
|
+
path: provide.path,
|
|
1866
|
+
registered_at: now,
|
|
1867
|
+
};
|
|
1868
|
+
}
|
|
1869
|
+
writeRegistry(reg);
|
|
1870
|
+
}
|
|
1871
|
+
export function unregisterCapabilities(instanceId) {
|
|
1872
|
+
const reg = readRegistry();
|
|
1873
|
+
for (const key of Object.keys(reg.capabilities)) {
|
|
1874
|
+
if (reg.capabilities[key].instanceId === instanceId) {
|
|
1875
|
+
delete reg.capabilities[key];
|
|
1876
|
+
}
|
|
1877
|
+
}
|
|
1878
|
+
writeRegistry(reg);
|
|
1879
|
+
}
|
|
1880
|
+
export function resolveRequires(spec) {
|
|
1881
|
+
if (!spec.requires || spec.requires.length === 0)
|
|
1882
|
+
return {};
|
|
1883
|
+
const reg = readRegistry();
|
|
1884
|
+
const result = {};
|
|
1885
|
+
for (const req of spec.requires) {
|
|
1886
|
+
const entry = reg.capabilities[req.capability];
|
|
1887
|
+
if (entry) {
|
|
1888
|
+
result[req.inject_as] = entry.address;
|
|
1889
|
+
}
|
|
1890
|
+
else if (req.required !== false) {
|
|
1891
|
+
throw new Error(`Required capability '${req.capability}' is not registered`);
|
|
1892
|
+
}
|
|
1893
|
+
}
|
|
1894
|
+
return result;
|
|
1895
|
+
}
|
|
1896
|
+
// ── App Lifecycle (delegates to nomad-manager) ───────────────────
|
|
1897
|
+
export async function startApp(appId) {
|
|
1898
|
+
const appData = getApp(appId);
|
|
1899
|
+
if (!appData) {
|
|
1900
|
+
return { ok: false, error: `App '${appId}' not found` };
|
|
1901
|
+
}
|
|
1902
|
+
if (appData.install_state === "installing") {
|
|
1903
|
+
return { ok: false, error: `App '${appId}' is still installing` };
|
|
1904
|
+
}
|
|
1905
|
+
if (isInstanceBackedApp(appData) || getAdapterManagedAgentType(appData)) {
|
|
1906
|
+
const { startNomadJobInstance } = await import("../nomad-manager.js");
|
|
1907
|
+
const result = await startNomadJobInstance(appId);
|
|
1908
|
+
if (!result.ok)
|
|
1909
|
+
return result;
|
|
1910
|
+
if (appData.spec.provides?.length) {
|
|
1911
|
+
registerCapabilities(appId, appData.spec);
|
|
1912
|
+
}
|
|
1913
|
+
if (appData.spec.lifecycle?.post_start?.length) {
|
|
1914
|
+
await runPostStartSteps(appData.spec);
|
|
1915
|
+
}
|
|
1916
|
+
return result;
|
|
1917
|
+
}
|
|
1918
|
+
let extraEnv = {};
|
|
1919
|
+
try {
|
|
1920
|
+
extraEnv = resolveRequires(appData.spec);
|
|
1921
|
+
}
|
|
1922
|
+
catch (e) {
|
|
1923
|
+
return { ok: false, error: e.message };
|
|
1924
|
+
}
|
|
1925
|
+
const { startAppJob: nomadStart, checkDependencies, waitForRunning } = await import("../nomad-manager.js");
|
|
1926
|
+
const depCheck = await checkDependencies(appData.spec);
|
|
1927
|
+
if (!depCheck.ok) {
|
|
1928
|
+
return { ok: false, error: depCheck.errors.join("; ") };
|
|
1929
|
+
}
|
|
1930
|
+
const result = await nomadStart(appData.spec, appId, extraEnv);
|
|
1931
|
+
if (!result.ok) {
|
|
1932
|
+
return result;
|
|
1933
|
+
}
|
|
1934
|
+
if (appData.spec.provides?.length) {
|
|
1935
|
+
registerCapabilities(appId, appData.spec);
|
|
1936
|
+
}
|
|
1937
|
+
if (appData.spec.lifecycle?.post_start?.length) {
|
|
1938
|
+
const running = await waitForRunning(appId);
|
|
1939
|
+
if (running) {
|
|
1940
|
+
await runPostStartSteps(appData.spec);
|
|
1941
|
+
}
|
|
1942
|
+
}
|
|
1943
|
+
return result;
|
|
1944
|
+
}
|
|
1945
|
+
export function startAppTask(appId) {
|
|
1946
|
+
if (!getApp(appId)) {
|
|
1947
|
+
return { ok: false, error: `App '${appId}' not found`, kind: "start" };
|
|
1948
|
+
}
|
|
1949
|
+
return startAppLifecycleTask(appId, "start", `开始启动应用 ${appId}...`, `应用 ${appId} 已启动`, async () => {
|
|
1950
|
+
const result = await startApp(appId);
|
|
1951
|
+
if (!result.ok) {
|
|
1952
|
+
throw new Error(result.error || `App '${appId}' start failed`);
|
|
1953
|
+
}
|
|
1954
|
+
});
|
|
1955
|
+
}
|
|
1956
|
+
export async function stopApp(appId, purge = false) {
|
|
1957
|
+
const appData = getApp(appId);
|
|
1958
|
+
if (appData?.install_state === "installing") {
|
|
1959
|
+
return { ok: false, error: `App '${appId}' is still installing` };
|
|
1960
|
+
}
|
|
1961
|
+
if (isInstanceBackedApp(appData) || getAdapterManagedAgentType(appData)) {
|
|
1962
|
+
const { stopNomadJobInstance } = await import("../nomad-manager.js");
|
|
1963
|
+
const result = await stopNomadJobInstance(appId, purge);
|
|
1964
|
+
if (result.ok || result.error?.includes("not running") || result.error?.includes("not found")) {
|
|
1965
|
+
unregisterCapabilities(appId);
|
|
1966
|
+
}
|
|
1967
|
+
return result;
|
|
1968
|
+
}
|
|
1969
|
+
const { stopAppJob } = await import("../nomad-manager.js");
|
|
1970
|
+
const result = await stopAppJob(appId, purge);
|
|
1971
|
+
if (result.ok || result.error?.includes("not running") || result.error?.includes("not found")) {
|
|
1972
|
+
unregisterCapabilities(appId);
|
|
1973
|
+
}
|
|
1974
|
+
return result;
|
|
1975
|
+
}
|
|
1976
|
+
export function stopAppTask(appId, purge = false) {
|
|
1977
|
+
if (!getApp(appId)) {
|
|
1978
|
+
return { ok: false, error: `App '${appId}' not found`, kind: "stop" };
|
|
1979
|
+
}
|
|
1980
|
+
return startAppLifecycleTask(appId, "stop", `开始停止应用 ${appId}${purge ? "(purge)" : ""}...`, `应用 ${appId} 已停止${purge ? "(purged)" : ""}`, async () => {
|
|
1981
|
+
const result = await stopApp(appId, purge);
|
|
1982
|
+
if (!result.ok) {
|
|
1983
|
+
throw new Error(result.error || `App '${appId}' stop failed`);
|
|
1984
|
+
}
|
|
1985
|
+
});
|
|
1986
|
+
}
|
|
1987
|
+
export async function restartApp(appId) {
|
|
1988
|
+
const appData = getApp(appId);
|
|
1989
|
+
if (appData?.install_state === "installing") {
|
|
1990
|
+
return { ok: false, error: `App '${appId}' is still installing` };
|
|
1991
|
+
}
|
|
1992
|
+
if (isInstanceBackedApp(appData) || getAdapterManagedAgentType(appData)) {
|
|
1993
|
+
const { restartNomadJobInstance } = await import("../nomad-manager.js");
|
|
1994
|
+
const result = await restartNomadJobInstance(appId);
|
|
1995
|
+
if (!result.ok)
|
|
1996
|
+
return result;
|
|
1997
|
+
if (appData?.spec.provides?.length) {
|
|
1998
|
+
registerCapabilities(appId, appData.spec);
|
|
1999
|
+
}
|
|
2000
|
+
if (appData?.spec.lifecycle?.post_start?.length) {
|
|
2001
|
+
await runPostStartSteps(appData.spec);
|
|
2002
|
+
}
|
|
2003
|
+
return result;
|
|
2004
|
+
}
|
|
2005
|
+
const stopResult = await stopApp(appId, true);
|
|
2006
|
+
if (!stopResult.ok && !stopResult.error?.includes("not found")) {
|
|
2007
|
+
return stopResult;
|
|
2008
|
+
}
|
|
2009
|
+
return startApp(appId);
|
|
2010
|
+
}
|
|
2011
|
+
export function restartAppTask(appId) {
|
|
2012
|
+
if (!getApp(appId)) {
|
|
2013
|
+
return { ok: false, error: `App '${appId}' not found`, kind: "restart" };
|
|
2014
|
+
}
|
|
2015
|
+
return startAppLifecycleTask(appId, "restart", `开始重启应用 ${appId}...`, `应用 ${appId} 已重启`, async () => {
|
|
2016
|
+
const result = await restartApp(appId);
|
|
2017
|
+
if (!result.ok) {
|
|
2018
|
+
throw new Error(result.error || `App '${appId}' restart failed`);
|
|
2019
|
+
}
|
|
2020
|
+
});
|
|
2021
|
+
}
|
|
2022
|
+
export async function getAppRuntimeStatus(appId) {
|
|
2023
|
+
const appData = getApp(appId);
|
|
2024
|
+
if (isInstanceBackedApp(appData) || getAdapterManagedAgentType(appData)) {
|
|
2025
|
+
const { getInstanceStatus } = await import("../nomad-manager.js");
|
|
2026
|
+
const st = await getInstanceStatus(appId);
|
|
2027
|
+
return {
|
|
2028
|
+
status: st.status,
|
|
2029
|
+
uptime: st.uptime ?? undefined,
|
|
2030
|
+
memory_mb: st.memory_mb ?? undefined,
|
|
2031
|
+
};
|
|
2032
|
+
}
|
|
2033
|
+
const { getAppStatus, isBinaryRunning } = await import("../nomad-manager.js");
|
|
2034
|
+
const st = await getAppStatus(appId);
|
|
2035
|
+
// For process-runtime apps: if Nomad can't place the job (pending/unplaced due
|
|
2036
|
+
// to missing raw_exec driver) but the binary is already running on the host,
|
|
2037
|
+
// report the real status as "running" so the UI doesn't show a failure.
|
|
2038
|
+
if (st.status === "pending" || st.status === "stopped") {
|
|
2039
|
+
const appData = getApp(appId);
|
|
2040
|
+
if (appData) {
|
|
2041
|
+
const processTask = appData.spec.tasks.find((t) => t.runtime === "process" && (t.role ?? "service") === "service" && t.command);
|
|
2042
|
+
if (processTask) {
|
|
2043
|
+
const running = await isBinaryRunning(processTask.command);
|
|
2044
|
+
if (running) {
|
|
2045
|
+
return { status: "running" };
|
|
2046
|
+
}
|
|
2047
|
+
}
|
|
2048
|
+
}
|
|
2049
|
+
}
|
|
2050
|
+
return {
|
|
2051
|
+
status: st.status,
|
|
2052
|
+
uptime: st.uptime ?? undefined,
|
|
2053
|
+
memory_mb: st.memory_mb ?? undefined,
|
|
2054
|
+
tasks: st.tasks,
|
|
2055
|
+
error: st.error,
|
|
2056
|
+
};
|
|
2057
|
+
}
|
|
2058
|
+
export async function getAppStatus(appId) {
|
|
2059
|
+
const appData = getApp(appId);
|
|
2060
|
+
if (appData?.install_state === "installing") {
|
|
2061
|
+
return {
|
|
2062
|
+
status: "installing",
|
|
2063
|
+
tasks: {},
|
|
2064
|
+
pid: null,
|
|
2065
|
+
uptime: null,
|
|
2066
|
+
memory_mb: null,
|
|
2067
|
+
cpu_percent: null,
|
|
2068
|
+
restarts: 0,
|
|
2069
|
+
error: undefined,
|
|
2070
|
+
};
|
|
2071
|
+
}
|
|
2072
|
+
if (isInstanceBackedApp(appData) || getAdapterManagedAgentType(appData)) {
|
|
2073
|
+
const { getInstanceStatus } = await import("../nomad-manager.js");
|
|
2074
|
+
const st = await getInstanceStatus(appId);
|
|
2075
|
+
return {
|
|
2076
|
+
status: st.status,
|
|
2077
|
+
tasks: {},
|
|
2078
|
+
pid: null,
|
|
2079
|
+
uptime: st.uptime,
|
|
2080
|
+
memory_mb: st.memory_mb,
|
|
2081
|
+
cpu_percent: st.cpu_percent,
|
|
2082
|
+
restarts: 0,
|
|
2083
|
+
error: undefined,
|
|
2084
|
+
};
|
|
2085
|
+
}
|
|
2086
|
+
const { getAppStatus: nomadGetAppStatus } = await import("../nomad-manager.js");
|
|
2087
|
+
return nomadGetAppStatus(appId);
|
|
2088
|
+
}
|
|
2089
|
+
export async function getAppLogs(appId, taskName, lines = 200, logType = "stderr") {
|
|
2090
|
+
const appData = getApp(appId);
|
|
2091
|
+
if (isInstanceBackedApp(appData) || getAdapterManagedAgentType(appData)) {
|
|
2092
|
+
const { getInstanceLogs } = await import("../nomad-manager.js");
|
|
2093
|
+
return getInstanceLogs(appId, lines, logType);
|
|
2094
|
+
}
|
|
2095
|
+
const { getAppLogs: nomadGetAppLogs } = await import("../nomad-manager.js");
|
|
2096
|
+
return nomadGetAppLogs(appId, taskName, lines, logType);
|
|
2097
|
+
}
|
|
2098
|
+
export async function execInApp(appId, command, timeoutMs) {
|
|
2099
|
+
const appData = getApp(appId);
|
|
2100
|
+
if (isInstanceBackedApp(appData) || getAdapterManagedAgentType(appData)) {
|
|
2101
|
+
const { execInInstance } = await import("../nomad-manager.js");
|
|
2102
|
+
return execInInstance(appId, command, timeoutMs ?? 120_000);
|
|
2103
|
+
}
|
|
2104
|
+
const { execInApp: nomadExecInApp } = await import("../nomad-manager.js");
|
|
2105
|
+
return nomadExecInApp(appId, "", command, timeoutMs ?? 120_000);
|
|
2106
|
+
}
|
|
2107
|
+
export async function streamExecInApp(appId, command, handlers, options) {
|
|
2108
|
+
const timeoutMs = options?.timeoutMs ?? 120_000;
|
|
2109
|
+
const taskName = options?.taskName ?? "";
|
|
2110
|
+
const appData = getApp(appId);
|
|
2111
|
+
if (isInstanceBackedApp(appData) || getAdapterManagedAgentType(appData)) {
|
|
2112
|
+
const { streamExecInInstance } = await import("../nomad-manager.js");
|
|
2113
|
+
return streamExecInInstance(appId, command, handlers, timeoutMs, taskName);
|
|
2114
|
+
}
|
|
2115
|
+
const { streamExecInApp: nomadStreamExecInApp } = await import("../nomad-manager.js");
|
|
2116
|
+
return nomadStreamExecInApp(appId, taskName, command, handlers, timeoutMs);
|
|
2117
|
+
}
|
|
2118
|
+
export async function copyApp(sourceId) {
|
|
2119
|
+
const source = getApp(sourceId);
|
|
2120
|
+
if (!source)
|
|
2121
|
+
throw new Error(`App '${sourceId}' not found`);
|
|
2122
|
+
if (source.spec.singleInstance)
|
|
2123
|
+
throw new Error(`App '${sourceId}' is singleInstance — cannot copy`);
|
|
2124
|
+
// A faithful copy requires re-materialization: ${app_id} tokens, port
|
|
2125
|
+
// shifts, and app-scoped paths all need to be recomputed for the new
|
|
2126
|
+
// slot. Naively swapping the `id:` line leaves lifecycle scripts and
|
|
2127
|
+
// volume sources pointing at the source's settings dir, which then
|
|
2128
|
+
// collides on start and leaks state across instances.
|
|
2129
|
+
//
|
|
2130
|
+
// The base app's on-disk yaml for offset=0 is written as the pristine
|
|
2131
|
+
// originalSpecYaml (see resolveInstallTarget → installedSpecYaml), so
|
|
2132
|
+
// reading from the base dir recovers the template that installApp can
|
|
2133
|
+
// re-materialize. The source's own yaml is already baked to its own
|
|
2134
|
+
// offset when source is itself a copy, and installApp cannot cleanly
|
|
2135
|
+
// un-do that substitution from a string diff alone.
|
|
2136
|
+
const baseId = source.spec.id;
|
|
2137
|
+
const baseDir = resolveAppDir(baseId);
|
|
2138
|
+
if (!baseDir) {
|
|
2139
|
+
throw new Error(`App '${sourceId}' base '${baseId}' is not installed; cannot copy without the pristine spec. Reinstall the base app first.`);
|
|
2140
|
+
}
|
|
2141
|
+
const baseYaml = readFileSync(join(baseDir, "app-spec.yaml"), "utf-8");
|
|
2142
|
+
// Delegate: installApp runs resolveInstallTarget (which picks the next
|
|
2143
|
+
// free <baseId>-<prefix><n> slot and applies a fresh port shift) and
|
|
2144
|
+
// executes the install lifecycle in the new app dir.
|
|
2145
|
+
return installApp(baseYaml);
|
|
2146
|
+
}
|
|
2147
|
+
export { onConfigChange, notifyConfigChange, instanceDir, instanceMetaPath, defaultModelEnvFile, normalizePath, extractGatewayPort, isPortInUse, allocateGatewayPort, releasePendingPort, getResolvedOpenclawBin, resolveServiceUser, chownToServiceUser, parseEnvFile, updateEnvFile, inferProviderApiKeyEnvName, listInstances, getInstance, updateInstanceMeta, deleteInstance, getConfig, getStoredConfig, saveConfig, CHANNEL_PLUGIN_MAP, isChannelPluginInstalled, createInstance, getOpenclawHome, saveFeishuCredentials, saveWeixinCredentials, getWeixinAccounts, getOpenclawConfigPath, getLegacyOpenclawConfigPath, getInstanceRuntime, getRuntimeEnvFiles, getGatewayPort, getGatewayHost, getListeningHostForPort, urlHost, findInstancesSharingOpenclawHome, reallocateGatewayPort, findInstancesSharingGatewayPort, getRuntimeEnv, defaultGatewayPort, releasePort, } from "../instance-manager.js";
|
|
2148
|
+
//# sourceMappingURL=app-manager.js.map
|