gaoding-cli 1.0.0-alpha.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +38 -0
- package/contracts/operations/agent.send/input.schema.json +354 -0
- package/contracts/operations/agent.send/output.schema.json +271 -0
- package/contracts/operations/auth.status/input.schema.json +7 -0
- package/contracts/operations/auth.status/output.schema.json +132 -0
- package/contracts/operations/dam.delete/input.schema.json +23 -0
- package/contracts/operations/dam.delete/output.schema.json +6 -0
- package/contracts/operations/dam.get/input.schema.json +16 -0
- package/contracts/operations/dam.get/output.schema.json +46 -0
- package/contracts/operations/dam.list/input.schema.json +44 -0
- package/contracts/operations/dam.list/output.schema.json +71 -0
- package/contracts/operations/dam.search/input.schema.json +46 -0
- package/contracts/operations/dam.search/output.schema.json +71 -0
- package/contracts/operations/dam.upload/input.schema.json +31 -0
- package/contracts/operations/dam.upload/output.schema.json +46 -0
- package/contracts/operations/editor.apply/input.schema.json +134 -0
- package/contracts/operations/editor.apply/output.schema.json +15 -0
- package/contracts/operations/editor.connect/input.schema.json +15 -0
- package/contracts/operations/editor.connect/output.schema.json +17 -0
- package/contracts/operations/editor.disconnect/input.schema.json +8 -0
- package/contracts/operations/editor.disconnect/output.schema.json +14 -0
- package/contracts/operations/editor.save/input.schema.json +8 -0
- package/contracts/operations/editor.save/output.schema.json +23 -0
- package/contracts/operations/editor.screenshot/input.schema.json +8 -0
- package/contracts/operations/editor.screenshot/output.schema.json +16 -0
- package/contracts/operations/editor.snapshot/input.schema.json +8 -0
- package/contracts/operations/editor.snapshot/output.schema.json +113 -0
- package/contracts/operations/model.get/input.schema.json +18 -0
- package/contracts/operations/model.get/output.schema.json +190 -0
- package/contracts/operations/model.list/input.schema.json +15 -0
- package/contracts/operations/model.list/output.schema.json +111 -0
- package/contracts/operations/org.current/input.schema.json +7 -0
- package/contracts/operations/org.current/output.schema.json +49 -0
- package/contracts/operations/org.list/input.schema.json +7 -0
- package/contracts/operations/org.list/output.schema.json +71 -0
- package/contracts/operations/tool.call/input.schema.json +35 -0
- package/contracts/operations/tool.call/output.schema.json +129 -0
- package/contracts/operations/tool.list/input.schema.json +7 -0
- package/contracts/operations/tool.list/output.schema.json +45 -0
- package/dist/bin/gd-cli.js +38 -0
- package/dist/bin/postinstall.js +42 -0
- package/dist/src/bootstrap/create-cli.js +55 -0
- package/dist/src/bootstrap/create-runtime.js +222 -0
- package/dist/src/bootstrap/validators.js +118 -0
- package/dist/src/cli/action-binding.js +63 -0
- package/dist/src/cli/agent-commands.js +39 -0
- package/dist/src/cli/auth-commands.js +47 -0
- package/dist/src/cli/dam-commands.js +207 -0
- package/dist/src/cli/editor-commands.js +73 -0
- package/dist/src/cli/errors.js +90 -0
- package/dist/src/cli/model-commands.js +47 -0
- package/dist/src/cli/org-commands.js +52 -0
- package/dist/src/cli/presenter.js +80 -0
- package/dist/src/cli/prompt.js +30 -0
- package/dist/src/cli/tool-commands.js +65 -0
- package/dist/src/cli/update-command.js +12 -0
- package/dist/src/contracts/schema.js +4 -0
- package/dist/src/features/agent/creative-agent-adapter.js +31 -0
- package/dist/src/features/agent/creative-protocol.js +324 -0
- package/dist/src/features/agent/creative-stream.js +127 -0
- package/dist/src/features/agent/use-cases.js +113 -0
- package/dist/src/features/auth/access-policy.js +101 -0
- package/dist/src/features/auth/credential-store.js +62 -0
- package/dist/src/features/auth/sso-service.js +179 -0
- package/dist/src/features/auth/state.js +129 -0
- package/dist/src/features/auth/use-cases.js +106 -0
- package/dist/src/features/dam/asset-projection.js +270 -0
- package/dist/src/features/dam/dam-api-adapter.js +206 -0
- package/dist/src/features/dam/object-storage.js +191 -0
- package/dist/src/features/dam/registered-uploader.js +224 -0
- package/dist/src/features/dam/storage-upload.js +141 -0
- package/dist/src/features/dam/transient-uploader.js +17 -0
- package/dist/src/features/dam/use-cases.js +151 -0
- package/dist/src/features/editor/bridge-client.js +96 -0
- package/dist/src/features/editor/bridge-process.js +222 -0
- package/dist/src/features/editor/bridge-server.js +311 -0
- package/dist/src/features/editor/protocol.js +1 -0
- package/dist/src/features/editor/session-state.js +78 -0
- package/dist/src/features/editor/session.js +193 -0
- package/dist/src/features/editor/use-cases.js +61 -0
- package/dist/src/features/org/org-service.js +76 -0
- package/dist/src/features/org/use-cases.js +140 -0
- package/dist/src/features/skill/bundled-skills.js +55 -0
- package/dist/src/features/skill/installer.js +265 -0
- package/dist/src/features/tool/catalog.js +79 -0
- package/dist/src/features/tool/dynamic-schema.js +96 -0
- package/dist/src/features/tool/mns-catalog-adapter.js +262 -0
- package/dist/src/features/tool/tool-api-adapter.js +200 -0
- package/dist/src/features/tool/use-cases.js +126 -0
- package/dist/src/features/update/update-service.js +194 -0
- package/dist/src/platform/json-input.js +64 -0
- package/dist/src/platform/local-json-file.js +59 -0
- package/dist/src/platform/open-browser.js +8 -0
- package/dist/src/platform/redact.js +80 -0
- package/dist/src/platform/safe-upload-file.js +146 -0
- package/dist/src/platform/signature.js +19 -0
- package/dist/src/platform/signed-http-transport.js +109 -0
- package/dist/src/platform/url-safety.js +100 -0
- package/package.json +56 -0
- package/skills/gd-cli/SKILL.md +15 -0
- package/skills/gd-cli/references/auth-org.md +9 -0
- package/skills/gd-cli/references/creation.md +38 -0
- package/skills/gd-cli/references/dam.md +20 -0
- package/skills/gd-cli/references/editor.md +12 -0
- package/skills/gd-cli/references/errors.md +10 -0
- package/skills/gd-cli/references/update.md +11 -0
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { spawn as nodeSpawn } from "node:child_process";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { startEditorBridge } from "./bridge-server.js";
|
|
5
|
+
import { createEditorSessionStateStore } from "./session-state.js";
|
|
6
|
+
const DEFAULT_READINESS_TIMEOUT_MS = 10_000;
|
|
7
|
+
export async function runEditorBridgeProcess(targetOrigin, options = {}) {
|
|
8
|
+
const runtime = options.runtime ?? process;
|
|
9
|
+
const store = createEditorSessionStateStore({
|
|
10
|
+
...(options.homeDirectory === undefined
|
|
11
|
+
? {}
|
|
12
|
+
: { homeDirectory: options.homeDirectory })
|
|
13
|
+
});
|
|
14
|
+
const bridge = await (options.startBridge ?? startEditorBridge)({ targetOrigin });
|
|
15
|
+
const shutdown = () => {
|
|
16
|
+
void bridge.close().catch(() => undefined);
|
|
17
|
+
};
|
|
18
|
+
let signalListenersInstalled = false;
|
|
19
|
+
const removeSignalListeners = () => {
|
|
20
|
+
if (!signalListenersInstalled)
|
|
21
|
+
return;
|
|
22
|
+
signalListenersInstalled = false;
|
|
23
|
+
runtime.removeListener("SIGINT", shutdown);
|
|
24
|
+
runtime.removeListener("SIGTERM", shutdown);
|
|
25
|
+
};
|
|
26
|
+
try {
|
|
27
|
+
await store.write(bridge.state);
|
|
28
|
+
runtime.once("SIGINT", shutdown);
|
|
29
|
+
runtime.once("SIGTERM", shutdown);
|
|
30
|
+
signalListenersInstalled = true;
|
|
31
|
+
await runtime.send?.({ type: "ready", state: bridge.state });
|
|
32
|
+
await runtime.disconnect?.();
|
|
33
|
+
}
|
|
34
|
+
catch (cause) {
|
|
35
|
+
removeSignalListeners();
|
|
36
|
+
await cleanupFailedBridge(bridge, store);
|
|
37
|
+
throw cause;
|
|
38
|
+
}
|
|
39
|
+
try {
|
|
40
|
+
await bridge.closed;
|
|
41
|
+
}
|
|
42
|
+
finally {
|
|
43
|
+
removeSignalListeners();
|
|
44
|
+
await store.removeIfOwned(bridge.state.token);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
export function startEditorBridgeProcess(input) {
|
|
48
|
+
if (input.signal.aborted)
|
|
49
|
+
return Promise.reject(abortReason(input.signal));
|
|
50
|
+
let child;
|
|
51
|
+
try {
|
|
52
|
+
child = (input.spawn ?? nodeSpawn)(process.execPath, [
|
|
53
|
+
fileURLToPath(new URL("./bridge-process.js", import.meta.url)),
|
|
54
|
+
input.targetOrigin,
|
|
55
|
+
...(input.homeDirectory === undefined ? [] : [input.homeDirectory])
|
|
56
|
+
], {
|
|
57
|
+
detached: true,
|
|
58
|
+
stdio: ["ignore", "ignore", "ignore", "ipc"]
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
catch (cause) {
|
|
62
|
+
return Promise.reject(cause);
|
|
63
|
+
}
|
|
64
|
+
return awaitReadiness(child, input);
|
|
65
|
+
}
|
|
66
|
+
async function cleanupFailedBridge(bridge, store) {
|
|
67
|
+
try {
|
|
68
|
+
await bridge.close();
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
// Preserve the setup failure.
|
|
72
|
+
}
|
|
73
|
+
try {
|
|
74
|
+
await store.removeIfOwned(bridge.state.token);
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
// Preserve the setup failure.
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
function awaitReadiness(child, input) {
|
|
81
|
+
return new Promise((resolvePromise, rejectPromise) => {
|
|
82
|
+
let settled = false;
|
|
83
|
+
let timer;
|
|
84
|
+
let ipcDisconnectAttempted = false;
|
|
85
|
+
let killAttempted = false;
|
|
86
|
+
const onError = (error) => {
|
|
87
|
+
settleFailure(error);
|
|
88
|
+
};
|
|
89
|
+
const onExit = (code) => {
|
|
90
|
+
settleFailure(new Error(`Editor Bridge exited before ready (${code ?? "signal"}).`));
|
|
91
|
+
};
|
|
92
|
+
const onMessage = (message) => {
|
|
93
|
+
if (settled)
|
|
94
|
+
return;
|
|
95
|
+
const state = parseReadyMessage(message, input.targetOrigin);
|
|
96
|
+
if (!state) {
|
|
97
|
+
settleFailure(new Error("Editor Bridge sent invalid readiness."));
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
try {
|
|
101
|
+
disconnectIpc();
|
|
102
|
+
child.unref();
|
|
103
|
+
}
|
|
104
|
+
catch (cause) {
|
|
105
|
+
settleFailure(cause);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
settled = true;
|
|
109
|
+
clearTimer();
|
|
110
|
+
removeSuccessListeners();
|
|
111
|
+
resolvePromise(state);
|
|
112
|
+
};
|
|
113
|
+
const onAbort = () => {
|
|
114
|
+
settleFailure(abortReason(input.signal));
|
|
115
|
+
};
|
|
116
|
+
const clearTimer = () => {
|
|
117
|
+
if (timer === undefined)
|
|
118
|
+
return;
|
|
119
|
+
clearTimeout(timer);
|
|
120
|
+
timer = undefined;
|
|
121
|
+
};
|
|
122
|
+
const disconnectIpc = () => {
|
|
123
|
+
if (ipcDisconnectAttempted || !child.connected)
|
|
124
|
+
return;
|
|
125
|
+
ipcDisconnectAttempted = true;
|
|
126
|
+
child.disconnect();
|
|
127
|
+
};
|
|
128
|
+
const cleanupFailedChild = () => {
|
|
129
|
+
child.removeListener("message", onMessage);
|
|
130
|
+
input.signal.removeEventListener("abort", onAbort);
|
|
131
|
+
try {
|
|
132
|
+
disconnectIpc();
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
// The IPC channel may already be unavailable.
|
|
136
|
+
}
|
|
137
|
+
if (!killAttempted) {
|
|
138
|
+
killAttempted = true;
|
|
139
|
+
try {
|
|
140
|
+
child.kill();
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
// The child may already have exited.
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
const removeSuccessListeners = () => {
|
|
148
|
+
child.removeListener("message", onMessage);
|
|
149
|
+
child.removeListener("error", onError);
|
|
150
|
+
child.removeListener("exit", onExit);
|
|
151
|
+
input.signal.removeEventListener("abort", onAbort);
|
|
152
|
+
};
|
|
153
|
+
const settleFailure = (cause) => {
|
|
154
|
+
if (settled)
|
|
155
|
+
return;
|
|
156
|
+
settled = true;
|
|
157
|
+
clearTimer();
|
|
158
|
+
cleanupFailedChild();
|
|
159
|
+
rejectPromise(cause);
|
|
160
|
+
};
|
|
161
|
+
child.on("error", onError);
|
|
162
|
+
child.on("exit", onExit);
|
|
163
|
+
child.on("message", onMessage);
|
|
164
|
+
input.signal.addEventListener("abort", onAbort, { once: true });
|
|
165
|
+
if (input.signal.aborted) {
|
|
166
|
+
settleFailure(abortReason(input.signal));
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
timer = setTimeout(() => settleFailure(new Error("Editor Bridge readiness timed out.")), Math.max(0, input.readinessTimeoutMs ?? DEFAULT_READINESS_TIMEOUT_MS));
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
function parseReadyMessage(value, expectedTargetOrigin) {
|
|
173
|
+
if (!isRecord(value) || !hasExactKeys(value, ["type", "state"]) || value.type !== "ready") {
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
const state = value.state;
|
|
177
|
+
if (!isRecord(state) ||
|
|
178
|
+
!hasExactKeys(state, ["pid", "port", "token", "startedAt", "targetOrigin"]) ||
|
|
179
|
+
!Number.isInteger(state.pid) ||
|
|
180
|
+
state.pid <= 0 ||
|
|
181
|
+
!Number.isInteger(state.port) ||
|
|
182
|
+
state.port < 1 ||
|
|
183
|
+
state.port > 65_535 ||
|
|
184
|
+
!isNonblank(state.token) ||
|
|
185
|
+
!isNonblank(state.startedAt) ||
|
|
186
|
+
state.targetOrigin !== expectedTargetOrigin) {
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
return state;
|
|
190
|
+
}
|
|
191
|
+
function abortReason(signal) {
|
|
192
|
+
return signal.reason instanceof Error
|
|
193
|
+
? signal.reason
|
|
194
|
+
: new DOMException("The Editor Bridge start was aborted.", "AbortError");
|
|
195
|
+
}
|
|
196
|
+
function isNonblank(value) {
|
|
197
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
198
|
+
}
|
|
199
|
+
function isRecord(value) {
|
|
200
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
201
|
+
}
|
|
202
|
+
function hasExactKeys(value, keys) {
|
|
203
|
+
const actual = Object.keys(value).sort();
|
|
204
|
+
const expected = [...keys].sort();
|
|
205
|
+
return actual.length === expected.length &&
|
|
206
|
+
actual.every((key, index) => key === expected[index]);
|
|
207
|
+
}
|
|
208
|
+
const ownPath = fileURLToPath(import.meta.url);
|
|
209
|
+
if (process.argv[1] && resolve(process.argv[1]) === resolve(ownPath)) {
|
|
210
|
+
const targetOrigin = process.argv[2];
|
|
211
|
+
const homeDirectory = process.argv[3];
|
|
212
|
+
if (!targetOrigin) {
|
|
213
|
+
process.exitCode = 1;
|
|
214
|
+
}
|
|
215
|
+
else {
|
|
216
|
+
void runEditorBridgeProcess(targetOrigin, {
|
|
217
|
+
...(homeDirectory === undefined ? {} : { homeDirectory })
|
|
218
|
+
}).catch(() => {
|
|
219
|
+
process.exitCode = 1;
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
}
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import { randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { createServer } from "node:http";
|
|
3
|
+
import { WebSocket, WebSocketServer } from "ws";
|
|
4
|
+
import { EDITOR_PROTOCOL_VERSION } from "./protocol.js";
|
|
5
|
+
export async function startEditorBridge(options) {
|
|
6
|
+
const token = options.token ?? randomBytes(32).toString("hex");
|
|
7
|
+
const now = options.now ?? (() => new Date());
|
|
8
|
+
const nextRequestId = options.requestId ?? randomUUID;
|
|
9
|
+
const webSockets = new WebSocketServer({ noServer: true });
|
|
10
|
+
let pageSocket = null;
|
|
11
|
+
let pending;
|
|
12
|
+
let closing;
|
|
13
|
+
let resolveClosed;
|
|
14
|
+
const closed = new Promise((resolve) => {
|
|
15
|
+
resolveClosed = resolve;
|
|
16
|
+
});
|
|
17
|
+
const http = createServer((request, response) => {
|
|
18
|
+
void handleRequest(request, response).catch(() => {
|
|
19
|
+
sendError(response, 500, "SERVER_ERROR", "Editor Bridge request failed.");
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
http.on("upgrade", (request, socket, head) => {
|
|
23
|
+
if (request.headers.origin !== options.targetOrigin) {
|
|
24
|
+
rejectUpgrade(socket, 403, "Forbidden");
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
webSockets.handleUpgrade(request, socket, head, (webSocket) => {
|
|
28
|
+
webSockets.emit("connection", webSocket, request);
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
webSockets.on("connection", (socket) => {
|
|
32
|
+
let authenticated = false;
|
|
33
|
+
const handleSocketClosed = () => {
|
|
34
|
+
if (socket !== pageSocket)
|
|
35
|
+
return;
|
|
36
|
+
pageSocket = null;
|
|
37
|
+
failPending(503, "EDITOR_DISCONNECTED", "The Editor page disconnected.");
|
|
38
|
+
};
|
|
39
|
+
socket.on("message", (data) => {
|
|
40
|
+
const message = parseMessage(data.toString());
|
|
41
|
+
if (!authenticated) {
|
|
42
|
+
const connect = parseConnectMessage(message);
|
|
43
|
+
if (!connect) {
|
|
44
|
+
closeWithError(socket, "INVALID_HANDSHAKE", "First message must authenticate.");
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
if (!safeEqual(connect.token, token)) {
|
|
48
|
+
closeWithError(socket, "UNAUTHORIZED", "Editor token is invalid.");
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
if (connect.protocolVersion !== EDITOR_PROTOCOL_VERSION) {
|
|
52
|
+
closeWithError(socket, "PROTOCOL_VERSION_MISMATCH", `Editor protocol version must be ${EDITOR_PROTOCOL_VERSION}.`);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
if (pageSocket !== null) {
|
|
56
|
+
closeWithError(socket, "EDITOR_ALREADY_CONNECTED", "An Editor page is already connected.");
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
authenticated = true;
|
|
60
|
+
pageSocket = socket;
|
|
61
|
+
sendSocket(socket, {
|
|
62
|
+
type: "connected",
|
|
63
|
+
protocolVersion: EDITOR_PROTOCOL_VERSION
|
|
64
|
+
});
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
if (socket !== pageSocket)
|
|
68
|
+
return;
|
|
69
|
+
const pageResponse = parseResponseMessage(message);
|
|
70
|
+
if (!pageResponse || pageResponse.requestId !== pending?.requestId)
|
|
71
|
+
return;
|
|
72
|
+
const response = pending.response;
|
|
73
|
+
pending = undefined;
|
|
74
|
+
if (pageResponse.error) {
|
|
75
|
+
sendJson(response, 422, { error: pageResponse.error });
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
sendJson(response, 200, { result: pageResponse.result });
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
socket.on("error", () => {
|
|
82
|
+
handleSocketClosed();
|
|
83
|
+
if (socket.readyState !== WebSocket.CLOSED)
|
|
84
|
+
socket.terminate();
|
|
85
|
+
});
|
|
86
|
+
socket.on("close", handleSocketClosed);
|
|
87
|
+
});
|
|
88
|
+
await new Promise((resolve, reject) => {
|
|
89
|
+
http.once("error", reject);
|
|
90
|
+
http.listen(0, "127.0.0.1", () => {
|
|
91
|
+
http.off("error", reject);
|
|
92
|
+
resolve();
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
const address = http.address();
|
|
96
|
+
if (!address || typeof address === "string") {
|
|
97
|
+
await close();
|
|
98
|
+
throw new Error("Editor Bridge did not bind a TCP port.");
|
|
99
|
+
}
|
|
100
|
+
const state = {
|
|
101
|
+
pid: process.pid,
|
|
102
|
+
port: address.port,
|
|
103
|
+
token,
|
|
104
|
+
startedAt: now().toISOString(),
|
|
105
|
+
targetOrigin: options.targetOrigin
|
|
106
|
+
};
|
|
107
|
+
return { state, close, closed };
|
|
108
|
+
async function handleRequest(request, response) {
|
|
109
|
+
if (!isAuthorized(request, token)) {
|
|
110
|
+
sendError(response, 401, "UNAUTHORIZED", "Editor token is invalid.");
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
const pathname = new URL(request.url ?? "/", "http://127.0.0.1").pathname;
|
|
114
|
+
if (request.method === "GET" && pathname === "/health") {
|
|
115
|
+
sendJson(response, 200, {
|
|
116
|
+
status: "ok",
|
|
117
|
+
pageConnected: pageSocket?.readyState === WebSocket.OPEN,
|
|
118
|
+
protocolVersion: EDITOR_PROTOCOL_VERSION
|
|
119
|
+
});
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
if (request.method === "POST" && pathname === "/rpc") {
|
|
123
|
+
if (pageSocket?.readyState !== WebSocket.OPEN) {
|
|
124
|
+
sendError(response, 409, "EDITOR_NOT_CONNECTED", "No Editor page is connected.");
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
if (pending) {
|
|
128
|
+
sendError(response, 409, "EDITOR_BUSY", "Editor is handling another request.");
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
const body = await readRpcBody(request).catch(() => null);
|
|
132
|
+
if (!body) {
|
|
133
|
+
sendError(response, 400, "INVALID_REQUEST", "Editor RPC body is invalid.");
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
if (pageSocket?.readyState !== WebSocket.OPEN) {
|
|
137
|
+
sendError(response, 409, "EDITOR_NOT_CONNECTED", "No Editor page is connected.");
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
if (pending) {
|
|
141
|
+
sendError(response, 409, "EDITOR_BUSY", "Editor is handling another request.");
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
const requestId = nextRequestId();
|
|
145
|
+
const socket = pageSocket;
|
|
146
|
+
pending = { requestId, response };
|
|
147
|
+
const message = {
|
|
148
|
+
type: "request",
|
|
149
|
+
requestId,
|
|
150
|
+
method: body.method,
|
|
151
|
+
...(body.payload === undefined ? {} : { payload: body.payload })
|
|
152
|
+
};
|
|
153
|
+
try {
|
|
154
|
+
socket.send(JSON.stringify(message), (error) => {
|
|
155
|
+
if (!error || pending?.requestId !== requestId)
|
|
156
|
+
return;
|
|
157
|
+
pending = undefined;
|
|
158
|
+
sendError(response, 503, "EDITOR_DISCONNECTED", "The Editor page disconnected.");
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
if (pending?.requestId === requestId)
|
|
163
|
+
pending = undefined;
|
|
164
|
+
sendError(response, 503, "EDITOR_DISCONNECTED", "The Editor page disconnected.");
|
|
165
|
+
}
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
if (request.method === "POST" && pathname === "/disconnect") {
|
|
169
|
+
sendJson(response, 200, { status: "disconnected" });
|
|
170
|
+
setImmediate(() => void close());
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
sendError(response, 404, "NOT_FOUND", "Editor Bridge route not found.");
|
|
174
|
+
}
|
|
175
|
+
function failPending(status, code, message) {
|
|
176
|
+
if (!pending)
|
|
177
|
+
return;
|
|
178
|
+
const response = pending.response;
|
|
179
|
+
pending = undefined;
|
|
180
|
+
sendError(response, status, code, message);
|
|
181
|
+
}
|
|
182
|
+
function close() {
|
|
183
|
+
if (closing)
|
|
184
|
+
return closing;
|
|
185
|
+
closing = new Promise((resolve) => {
|
|
186
|
+
failPending(503, "SERVER_STOPPED", "Editor Bridge stopped.");
|
|
187
|
+
for (const socket of webSockets.clients)
|
|
188
|
+
socket.terminate();
|
|
189
|
+
webSockets.close();
|
|
190
|
+
http.close(() => {
|
|
191
|
+
resolve();
|
|
192
|
+
resolveClosed();
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
return closing;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
function isAuthorized(request, token) {
|
|
199
|
+
const header = request.headers.authorization;
|
|
200
|
+
return typeof header === "string" && safeEqual(header, `Bearer ${token}`);
|
|
201
|
+
}
|
|
202
|
+
function safeEqual(actual, expected) {
|
|
203
|
+
const actualBytes = Buffer.from(actual);
|
|
204
|
+
const expectedBytes = Buffer.from(expected);
|
|
205
|
+
return actualBytes.length === expectedBytes.length &&
|
|
206
|
+
timingSafeEqual(actualBytes, expectedBytes);
|
|
207
|
+
}
|
|
208
|
+
function rejectUpgrade(socket, status, message) {
|
|
209
|
+
socket.end(`HTTP/1.1 ${status} ${message}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`);
|
|
210
|
+
}
|
|
211
|
+
function closeWithError(socket, code, message) {
|
|
212
|
+
sendSocket(socket, { type: "error", error: { code, message } }, () => socket.close());
|
|
213
|
+
}
|
|
214
|
+
function sendSocket(socket, value, callback) {
|
|
215
|
+
try {
|
|
216
|
+
socket.send(JSON.stringify(value), () => callback?.());
|
|
217
|
+
}
|
|
218
|
+
catch {
|
|
219
|
+
socket.terminate();
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
function parseMessage(value) {
|
|
223
|
+
try {
|
|
224
|
+
return JSON.parse(value);
|
|
225
|
+
}
|
|
226
|
+
catch {
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
function parseConnectMessage(value) {
|
|
231
|
+
if (!isRecord(value) || !hasExactKeys(value, ["type", "token", "protocolVersion"])) {
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
if (value.type !== "connect" ||
|
|
235
|
+
typeof value.token !== "string" ||
|
|
236
|
+
typeof value.protocolVersion !== "number") {
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
return {
|
|
240
|
+
type: "connect",
|
|
241
|
+
token: value.token,
|
|
242
|
+
protocolVersion: value.protocolVersion
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
function parseResponseMessage(value) {
|
|
246
|
+
if (!isRecord(value) || value.type !== "response" || typeof value.requestId !== "string") {
|
|
247
|
+
return null;
|
|
248
|
+
}
|
|
249
|
+
if (value.error !== undefined) {
|
|
250
|
+
if (!isRecord(value.error) ||
|
|
251
|
+
typeof value.error.code !== "string" ||
|
|
252
|
+
typeof value.error.message !== "string") {
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
255
|
+
return {
|
|
256
|
+
type: "response",
|
|
257
|
+
requestId: value.requestId,
|
|
258
|
+
error: {
|
|
259
|
+
code: value.error.code,
|
|
260
|
+
message: value.error.message
|
|
261
|
+
}
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
return {
|
|
265
|
+
type: "response",
|
|
266
|
+
requestId: value.requestId,
|
|
267
|
+
...(value.result === undefined ? {} : { result: value.result })
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
async function readRpcBody(request) {
|
|
271
|
+
const chunks = [];
|
|
272
|
+
for await (const chunk of request) {
|
|
273
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)));
|
|
274
|
+
}
|
|
275
|
+
const value = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
276
|
+
if (!isRecord(value) ||
|
|
277
|
+
!hasExactKeys(value, value.payload === undefined ? ["method"] : ["method", "payload"]) ||
|
|
278
|
+
!isEditorMethod(value.method)) {
|
|
279
|
+
throw new Error("invalid Editor RPC body");
|
|
280
|
+
}
|
|
281
|
+
return {
|
|
282
|
+
method: value.method,
|
|
283
|
+
...(value.payload === undefined ? {} : { payload: value.payload })
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
function isEditorMethod(value) {
|
|
287
|
+
return value === "preflight" ||
|
|
288
|
+
value === "snapshot" ||
|
|
289
|
+
value === "apply" ||
|
|
290
|
+
value === "screenshot" ||
|
|
291
|
+
value === "save";
|
|
292
|
+
}
|
|
293
|
+
function sendError(response, status, code, message) {
|
|
294
|
+
if (!response.headersSent)
|
|
295
|
+
sendJson(response, status, { error: { code, message } });
|
|
296
|
+
}
|
|
297
|
+
function sendJson(response, status, value) {
|
|
298
|
+
if (response.writableEnded || response.destroyed)
|
|
299
|
+
return;
|
|
300
|
+
response.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
|
|
301
|
+
response.end(JSON.stringify(value));
|
|
302
|
+
}
|
|
303
|
+
function isRecord(value) {
|
|
304
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
305
|
+
}
|
|
306
|
+
function hasExactKeys(value, keys) {
|
|
307
|
+
const actual = Object.keys(value).sort();
|
|
308
|
+
const expected = [...keys].sort();
|
|
309
|
+
return actual.length === expected.length &&
|
|
310
|
+
actual.every((key, index) => key === expected[index]);
|
|
311
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const EDITOR_PROTOCOL_VERSION = 1;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { createLocalJsonFile } from "../../platform/local-json-file.js";
|
|
4
|
+
export function createEditorSessionStateStore(options = {}) {
|
|
5
|
+
const file = options.file ?? createLocalJsonFile({
|
|
6
|
+
directory: join(options.homeDirectory ?? homedir(), ".gd"),
|
|
7
|
+
filename: "editor-session.json"
|
|
8
|
+
});
|
|
9
|
+
const read = async () => {
|
|
10
|
+
const text = await file.readText();
|
|
11
|
+
if (text === null)
|
|
12
|
+
return null;
|
|
13
|
+
let value;
|
|
14
|
+
try {
|
|
15
|
+
value = JSON.parse(text);
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
return isEditorSessionState(value) ? value : null;
|
|
21
|
+
};
|
|
22
|
+
return {
|
|
23
|
+
read,
|
|
24
|
+
async write(state) {
|
|
25
|
+
await file.writeTextAtomically(`${JSON.stringify(state, null, 2)}\n`);
|
|
26
|
+
},
|
|
27
|
+
async remove() {
|
|
28
|
+
await file.remove();
|
|
29
|
+
},
|
|
30
|
+
async removeIfOwned(token) {
|
|
31
|
+
if ((await read())?.token === token)
|
|
32
|
+
await file.remove();
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
function isEditorSessionState(value) {
|
|
37
|
+
if (!isRecord(value))
|
|
38
|
+
return false;
|
|
39
|
+
if (!hasExactKeys(value, ["pid", "port", "token", "startedAt", "targetOrigin"])) {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
return Number.isInteger(value.pid) &&
|
|
43
|
+
value.pid > 0 &&
|
|
44
|
+
Number.isInteger(value.port) &&
|
|
45
|
+
value.port >= 1 &&
|
|
46
|
+
value.port <= 65_535 &&
|
|
47
|
+
isNonblank(value.token) &&
|
|
48
|
+
isNonblank(value.startedAt) &&
|
|
49
|
+
isHttpOrigin(value.targetOrigin);
|
|
50
|
+
}
|
|
51
|
+
function isHttpOrigin(value) {
|
|
52
|
+
if (!isNonblank(value))
|
|
53
|
+
return false;
|
|
54
|
+
try {
|
|
55
|
+
const url = new URL(value);
|
|
56
|
+
return (url.protocol === "http:" || url.protocol === "https:") &&
|
|
57
|
+
url.username === "" &&
|
|
58
|
+
url.password === "" &&
|
|
59
|
+
url.pathname === "/" &&
|
|
60
|
+
url.search === "" &&
|
|
61
|
+
url.hash === "";
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
function isNonblank(value) {
|
|
68
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
69
|
+
}
|
|
70
|
+
function isRecord(value) {
|
|
71
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
72
|
+
}
|
|
73
|
+
function hasExactKeys(value, keys) {
|
|
74
|
+
const actual = Object.keys(value).sort();
|
|
75
|
+
const expected = [...keys].sort();
|
|
76
|
+
return actual.length === expected.length &&
|
|
77
|
+
actual.every((key, index) => key === expected[index]);
|
|
78
|
+
}
|