godot-daedalus_backend 1.0.2 → 1.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -1
- package/bin/godot-daedalus-manager.js +4 -0
- package/package.json +2 -1
- package/src/main.ts +1 -1
- package/src/manager/backend.ts +363 -0
- package/src/manager/cli.ts +182 -0
- package/src/manager/frontend.ts +233 -0
- package/src/manager/json-file.ts +24 -0
- package/src/manager/manager-error.ts +46 -0
- package/src/manager/paths.ts +69 -0
- package/src/manager/process.ts +98 -0
- package/src/manager/semver.ts +29 -0
- package/src/manager/status.ts +27 -0
- package/src/manager/types.ts +86 -0
- package/src/ping-client.ts +1 -1
- package/src/server/backend-health.ts +64 -0
- package/src/server/websocket-server.ts +2 -6
package/README.md
CHANGED
|
@@ -40,7 +40,7 @@ npm install godot-daedalus_backend
|
|
|
40
40
|
After installing the package, start it through the published bin command:
|
|
41
41
|
|
|
42
42
|
```powershell
|
|
43
|
-
$env:PORT = "
|
|
43
|
+
$env:PORT = "38180"
|
|
44
44
|
godot-daedalus-backend
|
|
45
45
|
```
|
|
46
46
|
|
|
@@ -71,6 +71,29 @@ Then run:
|
|
|
71
71
|
npm run daedalus
|
|
72
72
|
```
|
|
73
73
|
|
|
74
|
+
## Daedalus Manager
|
|
75
|
+
|
|
76
|
+
The package also ships `godot-daedalus-manager`, a JSON-first manager CLI used by the Godot plugin for stable install, update, launch, rollback, and diagnostics workflows.
|
|
77
|
+
|
|
78
|
+
```powershell
|
|
79
|
+
godot-daedalus-manager --json status --project "D:\GodotProjects\example"
|
|
80
|
+
godot-daedalus-manager --json backend install
|
|
81
|
+
godot-daedalus-manager --json backend start --port 38180
|
|
82
|
+
godot-daedalus-manager --json backend stop
|
|
83
|
+
godot-daedalus-manager --json backend rollback
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Backend installs are versioned under `%APPDATA%\.godot_daedalus\backend\versions\<version>`. The manager switches `%APPDATA%\.godot_daedalus\backend\current.json` after a new version is staged, avoiding in-place edits of a running `node_modules` directory.
|
|
87
|
+
|
|
88
|
+
Frontend plugin updates are staged rather than hot-applied. GitHub releases should provide:
|
|
89
|
+
|
|
90
|
+
```text
|
|
91
|
+
godot-daedalus-plugin-vX.Y.Z.zip
|
|
92
|
+
godot-daedalus-plugin-vX.Y.Z.manifest.json
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
The zip must contain `addons/godot_daedalus/plugin.cfg`. The manifest must include `version`, `tag`, `sha256`, `assetName`, and optionally `minGodotVersion`.
|
|
96
|
+
|
|
74
97
|
## Run The Godot MCP Server
|
|
75
98
|
|
|
76
99
|
The standalone Godot MCP server requires a Godot project path:
|
package/package.json
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "godot-daedalus_backend",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.4",
|
|
4
4
|
"description": "TypeScript backend for the Godot Daedalus editor assistant plugin.",
|
|
5
5
|
"main": "./src/main.ts",
|
|
6
6
|
"bin": {
|
|
7
7
|
"godot-daedalus-backend": "bin/godot-daedalus-backend.js",
|
|
8
|
+
"godot-daedalus-manager": "bin/godot-daedalus-manager.js",
|
|
8
9
|
"godot-daedalus-mcp": "bin/godot-daedalus-mcp.js",
|
|
9
10
|
"godot-daedalus-terminal-mcp": "bin/godot-daedalus-terminal-mcp.js"
|
|
10
11
|
},
|
package/src/main.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createServer } from "./server/websocket-server.js";
|
|
2
2
|
import { McpHost } from "./mcp/mcp-host.js";
|
|
3
3
|
|
|
4
|
-
const DEFAULT_PORT: number =
|
|
4
|
+
const DEFAULT_PORT: number = 38180;
|
|
5
5
|
const portText: string = process.env.PORT ?? String(DEFAULT_PORT);
|
|
6
6
|
const port: number = Number.parseInt(portText, 10);
|
|
7
7
|
|
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import { createWriteStream } from "node:fs";
|
|
3
|
+
import { mkdir, readdir, readFile, rename, rm, stat } from "node:fs/promises";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { createConnection } from "node:net";
|
|
6
|
+
import { BACKEND_BIN_NAME, BACKEND_PACKAGE_NAME, DEFAULT_BACKEND_PORT, type BackendCurrentFile, type BackendPidFile } from "./types.js";
|
|
7
|
+
import { ManagerError } from "./manager-error.js";
|
|
8
|
+
import { assertInside, getManagerPaths, type ManagerPaths } from "./paths.js";
|
|
9
|
+
import { readJsonFile, writeJsonFile } from "./json-file.js";
|
|
10
|
+
import { runCommand, stopProcess, isProcessAlive, type CommandResult } from "./process.js";
|
|
11
|
+
|
|
12
|
+
const MAX_BACKEND_VERSIONS: number = 3;
|
|
13
|
+
|
|
14
|
+
export async function getLatestBackendVersion(): Promise<string | null> {
|
|
15
|
+
const result: CommandResult = await runCommand(getNpmCommand(), ["view", BACKEND_PACKAGE_NAME, "version"], { timeoutMs: 20000 });
|
|
16
|
+
if (result.exitCode !== 0) {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const version: string = result.stdout.trim().split(/\s+/)[0] ?? "";
|
|
21
|
+
return version.length === 0 ? null : version;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function getCurrentBackend(): Promise<BackendCurrentFile | null> {
|
|
25
|
+
const paths: ManagerPaths = getManagerPaths();
|
|
26
|
+
return readJsonFile<BackendCurrentFile>(paths.backendCurrentPath);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function installBackend(versionSpec: string = "latest"): Promise<{ version: string; path: string }> {
|
|
30
|
+
const paths: ManagerPaths = getManagerPaths();
|
|
31
|
+
await mkdir(paths.backendVersionsDir, { recursive: true });
|
|
32
|
+
const packageSpec: string = await resolveBackendPackageSpec(versionSpec);
|
|
33
|
+
const stagingName: string = versionSpec.match(/^\d+\.\d+\.\d+$/) !== null || versionSpec === "latest"
|
|
34
|
+
? `${packageSpec.replace(`${BACKEND_PACKAGE_NAME}@`, "")}.staging`
|
|
35
|
+
: `manual-${Date.now()}.staging`;
|
|
36
|
+
const stagingDir: string = assertInside(paths.backendVersionsDir, join(paths.backendVersionsDir, stagingName));
|
|
37
|
+
const previous: BackendCurrentFile | null = await getCurrentBackend();
|
|
38
|
+
|
|
39
|
+
await rm(stagingDir, { recursive: true, force: true });
|
|
40
|
+
await mkdir(stagingDir, { recursive: true });
|
|
41
|
+
const installResult: CommandResult = await runCommand(getNpmCommand(), ["install", "--prefix", stagingDir, "--prefer-online", packageSpec], { timeoutMs: 120000 });
|
|
42
|
+
if (installResult.exitCode !== 0) {
|
|
43
|
+
await rm(stagingDir, { recursive: true, force: true });
|
|
44
|
+
throw new ManagerError({
|
|
45
|
+
code: "install_failed",
|
|
46
|
+
message: `Failed to install ${packageSpec}`,
|
|
47
|
+
details: installResult.stderr || installResult.stdout,
|
|
48
|
+
suggestedAction: "Check your npm registry/network, then try again."
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const installedVersion: string = await readInstalledBackendVersion(stagingDir);
|
|
53
|
+
const versionDir: string = assertInside(paths.backendVersionsDir, join(paths.backendVersionsDir, installedVersion));
|
|
54
|
+
const packageJsonPath: string = join(stagingDir, "node_modules", BACKEND_PACKAGE_NAME, "package.json");
|
|
55
|
+
await rm(versionDir, { recursive: true, force: true });
|
|
56
|
+
await rename(stagingDir, versionDir);
|
|
57
|
+
|
|
58
|
+
await writeJsonFile(paths.backendCurrentPath, {
|
|
59
|
+
version: installedVersion,
|
|
60
|
+
path: versionDir,
|
|
61
|
+
...(previous === null ? {} : { previousVersion: previous.version }),
|
|
62
|
+
updatedAt: new Date().toISOString()
|
|
63
|
+
} satisfies BackendCurrentFile);
|
|
64
|
+
await pruneBackendVersions(installedVersion, previous?.version);
|
|
65
|
+
return { version: installedVersion, path: versionDir };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function resolveBackendPackageSpec(versionSpec: string): Promise<string> {
|
|
69
|
+
if (versionSpec === "latest") {
|
|
70
|
+
return `${BACKEND_PACKAGE_NAME}@${await requireLatestBackendVersion()}`;
|
|
71
|
+
}
|
|
72
|
+
if (versionSpec.match(/^\d+\.\d+\.\d+(?:[-+].*)?$/) !== null) {
|
|
73
|
+
return `${BACKEND_PACKAGE_NAME}@${versionSpec}`;
|
|
74
|
+
}
|
|
75
|
+
return versionSpec;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function startBackend(port: number = DEFAULT_BACKEND_PORT): Promise<BackendPidFile> {
|
|
79
|
+
const paths: ManagerPaths = getManagerPaths();
|
|
80
|
+
const current: BackendCurrentFile | null = await getCurrentBackend();
|
|
81
|
+
if (current === null) {
|
|
82
|
+
throw new ManagerError({
|
|
83
|
+
code: "not_installed",
|
|
84
|
+
message: "Daedalus backend is not installed.",
|
|
85
|
+
suggestedAction: "Run godot-daedalus-manager backend install --json."
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const existingPid: BackendPidFile | null = await readJsonFile<BackendPidFile>(paths.backendPidPath);
|
|
90
|
+
if (existingPid !== null && isProcessAlive(existingPid.pid)) {
|
|
91
|
+
return existingPid;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
await mkdir(paths.logsDir, { recursive: true });
|
|
95
|
+
await mkdir(paths.backendRuntimeDir, { recursive: true });
|
|
96
|
+
const logPath: string = join(paths.logsDir, `backend_${current.version}_${Date.now()}.log`);
|
|
97
|
+
const out = createWriteStream(logPath, { flags: "a" });
|
|
98
|
+
const packageRoot: string = join(current.path, "node_modules", BACKEND_PACKAGE_NAME);
|
|
99
|
+
const entryPath: string = join(packageRoot, "src", "main.ts");
|
|
100
|
+
const child = (await import("node:child_process")).spawn(
|
|
101
|
+
process.execPath,
|
|
102
|
+
["--import", "tsx", entryPath],
|
|
103
|
+
{
|
|
104
|
+
cwd: packageRoot,
|
|
105
|
+
env: { ...process.env, PORT: String(port) },
|
|
106
|
+
detached: true,
|
|
107
|
+
windowsHide: true,
|
|
108
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
109
|
+
}
|
|
110
|
+
);
|
|
111
|
+
child.stdout.pipe(out, { end: false });
|
|
112
|
+
child.stderr.pipe(out, { end: false });
|
|
113
|
+
child.unref();
|
|
114
|
+
|
|
115
|
+
const pidFile: BackendPidFile = {
|
|
116
|
+
pid: child.pid ?? 0,
|
|
117
|
+
version: current.version,
|
|
118
|
+
port,
|
|
119
|
+
url: `ws://localhost:${port}`,
|
|
120
|
+
logPath,
|
|
121
|
+
startedAt: new Date().toISOString()
|
|
122
|
+
};
|
|
123
|
+
await writeJsonFile(paths.backendPidPath, pidFile);
|
|
124
|
+
return pidFile;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export async function stopBackend(): Promise<{ stopped: boolean; pid: number | null; details: string }> {
|
|
128
|
+
const paths: ManagerPaths = getManagerPaths();
|
|
129
|
+
const pidFile: BackendPidFile | null = await readJsonFile<BackendPidFile>(paths.backendPidPath);
|
|
130
|
+
if (pidFile === null) {
|
|
131
|
+
return { stopped: false, pid: null, details: "No backend pid file found." };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (!isProcessAlive(pidFile.pid)) {
|
|
135
|
+
await rm(paths.backendPidPath, { force: true });
|
|
136
|
+
return { stopped: false, pid: pidFile.pid, details: "Backend process was already stopped." };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const result: CommandResult = await stopProcess(pidFile.pid);
|
|
140
|
+
if (result.exitCode === 0 || !isProcessAlive(pidFile.pid)) {
|
|
141
|
+
await rm(paths.backendPidPath, { force: true });
|
|
142
|
+
return { stopped: true, pid: pidFile.pid, details: result.stdout || result.stderr };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
throw new ManagerError({
|
|
146
|
+
code: "process_failed",
|
|
147
|
+
message: `Failed to stop backend process ${pidFile.pid}`,
|
|
148
|
+
details: result.stderr || result.stdout,
|
|
149
|
+
logPath: pidFile.logPath
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export async function rollbackBackend(): Promise<BackendCurrentFile> {
|
|
154
|
+
const paths: ManagerPaths = getManagerPaths();
|
|
155
|
+
const current: BackendCurrentFile | null = await getCurrentBackend();
|
|
156
|
+
if (current === null || current.previousVersion === undefined) {
|
|
157
|
+
throw new ManagerError({
|
|
158
|
+
code: "not_installed",
|
|
159
|
+
message: "No previous backend version is available for rollback."
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const previousDir: string = join(paths.backendVersionsDir, current.previousVersion);
|
|
164
|
+
const previousStats = await stat(previousDir).catch((): null => null);
|
|
165
|
+
if (previousStats === null || !previousStats.isDirectory()) {
|
|
166
|
+
throw new ManagerError({
|
|
167
|
+
code: "not_installed",
|
|
168
|
+
message: `Previous backend version is missing: ${current.previousVersion}`
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const rollbackFile: BackendCurrentFile = {
|
|
173
|
+
version: current.previousVersion,
|
|
174
|
+
path: previousDir,
|
|
175
|
+
previousVersion: current.version,
|
|
176
|
+
updatedAt: new Date().toISOString()
|
|
177
|
+
};
|
|
178
|
+
await writeJsonFile(paths.backendCurrentPath, rollbackFile);
|
|
179
|
+
return rollbackFile;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export async function healthBackend(url: string = `ws://localhost:${DEFAULT_BACKEND_PORT}`): Promise<{ ok: boolean; url: string; error: string | null; result?: unknown }> {
|
|
183
|
+
try {
|
|
184
|
+
const parsedUrl: URL = new URL(url);
|
|
185
|
+
const port: number = parsedUrl.port === "" ? 80 : Number.parseInt(parsedUrl.port, 10);
|
|
186
|
+
const host: string = parsedUrl.hostname;
|
|
187
|
+
const path: string = `${parsedUrl.pathname}${parsedUrl.search}`;
|
|
188
|
+
const key: string = randomBytes(16).toString("base64");
|
|
189
|
+
return await new Promise((resolveHealth): void => {
|
|
190
|
+
const socket = createConnection({ host, port });
|
|
191
|
+
let buffer: Buffer = Buffer.alloc(0);
|
|
192
|
+
let handshakeDone: boolean = false;
|
|
193
|
+
let settled: boolean = false;
|
|
194
|
+
const finish = (result: { ok: boolean; url: string; error: string | null; result?: unknown }): void => {
|
|
195
|
+
if (settled) {
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
settled = true;
|
|
199
|
+
clearTimeout(timer);
|
|
200
|
+
socket.destroy();
|
|
201
|
+
resolveHealth(result);
|
|
202
|
+
};
|
|
203
|
+
const timer = setTimeout((): void => {
|
|
204
|
+
finish({ ok: false, url, error: "Timed out waiting for backend health." });
|
|
205
|
+
}, 2500);
|
|
206
|
+
socket.on("connect", (): void => {
|
|
207
|
+
socket.write([
|
|
208
|
+
`GET ${path === "" ? "/" : path} HTTP/1.1`,
|
|
209
|
+
`Host: ${host}:${port}`,
|
|
210
|
+
"Upgrade: websocket",
|
|
211
|
+
"Connection: Upgrade",
|
|
212
|
+
`Sec-WebSocket-Key: ${key}`,
|
|
213
|
+
"Sec-WebSocket-Version: 13",
|
|
214
|
+
"",
|
|
215
|
+
""
|
|
216
|
+
].join("\r\n"));
|
|
217
|
+
});
|
|
218
|
+
socket.on("data", (chunk: Buffer): void => {
|
|
219
|
+
buffer = Buffer.concat([buffer, chunk]);
|
|
220
|
+
if (!handshakeDone) {
|
|
221
|
+
const headerEnd: number = buffer.indexOf("\r\n\r\n");
|
|
222
|
+
if (headerEnd < 0) {
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
const headerText: string = buffer.subarray(0, headerEnd).toString("utf8");
|
|
226
|
+
if (!headerText.startsWith("HTTP/1.1 101")) {
|
|
227
|
+
finish({ ok: false, url, error: `Unexpected WebSocket handshake: ${headerText.split("\r\n")[0] ?? headerText}` });
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
handshakeDone = true;
|
|
231
|
+
buffer = buffer.subarray(headerEnd + 4);
|
|
232
|
+
socket.write(createClientTextFrame(JSON.stringify({ id: "manager-health", method: "backend.health", params: {} })));
|
|
233
|
+
}
|
|
234
|
+
const frame = tryReadServerTextFrame(buffer);
|
|
235
|
+
if (frame === null) {
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
const parsed: unknown = JSON.parse(frame.text);
|
|
239
|
+
finish({ ok: true, url, error: null, result: parsed });
|
|
240
|
+
});
|
|
241
|
+
socket.on("error", (error: Error): void => {
|
|
242
|
+
finish({ ok: false, url, error: error.message });
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
} catch (error: unknown) {
|
|
246
|
+
return { ok: false, url, error: error instanceof Error ? error.message : String(error) };
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function createClientTextFrame(text: string): Buffer {
|
|
251
|
+
const payload: Buffer = Buffer.from(text, "utf8");
|
|
252
|
+
const mask: Buffer = randomBytes(4);
|
|
253
|
+
const headerLength: number = payload.length < 126 ? 6 : 8;
|
|
254
|
+
const frame: Buffer = Buffer.alloc(headerLength + payload.length);
|
|
255
|
+
frame[0] = 0x81;
|
|
256
|
+
if (payload.length < 126) {
|
|
257
|
+
frame[1] = 0x80 | payload.length;
|
|
258
|
+
mask.copy(frame, 2);
|
|
259
|
+
for (let index: number = 0; index < payload.length; index += 1) {
|
|
260
|
+
frame[6 + index] = payload[index]! ^ mask[index % 4]!;
|
|
261
|
+
}
|
|
262
|
+
return frame;
|
|
263
|
+
}
|
|
264
|
+
frame[1] = 0x80 | 126;
|
|
265
|
+
frame.writeUInt16BE(payload.length, 2);
|
|
266
|
+
mask.copy(frame, 4);
|
|
267
|
+
for (let index: number = 0; index < payload.length; index += 1) {
|
|
268
|
+
frame[8 + index] = payload[index]! ^ mask[index % 4]!;
|
|
269
|
+
}
|
|
270
|
+
return frame;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function tryReadServerTextFrame(buffer: Buffer): { text: string; bytesRead: number } | null {
|
|
274
|
+
if (buffer.length < 2) {
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
const opcode: number = buffer[0]! & 0x0f;
|
|
278
|
+
let payloadLength: number = buffer[1]! & 0x7f;
|
|
279
|
+
let offset: number = 2;
|
|
280
|
+
if (payloadLength === 126) {
|
|
281
|
+
if (buffer.length < 4) {
|
|
282
|
+
return null;
|
|
283
|
+
}
|
|
284
|
+
payloadLength = buffer.readUInt16BE(2);
|
|
285
|
+
offset = 4;
|
|
286
|
+
} else if (payloadLength === 127) {
|
|
287
|
+
return null;
|
|
288
|
+
}
|
|
289
|
+
if (buffer.length < offset + payloadLength) {
|
|
290
|
+
return null;
|
|
291
|
+
}
|
|
292
|
+
if (opcode !== 1) {
|
|
293
|
+
return null;
|
|
294
|
+
}
|
|
295
|
+
return {
|
|
296
|
+
text: buffer.subarray(offset, offset + payloadLength).toString("utf8"),
|
|
297
|
+
bytesRead: offset + payloadLength
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
export async function getInstalledBackendVersion(): Promise<string | null> {
|
|
302
|
+
const current: BackendCurrentFile | null = await getCurrentBackend();
|
|
303
|
+
return current?.version ?? null;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export async function getRunningBackend(): Promise<BackendPidFile | null> {
|
|
307
|
+
const paths: ManagerPaths = getManagerPaths();
|
|
308
|
+
const pidFile: BackendPidFile | null = await readJsonFile<BackendPidFile>(paths.backendPidPath);
|
|
309
|
+
if (pidFile === null || !isProcessAlive(pidFile.pid)) {
|
|
310
|
+
return null;
|
|
311
|
+
}
|
|
312
|
+
return pidFile;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
async function requireLatestBackendVersion(): Promise<string> {
|
|
316
|
+
const latest: string | null = await getLatestBackendVersion();
|
|
317
|
+
if (latest === null) {
|
|
318
|
+
throw new ManagerError({
|
|
319
|
+
code: "network_error",
|
|
320
|
+
message: "Could not read latest backend version from npm.",
|
|
321
|
+
suggestedAction: "Check npm network access, then try again."
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
return latest;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
async function readInstalledBackendVersion(versionDir: string): Promise<string> {
|
|
328
|
+
const manifestText: string = await readFile(join(versionDir, "node_modules", BACKEND_PACKAGE_NAME, "package.json"), "utf8");
|
|
329
|
+
const manifest = JSON.parse(manifestText) as { version?: unknown };
|
|
330
|
+
if (typeof manifest.version !== "string" || manifest.version.trim() === "") {
|
|
331
|
+
throw new ManagerError({ code: "install_failed", message: "Installed backend package has no version." });
|
|
332
|
+
}
|
|
333
|
+
return manifest.version;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
async function pruneBackendVersions(currentVersion: string, previousVersion: string | undefined): Promise<void> {
|
|
337
|
+
const paths: ManagerPaths = getManagerPaths();
|
|
338
|
+
const entries = await readdir(paths.backendVersionsDir, { withFileTypes: true }).catch(() => []);
|
|
339
|
+
const keep: Set<string> = new Set([currentVersion, ...(previousVersion === undefined ? [] : [previousVersion])]);
|
|
340
|
+
const versions = entries
|
|
341
|
+
.filter((entry) => entry.isDirectory() && !entry.name.endsWith(".staging"))
|
|
342
|
+
.map((entry) => entry.name)
|
|
343
|
+
.sort()
|
|
344
|
+
.reverse();
|
|
345
|
+
for (const version of versions) {
|
|
346
|
+
if (keep.has(version)) {
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
349
|
+
if (keep.size < MAX_BACKEND_VERSIONS) {
|
|
350
|
+
keep.add(version);
|
|
351
|
+
continue;
|
|
352
|
+
}
|
|
353
|
+
await rm(assertInside(paths.backendVersionsDir, join(paths.backendVersionsDir, version)), { recursive: true, force: true });
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
export function sha256Text(value: string): string {
|
|
358
|
+
return createHash("sha256").update(value).digest("hex");
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function getNpmCommand(): string {
|
|
362
|
+
return process.platform === "win32" ? "npm.cmd" : "npm";
|
|
363
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { DEFAULT_BACKEND_PORT, type ManagerResult } from "./types.js";
|
|
2
|
+
import { toManagerFailure, ManagerError } from "./manager-error.js";
|
|
3
|
+
import { installBackend, rollbackBackend, startBackend, stopBackend, healthBackend, getLatestBackendVersion } from "./backend.js";
|
|
4
|
+
import { applyFrontendUpdate, downloadAndStageFrontend, getLatestFrontendVersion, rollbackFrontend } from "./frontend.js";
|
|
5
|
+
import { readStatus } from "./status.js";
|
|
6
|
+
|
|
7
|
+
type ParsedArgs = {
|
|
8
|
+
json: boolean;
|
|
9
|
+
command: string[];
|
|
10
|
+
options: Map<string, string | true>;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
async function main(): Promise<void> {
|
|
14
|
+
const args: ParsedArgs = parseArgs(process.argv.slice(2));
|
|
15
|
+
try {
|
|
16
|
+
const result: ManagerResult = await handleCommand(args);
|
|
17
|
+
writeResult(result, args.json);
|
|
18
|
+
process.exitCode = result.ok ? 0 : 1;
|
|
19
|
+
} catch (error: unknown) {
|
|
20
|
+
const failure = toManagerFailure(error);
|
|
21
|
+
writeResult(failure, args.json);
|
|
22
|
+
process.exitCode = 1;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function handleCommand(args: ParsedArgs): Promise<ManagerResult> {
|
|
27
|
+
const [first, second] = args.command;
|
|
28
|
+
if (first === undefined || first === "help" || first === "--help") {
|
|
29
|
+
return { ok: true, help: getHelpText() };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (first === "status") {
|
|
33
|
+
return { ok: true, status: await readStatus(getOptionalStringOption(args, "project")) };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (first === "doctor") {
|
|
37
|
+
return {
|
|
38
|
+
ok: true,
|
|
39
|
+
status: await readStatus(getOptionalStringOption(args, "project")),
|
|
40
|
+
node: process.version,
|
|
41
|
+
platform: process.platform
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (first === "backend") {
|
|
46
|
+
if (second === "install" || second === "update") {
|
|
47
|
+
return { ok: true, backend: await installBackend(getStringOption(args, "version") ?? "latest") };
|
|
48
|
+
}
|
|
49
|
+
if (second === "start") {
|
|
50
|
+
return { ok: true, backend: await startBackend(getNumberOption(args, "port") ?? DEFAULT_BACKEND_PORT) };
|
|
51
|
+
}
|
|
52
|
+
if (second === "stop") {
|
|
53
|
+
return { ok: true, backend: await stopBackend() };
|
|
54
|
+
}
|
|
55
|
+
if (second === "health") {
|
|
56
|
+
return { ok: true, health: await healthBackend(getStringOption(args, "url") ?? `ws://localhost:${DEFAULT_BACKEND_PORT}`) };
|
|
57
|
+
}
|
|
58
|
+
if (second === "rollback") {
|
|
59
|
+
return { ok: true, backend: await rollbackBackend() };
|
|
60
|
+
}
|
|
61
|
+
if (second === "latest") {
|
|
62
|
+
return { ok: true, version: await getLatestBackendVersion() };
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (first === "frontend") {
|
|
67
|
+
if (second === "check") {
|
|
68
|
+
return { ok: true, status: (await readStatus(getOptionalStringOption(args, "project"))).frontend };
|
|
69
|
+
}
|
|
70
|
+
if (second === "download" || second === "stage") {
|
|
71
|
+
const version: string | null = getStringOption(args, "version") ?? await getLatestFrontendVersion();
|
|
72
|
+
if (version === null) {
|
|
73
|
+
throw new ManagerError({ code: "network_error", message: "Could not resolve latest frontend version." });
|
|
74
|
+
}
|
|
75
|
+
return { ok: true, frontend: await downloadAndStageFrontend(version) };
|
|
76
|
+
}
|
|
77
|
+
if (second === "apply") {
|
|
78
|
+
return { ok: true, frontend: await applyFrontendUpdate(getRequiredStringOption(args, "project")) };
|
|
79
|
+
}
|
|
80
|
+
if (second === "rollback") {
|
|
81
|
+
return { ok: true, frontend: await rollbackFrontend(getRequiredStringOption(args, "project")) };
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
throw new ManagerError({
|
|
86
|
+
code: "invalid_arguments",
|
|
87
|
+
message: `Unknown manager command: ${args.command.join(" ")}`,
|
|
88
|
+
details: getHelpText()
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function parseArgs(argv: string[]): ParsedArgs {
|
|
93
|
+
const command: string[] = [];
|
|
94
|
+
const options: Map<string, string | true> = new Map();
|
|
95
|
+
let json: boolean = false;
|
|
96
|
+
for (let index: number = 0; index < argv.length; index += 1) {
|
|
97
|
+
const arg: string = argv[index]!;
|
|
98
|
+
if (arg === "--json") {
|
|
99
|
+
json = true;
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
if (arg.startsWith("--")) {
|
|
103
|
+
const name: string = arg.slice(2);
|
|
104
|
+
const next: string | undefined = argv[index + 1];
|
|
105
|
+
if (next !== undefined && !next.startsWith("--")) {
|
|
106
|
+
options.set(name, next);
|
|
107
|
+
index += 1;
|
|
108
|
+
} else {
|
|
109
|
+
options.set(name, true);
|
|
110
|
+
}
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
command.push(arg);
|
|
114
|
+
}
|
|
115
|
+
return { json, command, options };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function getStringOption(args: ParsedArgs, name: string): string | null {
|
|
119
|
+
const value: string | true | undefined = args.options.get(name);
|
|
120
|
+
return typeof value === "string" && value.trim() !== "" ? value : null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function getOptionalStringOption(args: ParsedArgs, name: string): string | undefined {
|
|
124
|
+
return getStringOption(args, name) ?? undefined;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function getRequiredStringOption(args: ParsedArgs, name: string): string {
|
|
128
|
+
const value: string | null = getStringOption(args, name);
|
|
129
|
+
if (value === null) {
|
|
130
|
+
throw new ManagerError({ code: "invalid_arguments", message: `Missing required option --${name}` });
|
|
131
|
+
}
|
|
132
|
+
return value;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function getNumberOption(args: ParsedArgs, name: string): number | null {
|
|
136
|
+
const value: string | null = getStringOption(args, name);
|
|
137
|
+
if (value === null) {
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
const parsed: number = Number.parseInt(value, 10);
|
|
141
|
+
if (!Number.isInteger(parsed) || parsed <= 0 || parsed > 65535) {
|
|
142
|
+
throw new ManagerError({ code: "invalid_arguments", message: `Invalid --${name}: ${value}` });
|
|
143
|
+
}
|
|
144
|
+
return parsed;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function writeResult(result: ManagerResult, json: boolean): void {
|
|
148
|
+
if (json) {
|
|
149
|
+
console.log(JSON.stringify(result, null, 2));
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (result.ok) {
|
|
154
|
+
console.log(JSON.stringify(result, null, 2));
|
|
155
|
+
} else {
|
|
156
|
+
console.error(result.message);
|
|
157
|
+
if (result.details !== undefined) {
|
|
158
|
+
console.error(result.details);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function getHelpText(): string {
|
|
164
|
+
return [
|
|
165
|
+
"godot-daedalus-manager [--json] <command>",
|
|
166
|
+
"",
|
|
167
|
+
"Commands:",
|
|
168
|
+
" status [--project <path>]",
|
|
169
|
+
" doctor [--project <path>]",
|
|
170
|
+
" backend install|update [--version <version>]",
|
|
171
|
+
" backend start [--port <port>]",
|
|
172
|
+
" backend stop",
|
|
173
|
+
" backend health [--url <ws://...>]",
|
|
174
|
+
" backend rollback",
|
|
175
|
+
" frontend check [--project <path>]",
|
|
176
|
+
" frontend download|stage [--version <version>]",
|
|
177
|
+
" frontend apply --project <path>",
|
|
178
|
+
" frontend rollback --project <path>"
|
|
179
|
+
].join("\n");
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
await main();
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { createWriteStream } from "node:fs";
|
|
3
|
+
import { copyFile, mkdir, readdir, readFile, rename, rm, stat } from "node:fs/promises";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { pipeline } from "node:stream/promises";
|
|
6
|
+
import { FRONTEND_ADDON_DIR_NAME, FRONTEND_REPOSITORY, type FrontendManifest, type PendingFrontendUpdate } from "./types.js";
|
|
7
|
+
import { ManagerError } from "./manager-error.js";
|
|
8
|
+
import { assertInside, getManagerPaths, resolveProjectPluginDir, type ManagerPaths } from "./paths.js";
|
|
9
|
+
import { readJsonFile, writeJsonFile } from "./json-file.js";
|
|
10
|
+
import { runCommand } from "./process.js";
|
|
11
|
+
|
|
12
|
+
export async function getInstalledFrontendVersion(projectPath: string | undefined): Promise<string | null> {
|
|
13
|
+
const pluginDir: string | null = resolveProjectPluginDir(projectPath);
|
|
14
|
+
if (pluginDir === null) {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const pluginCfgPath: string = join(pluginDir, "plugin.cfg");
|
|
19
|
+
const text: string = await readFile(pluginCfgPath, "utf8").catch((): string => "");
|
|
20
|
+
const match: RegExpMatchArray | null = text.match(/version="([^"]+)"/);
|
|
21
|
+
return match?.[1] ?? null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function getLatestFrontendVersion(): Promise<string | null> {
|
|
25
|
+
const response: Response = await fetch(`https://api.github.com/repos/${FRONTEND_REPOSITORY}/releases/latest`, {
|
|
26
|
+
headers: { "User-Agent": "godot-daedalus-manager" }
|
|
27
|
+
});
|
|
28
|
+
if (!response.ok) {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
const data = await response.json() as { tag_name?: unknown };
|
|
32
|
+
if (typeof data.tag_name !== "string") {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
return data.tag_name.replace(/^v/, "");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function getPendingFrontendVersion(): Promise<string | null> {
|
|
39
|
+
const paths: ManagerPaths = getManagerPaths();
|
|
40
|
+
const pending: PendingFrontendUpdate | null = await readJsonFile<PendingFrontendUpdate>(paths.pendingFrontendUpdatePath);
|
|
41
|
+
return pending?.version ?? null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function downloadAndStageFrontend(version: string): Promise<PendingFrontendUpdate> {
|
|
45
|
+
const manifest: FrontendManifest = await downloadFrontendManifest(version);
|
|
46
|
+
validateFrontendManifest(manifest);
|
|
47
|
+
const paths: ManagerPaths = getManagerPaths();
|
|
48
|
+
await mkdir(paths.frontendDownloadsDir, { recursive: true });
|
|
49
|
+
await mkdir(paths.frontendStagedDir, { recursive: true });
|
|
50
|
+
const zipPath: string = assertInside(paths.frontendDownloadsDir, join(paths.frontendDownloadsDir, manifest.assetName));
|
|
51
|
+
const stagedDir: string = assertInside(paths.frontendStagedDir, join(paths.frontendStagedDir, manifest.version));
|
|
52
|
+
|
|
53
|
+
await downloadFile(getReleaseAssetUrl(manifest.tag, manifest.assetName), zipPath);
|
|
54
|
+
const hash: string = await sha256File(zipPath);
|
|
55
|
+
if (hash.toLowerCase() !== manifest.sha256.toLowerCase()) {
|
|
56
|
+
throw new ManagerError({
|
|
57
|
+
code: "hash_mismatch",
|
|
58
|
+
message: "Downloaded frontend package hash does not match manifest.",
|
|
59
|
+
details: `Expected ${manifest.sha256}, got ${hash}`
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
await rm(stagedDir, { recursive: true, force: true });
|
|
64
|
+
await mkdir(stagedDir, { recursive: true });
|
|
65
|
+
await extractZip(zipPath, stagedDir);
|
|
66
|
+
const stagedPluginCfg: string = join(stagedDir, "addons", FRONTEND_ADDON_DIR_NAME, "plugin.cfg");
|
|
67
|
+
const stagedStats = await stat(stagedPluginCfg).catch((): null => null);
|
|
68
|
+
if (stagedStats === null || !stagedStats.isFile()) {
|
|
69
|
+
throw new ManagerError({
|
|
70
|
+
code: "manifest_invalid",
|
|
71
|
+
message: "Frontend package does not contain addons/godot_daedalus/plugin.cfg."
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const pending: PendingFrontendUpdate = {
|
|
76
|
+
version: manifest.version,
|
|
77
|
+
sourceZipPath: zipPath,
|
|
78
|
+
stagedDir,
|
|
79
|
+
manifest,
|
|
80
|
+
createdAt: new Date().toISOString()
|
|
81
|
+
};
|
|
82
|
+
await writeJsonFile(paths.pendingFrontendUpdatePath, pending);
|
|
83
|
+
return pending;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function applyFrontendUpdate(projectPath: string | undefined): Promise<{ applied: boolean; version: string | null; backupDir?: string }> {
|
|
87
|
+
const pluginDir: string | null = resolveProjectPluginDir(projectPath);
|
|
88
|
+
if (pluginDir === null) {
|
|
89
|
+
throw new ManagerError({
|
|
90
|
+
code: "invalid_arguments",
|
|
91
|
+
message: "frontend apply requires --project <Godot project root>."
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const paths: ManagerPaths = getManagerPaths();
|
|
96
|
+
const pending: PendingFrontendUpdate | null = await readJsonFile<PendingFrontendUpdate>(paths.pendingFrontendUpdatePath);
|
|
97
|
+
if (pending === null) {
|
|
98
|
+
return { applied: false, version: null };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const stagedPluginDir: string = join(pending.stagedDir, "addons", FRONTEND_ADDON_DIR_NAME);
|
|
102
|
+
const stagedStats = await stat(stagedPluginDir).catch((): null => null);
|
|
103
|
+
if (stagedStats === null || !stagedStats.isDirectory()) {
|
|
104
|
+
throw new ManagerError({
|
|
105
|
+
code: "frontend_update_missing",
|
|
106
|
+
message: "Pending frontend update staged directory is missing."
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const backupDir: string = `${pluginDir}.backup-${Date.now()}`;
|
|
111
|
+
await rm(backupDir, { recursive: true, force: true });
|
|
112
|
+
await rename(pluginDir, backupDir).catch(async (): Promise<void> => {
|
|
113
|
+
await rm(backupDir, { recursive: true, force: true });
|
|
114
|
+
});
|
|
115
|
+
try {
|
|
116
|
+
await copyDirectory(stagedPluginDir, pluginDir);
|
|
117
|
+
await rm(paths.pendingFrontendUpdatePath, { force: true });
|
|
118
|
+
return { applied: true, version: pending.version, backupDir };
|
|
119
|
+
} catch (error: unknown) {
|
|
120
|
+
await rm(pluginDir, { recursive: true, force: true });
|
|
121
|
+
await rename(backupDir, pluginDir).catch((): void => undefined);
|
|
122
|
+
throw error;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export async function rollbackFrontend(projectPath: string | undefined): Promise<{ rolledBack: boolean }> {
|
|
127
|
+
const pluginDir: string | null = resolveProjectPluginDir(projectPath);
|
|
128
|
+
if (pluginDir === null) {
|
|
129
|
+
throw new ManagerError({ code: "invalid_arguments", message: "frontend rollback requires --project <Godot project root>." });
|
|
130
|
+
}
|
|
131
|
+
const parent: string = dirname(pluginDir);
|
|
132
|
+
const entries = await readdir(parent, { withFileTypes: true });
|
|
133
|
+
const backup = entries
|
|
134
|
+
.filter((entry) => entry.isDirectory() && entry.name.startsWith(`${FRONTEND_ADDON_DIR_NAME}.backup-`))
|
|
135
|
+
.map((entry) => entry.name)
|
|
136
|
+
.sort()
|
|
137
|
+
.reverse()[0];
|
|
138
|
+
if (backup === undefined) {
|
|
139
|
+
return { rolledBack: false };
|
|
140
|
+
}
|
|
141
|
+
await rm(pluginDir, { recursive: true, force: true });
|
|
142
|
+
await rename(join(parent, backup), pluginDir);
|
|
143
|
+
return { rolledBack: true };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function validateFrontendManifest(manifest: FrontendManifest): void {
|
|
147
|
+
if (!manifest.version.match(/^\d+\.\d+\.\d+$/)) {
|
|
148
|
+
throw new ManagerError({ code: "manifest_invalid", message: "Frontend manifest version must be X.Y.Z." });
|
|
149
|
+
}
|
|
150
|
+
if (manifest.tag !== `v${manifest.version}`) {
|
|
151
|
+
throw new ManagerError({ code: "manifest_invalid", message: "Frontend manifest tag must match version." });
|
|
152
|
+
}
|
|
153
|
+
if (!manifest.assetName.endsWith(".zip")) {
|
|
154
|
+
throw new ManagerError({ code: "manifest_invalid", message: "Frontend manifest assetName must be a zip file." });
|
|
155
|
+
}
|
|
156
|
+
if (!manifest.sha256.match(/^[a-fA-F0-9]{64}$/)) {
|
|
157
|
+
throw new ManagerError({ code: "manifest_invalid", message: "Frontend manifest sha256 is invalid." });
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function downloadFrontendManifest(version: string): Promise<FrontendManifest> {
|
|
162
|
+
const tag: string = version.startsWith("v") ? version : `v${version}`;
|
|
163
|
+
const assetName: string = `godot-daedalus-plugin-${tag}.manifest.json`;
|
|
164
|
+
const response: Response = await fetch(getReleaseAssetUrl(tag, assetName), {
|
|
165
|
+
headers: { "User-Agent": "godot-daedalus-manager" }
|
|
166
|
+
});
|
|
167
|
+
if (!response.ok) {
|
|
168
|
+
throw new ManagerError({
|
|
169
|
+
code: "network_error",
|
|
170
|
+
message: `Could not download frontend manifest ${assetName}.`,
|
|
171
|
+
details: `${response.status} ${response.statusText}`
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
return await response.json() as FrontendManifest;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function getReleaseAssetUrl(tag: string, assetName: string): string {
|
|
178
|
+
return `https://github.com/${FRONTEND_REPOSITORY}/releases/download/${encodeURIComponent(tag)}/${encodeURIComponent(assetName)}`;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function downloadFile(url: string, destination: string): Promise<void> {
|
|
182
|
+
const response: Response = await fetch(url, { headers: { "User-Agent": "godot-daedalus-manager" } });
|
|
183
|
+
if (!response.ok || response.body === null) {
|
|
184
|
+
throw new ManagerError({
|
|
185
|
+
code: "network_error",
|
|
186
|
+
message: `Could not download ${url}`,
|
|
187
|
+
details: `${response.status} ${response.statusText}`
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
await mkdir(dirname(destination), { recursive: true });
|
|
191
|
+
await pipeline(response.body, createWriteStream(destination));
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async function sha256File(filePath: string): Promise<string> {
|
|
195
|
+
const hash = createHash("sha256");
|
|
196
|
+
hash.update(await readFile(filePath));
|
|
197
|
+
return hash.digest("hex");
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async function extractZip(zipPath: string, destination: string): Promise<void> {
|
|
201
|
+
if (process.platform === "win32") {
|
|
202
|
+
const result = await runCommand("powershell.exe", [
|
|
203
|
+
"-NoProfile",
|
|
204
|
+
"-ExecutionPolicy",
|
|
205
|
+
"Bypass",
|
|
206
|
+
"-Command",
|
|
207
|
+
`Expand-Archive -LiteralPath '${zipPath.replaceAll("'", "''")}' -DestinationPath '${destination.replaceAll("'", "''")}' -Force`
|
|
208
|
+
], { timeoutMs: 60000 });
|
|
209
|
+
if (result.exitCode !== 0) {
|
|
210
|
+
throw new ManagerError({ code: "process_failed", message: "Failed to extract frontend zip.", details: result.stderr || result.stdout });
|
|
211
|
+
}
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
const result = await runCommand("unzip", ["-q", zipPath, "-d", destination], { timeoutMs: 60000 });
|
|
215
|
+
if (result.exitCode !== 0) {
|
|
216
|
+
throw new ManagerError({ code: "process_failed", message: "Failed to extract frontend zip.", details: result.stderr || result.stdout });
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async function copyDirectory(source: string, destination: string): Promise<void> {
|
|
221
|
+
await mkdir(destination, { recursive: true });
|
|
222
|
+
const entries = await readdir(source, { withFileTypes: true });
|
|
223
|
+
for (const entry of entries) {
|
|
224
|
+
const sourcePath: string = join(source, entry.name);
|
|
225
|
+
const destinationPath: string = join(destination, entry.name);
|
|
226
|
+
if (entry.isDirectory()) {
|
|
227
|
+
await copyDirectory(sourcePath, destinationPath);
|
|
228
|
+
} else if (entry.isFile()) {
|
|
229
|
+
await mkdir(dirname(destinationPath), { recursive: true });
|
|
230
|
+
await copyFile(sourcePath, destinationPath);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
|
|
4
|
+
export async function readJsonFile<T>(filePath: string): Promise<T | null> {
|
|
5
|
+
try {
|
|
6
|
+
return JSON.parse(await readFile(filePath, "utf8")) as T;
|
|
7
|
+
} catch (error: unknown) {
|
|
8
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT") {
|
|
9
|
+
return null;
|
|
10
|
+
}
|
|
11
|
+
throw error;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function writeJsonFile(filePath: string, value: unknown): Promise<void> {
|
|
16
|
+
await mkdir(dirname(filePath), { recursive: true });
|
|
17
|
+
const tempPath: string = `${filePath}.${process.pid}.tmp`;
|
|
18
|
+
await writeFile(tempPath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
|
19
|
+
await rename(tempPath, filePath);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function removeIfExists(path: string): Promise<void> {
|
|
23
|
+
await rm(path, { recursive: true, force: true });
|
|
24
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { ManagerErrorCode, ManagerFailure } from "./types.js";
|
|
2
|
+
|
|
3
|
+
export class ManagerError extends Error {
|
|
4
|
+
public readonly code: ManagerErrorCode;
|
|
5
|
+
public readonly details: string | undefined;
|
|
6
|
+
public readonly logPath: string | undefined;
|
|
7
|
+
public readonly suggestedAction: string | undefined;
|
|
8
|
+
|
|
9
|
+
public constructor(params: {
|
|
10
|
+
code: ManagerErrorCode;
|
|
11
|
+
message: string;
|
|
12
|
+
details?: string;
|
|
13
|
+
logPath?: string;
|
|
14
|
+
suggestedAction?: string;
|
|
15
|
+
}) {
|
|
16
|
+
super(params.message);
|
|
17
|
+
this.name = "ManagerError";
|
|
18
|
+
this.code = params.code;
|
|
19
|
+
this.details = params.details;
|
|
20
|
+
this.logPath = params.logPath;
|
|
21
|
+
this.suggestedAction = params.suggestedAction;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
public toFailure(): ManagerFailure {
|
|
25
|
+
return {
|
|
26
|
+
ok: false,
|
|
27
|
+
code: this.code,
|
|
28
|
+
message: this.message,
|
|
29
|
+
...(this.details === undefined ? {} : { details: this.details }),
|
|
30
|
+
...(this.logPath === undefined ? {} : { logPath: this.logPath }),
|
|
31
|
+
...(this.suggestedAction === undefined ? {} : { suggestedAction: this.suggestedAction })
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function toManagerFailure(error: unknown): ManagerFailure {
|
|
37
|
+
if (error instanceof ManagerError) {
|
|
38
|
+
return error.toFailure();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
ok: false,
|
|
43
|
+
code: "unknown_error",
|
|
44
|
+
message: error instanceof Error ? error.message : String(error)
|
|
45
|
+
};
|
|
46
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { join, resolve, sep } from "node:path";
|
|
2
|
+
import { getAppDataDir } from "../app-paths.js";
|
|
3
|
+
import { FRONTEND_ADDON_DIR_NAME } from "./types.js";
|
|
4
|
+
import { ManagerError } from "./manager-error.js";
|
|
5
|
+
|
|
6
|
+
export type ManagerPaths = {
|
|
7
|
+
appDir: string;
|
|
8
|
+
backendDir: string;
|
|
9
|
+
backendVersionsDir: string;
|
|
10
|
+
backendCurrentPath: string;
|
|
11
|
+
backendRuntimeDir: string;
|
|
12
|
+
backendPidPath: string;
|
|
13
|
+
logsDir: string;
|
|
14
|
+
frontendDir: string;
|
|
15
|
+
frontendDownloadsDir: string;
|
|
16
|
+
frontendStagedDir: string;
|
|
17
|
+
pendingFrontendUpdatePath: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export function getManagerAppDir(): string {
|
|
21
|
+
const override: string | undefined = process.env.GODOT_DAEDALUS_APP_DIR;
|
|
22
|
+
if (override !== undefined && override.trim() !== "") {
|
|
23
|
+
return resolve(override);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return getAppDataDir();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function getManagerPaths(): ManagerPaths {
|
|
30
|
+
const appDir: string = getManagerAppDir();
|
|
31
|
+
const backendDir: string = join(appDir, "backend");
|
|
32
|
+
const frontendDir: string = join(appDir, "frontend");
|
|
33
|
+
return {
|
|
34
|
+
appDir,
|
|
35
|
+
backendDir,
|
|
36
|
+
backendVersionsDir: join(backendDir, "versions"),
|
|
37
|
+
backendCurrentPath: join(backendDir, "current.json"),
|
|
38
|
+
backendRuntimeDir: join(backendDir, "runtime"),
|
|
39
|
+
backendPidPath: join(backendDir, "runtime", "backend.pid.json"),
|
|
40
|
+
logsDir: join(appDir, "logs"),
|
|
41
|
+
frontendDir,
|
|
42
|
+
frontendDownloadsDir: join(frontendDir, "downloads"),
|
|
43
|
+
frontendStagedDir: join(frontendDir, "staged"),
|
|
44
|
+
pendingFrontendUpdatePath: join(frontendDir, "pending_frontend_update.json")
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function assertInside(parentDir: string, childPath: string): string {
|
|
49
|
+
const resolvedParent: string = resolve(parentDir);
|
|
50
|
+
const resolvedChild: string = resolve(childPath);
|
|
51
|
+
if (resolvedChild !== resolvedParent && !resolvedChild.startsWith(`${resolvedParent}${sep}`)) {
|
|
52
|
+
throw new ManagerError({
|
|
53
|
+
code: "invalid_path",
|
|
54
|
+
message: `Refusing to operate outside managed directory: ${resolvedChild}`,
|
|
55
|
+
details: `Allowed root: ${resolvedParent}`
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return resolvedChild;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function resolveProjectPluginDir(projectPath: string | undefined): string | null {
|
|
63
|
+
if (projectPath === undefined || projectPath.trim() === "") {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const projectRoot: string = resolve(projectPath);
|
|
68
|
+
return assertInside(projectRoot, join(projectRoot, "addons", FRONTEND_ADDON_DIR_NAME));
|
|
69
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
export type CommandResult = {
|
|
4
|
+
exitCode: number;
|
|
5
|
+
stdout: string;
|
|
6
|
+
stderr: string;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export function runCommand(command: string, args: readonly string[], options: {
|
|
10
|
+
cwd?: string;
|
|
11
|
+
env?: NodeJS.ProcessEnv;
|
|
12
|
+
timeoutMs?: number;
|
|
13
|
+
} = {}): Promise<CommandResult> {
|
|
14
|
+
return new Promise<CommandResult>((resolve): void => {
|
|
15
|
+
const invocation = buildInvocation(command, args);
|
|
16
|
+
const child = spawn(invocation.command, invocation.args, {
|
|
17
|
+
cwd: options.cwd,
|
|
18
|
+
env: options.env ?? process.env,
|
|
19
|
+
windowsHide: true,
|
|
20
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
21
|
+
});
|
|
22
|
+
let stdout: string = "";
|
|
23
|
+
let stderr: string = "";
|
|
24
|
+
let settled: boolean = false;
|
|
25
|
+
const timeout = options.timeoutMs === undefined
|
|
26
|
+
? null
|
|
27
|
+
: setTimeout((): void => {
|
|
28
|
+
if (settled) {
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
child.kill("SIGTERM");
|
|
32
|
+
}, options.timeoutMs);
|
|
33
|
+
|
|
34
|
+
child.stdout.setEncoding("utf8");
|
|
35
|
+
child.stderr.setEncoding("utf8");
|
|
36
|
+
child.stdout.on("data", (chunk: string): void => {
|
|
37
|
+
stdout += chunk;
|
|
38
|
+
});
|
|
39
|
+
child.stderr.on("data", (chunk: string): void => {
|
|
40
|
+
stderr += chunk;
|
|
41
|
+
});
|
|
42
|
+
child.on("error", (error: Error): void => {
|
|
43
|
+
if (settled) {
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
settled = true;
|
|
47
|
+
if (timeout !== null) {
|
|
48
|
+
clearTimeout(timeout);
|
|
49
|
+
}
|
|
50
|
+
resolve({ exitCode: 1, stdout, stderr: stderr + error.message });
|
|
51
|
+
});
|
|
52
|
+
child.on("exit", (code: number | null): void => {
|
|
53
|
+
if (settled) {
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
settled = true;
|
|
57
|
+
if (timeout !== null) {
|
|
58
|
+
clearTimeout(timeout);
|
|
59
|
+
}
|
|
60
|
+
resolve({ exitCode: code ?? 1, stdout, stderr });
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function buildInvocation(command: string, args: readonly string[]): { command: string; args: string[] } {
|
|
66
|
+
if (process.platform !== "win32" || (!command.endsWith(".cmd") && !command.endsWith(".bat"))) {
|
|
67
|
+
return { command, args: [...args] };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const comspec: string = process.env.COMSPEC ?? "cmd.exe";
|
|
71
|
+
const commandLine: string = [command, ...args].map(quoteWindowsCommandPart).join(" ");
|
|
72
|
+
return { command: comspec, args: ["/d", "/s", "/c", commandLine] };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function quoteWindowsCommandPart(value: string): string {
|
|
76
|
+
if (!/[ \t&()^"]/u.test(value)) {
|
|
77
|
+
return value;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return `"${value.replaceAll("\"", "\\\"")}"`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function isProcessAlive(pid: number): boolean {
|
|
84
|
+
try {
|
|
85
|
+
process.kill(pid, 0);
|
|
86
|
+
return true;
|
|
87
|
+
} catch {
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export async function stopProcess(pid: number): Promise<CommandResult> {
|
|
93
|
+
if (process.platform === "win32") {
|
|
94
|
+
return runCommand("taskkill", ["/PID", String(pid), "/T", "/F"], { timeoutMs: 10000 });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return runCommand("kill", ["-TERM", String(pid)], { timeoutMs: 10000 });
|
|
98
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export function parseSemver(value: string): [number, number, number] | null {
|
|
2
|
+
const match: RegExpMatchArray | null = value.trim().match(/^v?(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/);
|
|
3
|
+
if (match === null) {
|
|
4
|
+
return null;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
return [Number.parseInt(match[1]!, 10), Number.parseInt(match[2]!, 10), Number.parseInt(match[3]!, 10)];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function isVersionNewer(candidate: string, current: string): boolean {
|
|
11
|
+
const left: [number, number, number] | null = parseSemver(candidate);
|
|
12
|
+
const right: [number, number, number] | null = parseSemver(current);
|
|
13
|
+
if (left === null || right === null) {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
for (let index: number = 0; index < 3; index += 1) {
|
|
18
|
+
const leftValue: number = left[index]!;
|
|
19
|
+
const rightValue: number = right[index]!;
|
|
20
|
+
if (leftValue > rightValue) {
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
23
|
+
if (leftValue < rightValue) {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { DEFAULT_BACKEND_PORT, type BackendPidFile, type ManagerStatus } from "./types.js";
|
|
2
|
+
import { getInstalledBackendVersion, getLatestBackendVersion, getRunningBackend, healthBackend } from "./backend.js";
|
|
3
|
+
import { getInstalledFrontendVersion, getLatestFrontendVersion, getPendingFrontendVersion } from "./frontend.js";
|
|
4
|
+
|
|
5
|
+
export async function readStatus(projectPath: string | undefined): Promise<ManagerStatus> {
|
|
6
|
+
const running: BackendPidFile | null = await getRunningBackend();
|
|
7
|
+
const url: string = running?.url ?? `ws://localhost:${DEFAULT_BACKEND_PORT}`;
|
|
8
|
+
const health = await healthBackend(url);
|
|
9
|
+
return {
|
|
10
|
+
frontend: {
|
|
11
|
+
installedVersion: await getInstalledFrontendVersion(projectPath),
|
|
12
|
+
latestVersion: await getLatestFrontendVersion(),
|
|
13
|
+
pendingVersion: await getPendingFrontendVersion()
|
|
14
|
+
},
|
|
15
|
+
backend: {
|
|
16
|
+
installedVersion: await getInstalledBackendVersion(),
|
|
17
|
+
latestVersion: await getLatestBackendVersion(),
|
|
18
|
+
runningVersion: running?.version ?? null,
|
|
19
|
+
pid: running?.pid ?? null
|
|
20
|
+
},
|
|
21
|
+
health: {
|
|
22
|
+
ok: health.ok,
|
|
23
|
+
url,
|
|
24
|
+
error: health.error
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
export const DEFAULT_BACKEND_PORT: number = 38180;
|
|
2
|
+
export const BACKEND_PACKAGE_NAME: string = "godot-daedalus_backend";
|
|
3
|
+
export const BACKEND_BIN_NAME: string = "godot-daedalus-backend";
|
|
4
|
+
export const FRONTEND_REPOSITORY: string = "LuYingYiLong/godot-daedalus";
|
|
5
|
+
export const FRONTEND_ADDON_DIR_NAME: string = "godot_daedalus";
|
|
6
|
+
|
|
7
|
+
export type JsonObject = Record<string, unknown>;
|
|
8
|
+
|
|
9
|
+
export type ManagerErrorCode =
|
|
10
|
+
| "invalid_arguments"
|
|
11
|
+
| "invalid_path"
|
|
12
|
+
| "not_installed"
|
|
13
|
+
| "network_error"
|
|
14
|
+
| "install_failed"
|
|
15
|
+
| "health_failed"
|
|
16
|
+
| "process_failed"
|
|
17
|
+
| "manifest_invalid"
|
|
18
|
+
| "hash_mismatch"
|
|
19
|
+
| "frontend_update_missing"
|
|
20
|
+
| "unknown_error";
|
|
21
|
+
|
|
22
|
+
export type ManagerFailure = {
|
|
23
|
+
ok: false;
|
|
24
|
+
code: ManagerErrorCode;
|
|
25
|
+
message: string;
|
|
26
|
+
details?: string;
|
|
27
|
+
logPath?: string;
|
|
28
|
+
suggestedAction?: string;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export type ManagerSuccess<T extends JsonObject = JsonObject> = {
|
|
32
|
+
ok: true;
|
|
33
|
+
} & T;
|
|
34
|
+
|
|
35
|
+
export type ManagerResult<T extends JsonObject = JsonObject> = ManagerSuccess<T> | ManagerFailure;
|
|
36
|
+
|
|
37
|
+
export type BackendCurrentFile = {
|
|
38
|
+
version: string;
|
|
39
|
+
path: string;
|
|
40
|
+
previousVersion?: string;
|
|
41
|
+
updatedAt: string;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export type BackendPidFile = {
|
|
45
|
+
pid: number;
|
|
46
|
+
version: string;
|
|
47
|
+
port: number;
|
|
48
|
+
url: string;
|
|
49
|
+
logPath: string;
|
|
50
|
+
startedAt: string;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export type FrontendManifest = {
|
|
54
|
+
version: string;
|
|
55
|
+
tag: string;
|
|
56
|
+
sha256: string;
|
|
57
|
+
assetName: string;
|
|
58
|
+
minGodotVersion?: string;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export type PendingFrontendUpdate = {
|
|
62
|
+
version: string;
|
|
63
|
+
sourceZipPath: string;
|
|
64
|
+
stagedDir: string;
|
|
65
|
+
manifest: FrontendManifest;
|
|
66
|
+
createdAt: string;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export type ManagerStatus = {
|
|
70
|
+
frontend: {
|
|
71
|
+
installedVersion: string | null;
|
|
72
|
+
latestVersion: string | null;
|
|
73
|
+
pendingVersion: string | null;
|
|
74
|
+
};
|
|
75
|
+
backend: {
|
|
76
|
+
installedVersion: string | null;
|
|
77
|
+
latestVersion: string | null;
|
|
78
|
+
runningVersion: string | null;
|
|
79
|
+
pid: number | null;
|
|
80
|
+
};
|
|
81
|
+
health: {
|
|
82
|
+
ok: boolean;
|
|
83
|
+
url: string;
|
|
84
|
+
error: string | null;
|
|
85
|
+
};
|
|
86
|
+
};
|
package/src/ping-client.ts
CHANGED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, resolve } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
|
|
5
|
+
const BACKEND_HEALTH_NAME: string = "godot-daedalus-backend";
|
|
6
|
+
const PACKAGE_ROOT: string = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
|
|
7
|
+
const PACKAGE_JSON_PATH: string = resolve(PACKAGE_ROOT, "package.json");
|
|
8
|
+
|
|
9
|
+
type PackageManifest = {
|
|
10
|
+
name?: unknown;
|
|
11
|
+
version?: unknown;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export type BackendHealthResult = {
|
|
15
|
+
name: string;
|
|
16
|
+
version: string;
|
|
17
|
+
pid: number;
|
|
18
|
+
mode: "development" | "runtime";
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
let cachedPackageVersion: string | null = null;
|
|
22
|
+
|
|
23
|
+
export function getBackendPackageVersion(): string {
|
|
24
|
+
if (cachedPackageVersion !== null) {
|
|
25
|
+
return cachedPackageVersion;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const envVersion: string | undefined = process.env.npm_package_version;
|
|
29
|
+
if (envVersion !== undefined && envVersion.trim() !== "") {
|
|
30
|
+
cachedPackageVersion = envVersion.trim();
|
|
31
|
+
return cachedPackageVersion;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
const manifestText: string = readFileSync(PACKAGE_JSON_PATH, "utf8");
|
|
36
|
+
const manifest: PackageManifest = JSON.parse(manifestText) as PackageManifest;
|
|
37
|
+
if (typeof manifest.version === "string" && manifest.version.trim() !== "") {
|
|
38
|
+
cachedPackageVersion = manifest.version.trim();
|
|
39
|
+
return cachedPackageVersion;
|
|
40
|
+
}
|
|
41
|
+
} catch {
|
|
42
|
+
// health 不能因为版本元数据不可读而阻断 WebSocket 启动。
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
cachedPackageVersion = "0.0.0";
|
|
46
|
+
return cachedPackageVersion;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function getBackendRuntimeMode(): "development" | "runtime" {
|
|
50
|
+
if (process.env.NODE_ENV === "development" || process.env.npm_lifecycle_event === "dev") {
|
|
51
|
+
return "development";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return "runtime";
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function createBackendHealthResult(): BackendHealthResult {
|
|
58
|
+
return {
|
|
59
|
+
name: BACKEND_HEALTH_NAME,
|
|
60
|
+
version: getBackendPackageVersion(),
|
|
61
|
+
pid: process.pid,
|
|
62
|
+
mode: getBackendRuntimeMode()
|
|
63
|
+
};
|
|
64
|
+
}
|
|
@@ -91,6 +91,7 @@ import {
|
|
|
91
91
|
serializePendingApprovalState,
|
|
92
92
|
type PendingApprovalState
|
|
93
93
|
} from "../session/approval-persistence.js";
|
|
94
|
+
import { createBackendHealthResult } from "./backend-health.js";
|
|
94
95
|
|
|
95
96
|
const tokenCounterPromise: Promise<TokenCounter> = createTokenCounter();
|
|
96
97
|
let sessionCompressorPromptCache: string | undefined;
|
|
@@ -2292,12 +2293,7 @@ async function handleRequest(socket: WebSocket, request: ClientRequest, session:
|
|
|
2292
2293
|
type: "response",
|
|
2293
2294
|
id: request.id,
|
|
2294
2295
|
ok: true,
|
|
2295
|
-
result:
|
|
2296
|
-
name: "godot-daedalus-backend",
|
|
2297
|
-
version: "1.0.1",
|
|
2298
|
-
pid: process.pid,
|
|
2299
|
-
mode: process.env.NODE_ENV === "development" || process.env.npm_lifecycle_event === "dev" ? "development" : "runtime"
|
|
2300
|
-
}
|
|
2296
|
+
result: createBackendHealthResult()
|
|
2301
2297
|
});
|
|
2302
2298
|
break;
|
|
2303
2299
|
|