@pixi-board/board-plugin-canvas 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +1131 -0
- package/package.json +27 -0
- package/src/dto.ts +151 -0
- package/src/errors.ts +11 -0
- package/src/filter.ts +106 -0
- package/src/index.ts +497 -0
- package/src/projects.ts +269 -0
- package/src/reader.ts +177 -0
- package/src/writerClient.ts +141 -0
package/src/projects.ts
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import type { ProjectInfo } from "@canvas/board-domain";
|
|
2
|
+
import { promises as fs } from "node:fs";
|
|
3
|
+
import net from "node:net";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { toProjectInfo } from "./dto";
|
|
7
|
+
import { BoardToolUserError } from "./errors";
|
|
8
|
+
|
|
9
|
+
const APP_IDENTIFIER = "com.codex.pixi-board";
|
|
10
|
+
const ACTIVE_PROJECT = "active";
|
|
11
|
+
const LAST_PROJECT_FILE = ".last-canvas";
|
|
12
|
+
const KNOWN_PROJECTS_FILE = ".known-canvas-projects.json";
|
|
13
|
+
const BRIDGE_FILE = ".canvas-mcp-bridge.json";
|
|
14
|
+
const BRIDGE_CONNECT_TIMEOUT_MS = 250;
|
|
15
|
+
|
|
16
|
+
type BridgeEndpoint = {
|
|
17
|
+
version: number;
|
|
18
|
+
host: string;
|
|
19
|
+
port: number;
|
|
20
|
+
token: string;
|
|
21
|
+
updatedAt: number;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export type KnownProject = ProjectInfo & {
|
|
25
|
+
bridgeAvailable: boolean;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
type ProjectOptions = {
|
|
29
|
+
appRoot?: string;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export async function resolveProjectRoot(projectRoot: unknown, options?: ProjectOptions): Promise<string> {
|
|
33
|
+
if (typeof projectRoot !== "string" || projectRoot.trim() === "") {
|
|
34
|
+
throw new BoardToolUserError('projectRoot must be a non-empty string or "active"');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (projectRoot.trim() === ACTIVE_PROJECT) {
|
|
38
|
+
return resolveActiveProjectRoot(options);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return realpathForProject(projectRoot);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function listKnownProjects(options?: ProjectOptions): Promise<KnownProject[]> {
|
|
45
|
+
const appRoot = resolveAppRoot(options);
|
|
46
|
+
const roots = await readKnownProjectRoots(appRoot);
|
|
47
|
+
roots.sort((left, right) => projectDisplayName(left).localeCompare(projectDisplayName(right), undefined, {
|
|
48
|
+
sensitivity: "accent",
|
|
49
|
+
}));
|
|
50
|
+
|
|
51
|
+
const bridgeAvailability = await Promise.all(
|
|
52
|
+
roots.map(async (root) => ({
|
|
53
|
+
root,
|
|
54
|
+
bridgeAvailable: await isBridgeAvailable(root),
|
|
55
|
+
})),
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
return bridgeAvailability.map(({ root, bridgeAvailable }) => ({
|
|
59
|
+
...toProjectInfo(root),
|
|
60
|
+
bridgeAvailable,
|
|
61
|
+
}));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function resolveActiveProjectRoot(options?: ProjectOptions): Promise<string> {
|
|
65
|
+
const appRoot = resolveAppRoot(options);
|
|
66
|
+
const root = await readLastProject(appRoot);
|
|
67
|
+
if (!root) {
|
|
68
|
+
throw new BoardToolUserError("No active canvas project. Open a project in the desktop app first.");
|
|
69
|
+
}
|
|
70
|
+
return root;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function readKnownProjectRoots(appRoot: string): Promise<string[]> {
|
|
74
|
+
const registryPath = path.join(appRoot, KNOWN_PROJECTS_FILE);
|
|
75
|
+
let parsed: unknown = [];
|
|
76
|
+
try {
|
|
77
|
+
parsed = JSON.parse(await fs.readFile(registryPath, "utf8"));
|
|
78
|
+
} catch (error) {
|
|
79
|
+
if (!isMissing(error)) {
|
|
80
|
+
await writeKnownProjectRoots(registryPath, []);
|
|
81
|
+
}
|
|
82
|
+
return [];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (!Array.isArray(parsed)) {
|
|
86
|
+
await writeKnownProjectRoots(registryPath, []);
|
|
87
|
+
return [];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const validRoots: string[] = [];
|
|
91
|
+
for (const entry of parsed) {
|
|
92
|
+
if (typeof entry !== "string" || entry.trim() === "") continue;
|
|
93
|
+
try {
|
|
94
|
+
const root = await fs.realpath(entry);
|
|
95
|
+
if (await isCanvasProject(root)) {
|
|
96
|
+
validRoots.push(root);
|
|
97
|
+
}
|
|
98
|
+
} catch {
|
|
99
|
+
// Ignore stale project entries and lazily clean them below.
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const dedupedRoots = dedupeProjectRoots(validRoots);
|
|
104
|
+
const rawRoots = parsed.filter((entry): entry is string => typeof entry === "string" && entry.trim() !== "");
|
|
105
|
+
if (!sameProjectLists(rawRoots, dedupedRoots)) {
|
|
106
|
+
await writeKnownProjectRoots(registryPath, dedupedRoots);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return dedupedRoots;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function readLastProject(appRoot: string): Promise<string | null> {
|
|
113
|
+
const lastProjectPath = path.join(appRoot, LAST_PROJECT_FILE);
|
|
114
|
+
let stored: string;
|
|
115
|
+
try {
|
|
116
|
+
stored = await fs.readFile(lastProjectPath, "utf8");
|
|
117
|
+
} catch (error) {
|
|
118
|
+
if (isMissing(error)) return null;
|
|
119
|
+
throw error;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const candidate = stored.trim();
|
|
123
|
+
if (!candidate) return null;
|
|
124
|
+
try {
|
|
125
|
+
const root = await fs.realpath(candidate);
|
|
126
|
+
return (await isCanvasProject(root)) ? root : null;
|
|
127
|
+
} catch {
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function writeKnownProjectRoots(registryPath: string, roots: string[]): Promise<void> {
|
|
133
|
+
await fs.mkdir(path.dirname(registryPath), { recursive: true });
|
|
134
|
+
await fs.writeFile(`${registryPath}.tmp`, `${JSON.stringify(roots, null, 2)}\n`, "utf8");
|
|
135
|
+
await fs.rename(`${registryPath}.tmp`, registryPath);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function isCanvasProject(projectRoot: string): Promise<boolean> {
|
|
139
|
+
try {
|
|
140
|
+
const [board, assets] = await Promise.all([
|
|
141
|
+
fs.stat(path.join(projectRoot, "board.json")),
|
|
142
|
+
fs.stat(path.join(projectRoot, "assets.json")),
|
|
143
|
+
]);
|
|
144
|
+
return board.isFile() && assets.isFile();
|
|
145
|
+
} catch {
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function realpathForProject(projectRoot: string): Promise<string> {
|
|
151
|
+
try {
|
|
152
|
+
const root = await fs.realpath(projectRoot);
|
|
153
|
+
const stat = await fs.stat(root);
|
|
154
|
+
if (!stat.isDirectory()) {
|
|
155
|
+
throw new BoardToolUserError(`projectRoot is not a directory: ${projectRoot}`);
|
|
156
|
+
}
|
|
157
|
+
if (!(await isCanvasProject(root))) {
|
|
158
|
+
throw new BoardToolUserError(`invalid canvas project: ${projectRoot}`);
|
|
159
|
+
}
|
|
160
|
+
return root;
|
|
161
|
+
} catch (error) {
|
|
162
|
+
if (error instanceof BoardToolUserError) throw error;
|
|
163
|
+
throw new BoardToolUserError(`Cannot access projectRoot ${projectRoot}: ${String(error)}`);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function isBridgeAvailable(projectRoot: string): Promise<boolean> {
|
|
168
|
+
const bridgePath = path.join(projectRoot, BRIDGE_FILE);
|
|
169
|
+
let value: unknown;
|
|
170
|
+
try {
|
|
171
|
+
value = JSON.parse(await fs.readFile(bridgePath, "utf8"));
|
|
172
|
+
} catch {
|
|
173
|
+
return false;
|
|
174
|
+
}
|
|
175
|
+
if (!isEndpoint(value)) return false;
|
|
176
|
+
return canConnectToBridge(value.host, value.port);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function canConnectToBridge(host: string, port: number): Promise<boolean> {
|
|
180
|
+
return new Promise((resolve) => {
|
|
181
|
+
const socket = net.createConnection({ host, port });
|
|
182
|
+
let settled = false;
|
|
183
|
+
const settle = (result: boolean) => {
|
|
184
|
+
if (settled) return;
|
|
185
|
+
settled = true;
|
|
186
|
+
socket.destroy();
|
|
187
|
+
resolve(result);
|
|
188
|
+
};
|
|
189
|
+
const timer = setTimeout(() => settle(false), BRIDGE_CONNECT_TIMEOUT_MS);
|
|
190
|
+
|
|
191
|
+
socket.once("connect", () => {
|
|
192
|
+
clearTimeout(timer);
|
|
193
|
+
settle(true);
|
|
194
|
+
});
|
|
195
|
+
socket.once("error", () => {
|
|
196
|
+
clearTimeout(timer);
|
|
197
|
+
settle(false);
|
|
198
|
+
});
|
|
199
|
+
socket.once("close", () => {
|
|
200
|
+
clearTimeout(timer);
|
|
201
|
+
settle(false);
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function resolveAppRoot(options?: ProjectOptions): string {
|
|
207
|
+
if (options?.appRoot) {
|
|
208
|
+
return path.resolve(options.appRoot);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (process.platform === "darwin") {
|
|
212
|
+
return path.join(os.homedir(), "Library", "Application Support", APP_IDENTIFIER);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (process.platform === "win32") {
|
|
216
|
+
const appData = process.env.APPDATA;
|
|
217
|
+
if (!appData) {
|
|
218
|
+
throw new BoardToolUserError("APPDATA is not set; cannot resolve the desktop app project root");
|
|
219
|
+
}
|
|
220
|
+
return path.join(appData, APP_IDENTIFIER);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const baseDir = process.env.XDG_DATA_HOME ?? path.join(os.homedir(), ".local", "share");
|
|
224
|
+
return path.join(baseDir, APP_IDENTIFIER);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function dedupeProjectRoots(roots: string[]): string[] {
|
|
228
|
+
const seen = new Set<string>();
|
|
229
|
+
const deduped: string[] = [];
|
|
230
|
+
for (const root of roots) {
|
|
231
|
+
const key = normalizeRootKey(root);
|
|
232
|
+
if (seen.has(key)) continue;
|
|
233
|
+
seen.add(key);
|
|
234
|
+
deduped.push(root);
|
|
235
|
+
}
|
|
236
|
+
return deduped;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function sameProjectLists(left: string[], right: string[]): boolean {
|
|
240
|
+
if (left.length !== right.length) return false;
|
|
241
|
+
return left.every((value, index) => normalizeRootKey(value) === normalizeRootKey(right[index]));
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function normalizeRootKey(root: string): string {
|
|
245
|
+
return process.platform === "win32" ? root.toLowerCase() : root;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function projectDisplayName(projectRoot: string): string {
|
|
249
|
+
return path.basename(projectRoot).toLowerCase();
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function isMissing(error: unknown): boolean {
|
|
253
|
+
return Boolean(error && typeof error === "object" && "code" in error && error.code === "ENOENT");
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function isEndpoint(value: unknown): value is BridgeEndpoint {
|
|
257
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
258
|
+
const endpoint = value as Partial<BridgeEndpoint>;
|
|
259
|
+
return (
|
|
260
|
+
endpoint.version === 1 &&
|
|
261
|
+
endpoint.host === "127.0.0.1" &&
|
|
262
|
+
typeof endpoint.port === "number" &&
|
|
263
|
+
Number.isInteger(endpoint.port) &&
|
|
264
|
+
endpoint.port > 0 &&
|
|
265
|
+
typeof endpoint.token === "string" &&
|
|
266
|
+
endpoint.token.length > 0 &&
|
|
267
|
+
typeof endpoint.updatedAt === "number"
|
|
268
|
+
);
|
|
269
|
+
}
|
package/src/reader.ts
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import type { Asset, BoardNode, BoardSnapshot, BoardViewportSnapshot } from "@canvas/board-domain";
|
|
2
|
+
import { promises as fs } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { BoardToolUserError, errorMessage } from "./errors";
|
|
5
|
+
import { resolveProjectRoot } from "./projects";
|
|
6
|
+
|
|
7
|
+
type BoardFile = {
|
|
8
|
+
schemaVersion?: number;
|
|
9
|
+
updatedAt?: number;
|
|
10
|
+
viewport?: BoardViewportSnapshot | null;
|
|
11
|
+
nodes?: BoardNode[];
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
type AssetFile = {
|
|
15
|
+
schemaVersion?: number;
|
|
16
|
+
updatedAt?: number;
|
|
17
|
+
assets?: Asset[];
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type ProjectFiles = {
|
|
21
|
+
root: string;
|
|
22
|
+
boardPath: string;
|
|
23
|
+
assetsPath: string;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export type LoadedProject = ProjectFiles & {
|
|
27
|
+
boardUpdatedAt: number | null;
|
|
28
|
+
assetsUpdatedAt: number | null;
|
|
29
|
+
snapshot: BoardSnapshot;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export async function resolveProjectFiles(projectRoot: unknown): Promise<ProjectFiles> {
|
|
33
|
+
const root = await resolveProjectRoot(projectRoot);
|
|
34
|
+
const boardPath = path.join(root, "board.json");
|
|
35
|
+
const assetsPath = path.join(root, "assets.json");
|
|
36
|
+
await assertReadableFile(boardPath, "board.json");
|
|
37
|
+
await assertReadableFile(assetsPath, "assets.json");
|
|
38
|
+
return { root, boardPath, assetsPath };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function loadProject(projectRoot: unknown): Promise<LoadedProject> {
|
|
42
|
+
const files = await resolveProjectFiles(projectRoot);
|
|
43
|
+
const [boardResult, assetsResult] = await Promise.all([
|
|
44
|
+
readJsonFile(files.boardPath, "board.json"),
|
|
45
|
+
readJsonFile(files.assetsPath, "assets.json"),
|
|
46
|
+
]);
|
|
47
|
+
|
|
48
|
+
const board = parseBoardFile(boardResult.value, files.boardPath);
|
|
49
|
+
const assets = parseAssetFile(assetsResult.value, files.assetsPath);
|
|
50
|
+
return {
|
|
51
|
+
...files,
|
|
52
|
+
boardUpdatedAt: board.updatedAt ?? boardResult.mtimeMs,
|
|
53
|
+
assetsUpdatedAt: assets.updatedAt ?? assetsResult.mtimeMs,
|
|
54
|
+
snapshot: {
|
|
55
|
+
nodes: board.nodes,
|
|
56
|
+
assets: assets.assets,
|
|
57
|
+
viewport: board.viewport ?? null,
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function getNodeOrThrow(snapshot: BoardSnapshot, nodeId: unknown): BoardNode {
|
|
63
|
+
if (typeof nodeId !== "string" || nodeId.trim() === "") {
|
|
64
|
+
throw new BoardToolUserError("nodeId must be a non-empty string");
|
|
65
|
+
}
|
|
66
|
+
const node = snapshot.nodes.find((entry) => entry.id === nodeId);
|
|
67
|
+
if (!node) {
|
|
68
|
+
throw new BoardToolUserError(`Node not found: ${nodeId}`);
|
|
69
|
+
}
|
|
70
|
+
return node;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function getAssetOrThrow(snapshot: BoardSnapshot, assetId: unknown): Asset {
|
|
74
|
+
if (typeof assetId !== "string" || assetId.trim() === "") {
|
|
75
|
+
throw new BoardToolUserError("assetId must be a non-empty string");
|
|
76
|
+
}
|
|
77
|
+
const asset = snapshot.assets.find((entry) => entry.id === assetId);
|
|
78
|
+
if (!asset) {
|
|
79
|
+
throw new BoardToolUserError(`Asset not found: ${assetId}`);
|
|
80
|
+
}
|
|
81
|
+
return asset;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function getOriginAssetForNode(snapshot: BoardSnapshot, nodeId: unknown): Asset {
|
|
85
|
+
const node = getNodeOrThrow(snapshot, nodeId);
|
|
86
|
+
const asset = getAssetOrThrow(snapshot, node.assetId);
|
|
87
|
+
if (!asset.localPath) {
|
|
88
|
+
throw new BoardToolUserError(`Asset has no original file: ${asset.id}`);
|
|
89
|
+
}
|
|
90
|
+
return asset;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function assertReadableFile(filePath: string, label: string): Promise<void> {
|
|
94
|
+
try {
|
|
95
|
+
const stat = await fs.stat(filePath);
|
|
96
|
+
if (!stat.isFile()) {
|
|
97
|
+
throw new BoardToolUserError(`${label} is not a file: ${filePath}`);
|
|
98
|
+
}
|
|
99
|
+
} catch (error) {
|
|
100
|
+
if (error instanceof BoardToolUserError) throw error;
|
|
101
|
+
throw new BoardToolUserError(`Cannot read ${label} at ${filePath}: ${errorMessage(error)}`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function readJsonFile(filePath: string, label: string): Promise<{ value: unknown; mtimeMs: number }> {
|
|
106
|
+
let text: string;
|
|
107
|
+
let mtimeMs = 0;
|
|
108
|
+
try {
|
|
109
|
+
const [content, stat] = await Promise.all([fs.readFile(filePath, "utf8"), fs.stat(filePath)]);
|
|
110
|
+
text = content;
|
|
111
|
+
mtimeMs = Math.trunc(stat.mtimeMs);
|
|
112
|
+
} catch (error) {
|
|
113
|
+
throw new BoardToolUserError(`Failed to read ${label} at ${filePath}: ${errorMessage(error)}`);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
try {
|
|
117
|
+
return { value: JSON.parse(text), mtimeMs };
|
|
118
|
+
} catch (error) {
|
|
119
|
+
throw new BoardToolUserError(
|
|
120
|
+
`Failed to parse ${label} at ${filePath}: ${errorMessage(error)}. The file may be mid-write; retry the read.`,
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function parseBoardFile(value: unknown, filePath: string): Required<Pick<BoardFile, "nodes">> & BoardFile {
|
|
126
|
+
if (!isRecord(value)) {
|
|
127
|
+
throw new BoardToolUserError(`Invalid board.json at ${filePath}: expected an object`);
|
|
128
|
+
}
|
|
129
|
+
if (!Array.isArray(value.nodes)) {
|
|
130
|
+
throw new BoardToolUserError(`Invalid board.json at ${filePath}: missing nodes array`);
|
|
131
|
+
}
|
|
132
|
+
return {
|
|
133
|
+
schemaVersion: numberOrUndefined(value.schemaVersion),
|
|
134
|
+
updatedAt: numberOrUndefined(value.updatedAt),
|
|
135
|
+
viewport: parseViewport(value.viewport),
|
|
136
|
+
nodes: value.nodes as BoardNode[],
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function parseAssetFile(value: unknown, filePath: string): Required<Pick<AssetFile, "assets">> & AssetFile {
|
|
141
|
+
if (!isRecord(value)) {
|
|
142
|
+
throw new BoardToolUserError(`Invalid assets.json at ${filePath}: expected an object`);
|
|
143
|
+
}
|
|
144
|
+
if (!Array.isArray(value.assets)) {
|
|
145
|
+
throw new BoardToolUserError(`Invalid assets.json at ${filePath}: missing assets array`);
|
|
146
|
+
}
|
|
147
|
+
return {
|
|
148
|
+
schemaVersion: numberOrUndefined(value.schemaVersion),
|
|
149
|
+
updatedAt: numberOrUndefined(value.updatedAt),
|
|
150
|
+
assets: value.assets as Asset[],
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function parseViewport(value: unknown): BoardViewportSnapshot | null {
|
|
155
|
+
if (value === null || value === undefined) return null;
|
|
156
|
+
if (!isRecord(value) || typeof value.scale !== "number" || !isRecord(value.offset)) {
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
if (typeof value.offset.x !== "number" || typeof value.offset.y !== "number") {
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
return {
|
|
163
|
+
scale: value.scale,
|
|
164
|
+
offset: {
|
|
165
|
+
x: value.offset.x,
|
|
166
|
+
y: value.offset.y,
|
|
167
|
+
},
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function numberOrUndefined(value: unknown): number | undefined {
|
|
172
|
+
return typeof value === "number" ? value : undefined;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
176
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
177
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import type { McpWriteCommand, McpWriteCommandResult } from "@canvas/board-domain";
|
|
2
|
+
import { promises as fs } from "node:fs";
|
|
3
|
+
import net from "node:net";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { BoardToolUserError, errorMessage } from "./errors";
|
|
6
|
+
import { resolveProjectFiles } from "./reader";
|
|
7
|
+
|
|
8
|
+
type BridgeEndpoint = {
|
|
9
|
+
version: 1;
|
|
10
|
+
host: string;
|
|
11
|
+
port: number;
|
|
12
|
+
token: string;
|
|
13
|
+
updatedAt: number;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
type BridgeResponse =
|
|
17
|
+
| { ok: true; data: McpWriteCommandResult }
|
|
18
|
+
| { ok: false; error: string };
|
|
19
|
+
|
|
20
|
+
const BRIDGE_FILE = ".canvas-mcp-bridge.json";
|
|
21
|
+
const CONNECT_TIMEOUT_MS = 1_500;
|
|
22
|
+
const RESPONSE_TIMEOUT_MS = 30_000;
|
|
23
|
+
|
|
24
|
+
export async function sendWriteCommand(command: McpWriteCommand): Promise<McpWriteCommandResult> {
|
|
25
|
+
const files = await resolveProjectFiles(command.projectRoot);
|
|
26
|
+
const endpoint = await readBridgeEndpoint(files.root);
|
|
27
|
+
const response = await sendJsonLine(endpoint, {
|
|
28
|
+
token: endpoint.token,
|
|
29
|
+
request: {
|
|
30
|
+
...command,
|
|
31
|
+
projectRoot: files.root,
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
if (!response.ok) {
|
|
35
|
+
throw new BoardToolUserError(response.error);
|
|
36
|
+
}
|
|
37
|
+
return response.data;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function readBridgeEndpoint(projectRoot: string): Promise<BridgeEndpoint> {
|
|
41
|
+
const bridgePath = path.join(projectRoot, BRIDGE_FILE);
|
|
42
|
+
let value: unknown;
|
|
43
|
+
try {
|
|
44
|
+
value = JSON.parse(await fs.readFile(bridgePath, "utf8"));
|
|
45
|
+
} catch (error) {
|
|
46
|
+
throw new BoardToolUserError(
|
|
47
|
+
`Desktop app is not available for writes: cannot read ${BRIDGE_FILE} in ${projectRoot}. Start the desktop app and open this project. Details: ${errorMessage(error)}`,
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
if (!isEndpoint(value)) {
|
|
51
|
+
throw new BoardToolUserError(`Desktop app bridge file is invalid: ${bridgePath}`);
|
|
52
|
+
}
|
|
53
|
+
return value;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function sendJsonLine(endpoint: BridgeEndpoint, payload: unknown): Promise<BridgeResponse> {
|
|
57
|
+
return new Promise((resolve, reject) => {
|
|
58
|
+
const socket = net.createConnection({ host: endpoint.host, port: endpoint.port });
|
|
59
|
+
let buffer = "";
|
|
60
|
+
let settled = false;
|
|
61
|
+
const connectTimer = setTimeout(() => {
|
|
62
|
+
socket.destroy();
|
|
63
|
+
reject(new BoardToolUserError("Desktop app is not available for writes: bridge connection timed out"));
|
|
64
|
+
}, CONNECT_TIMEOUT_MS);
|
|
65
|
+
const responseTimer = setTimeout(() => {
|
|
66
|
+
socket.destroy();
|
|
67
|
+
reject(new BoardToolUserError("Desktop app did not finish the write command before the timeout"));
|
|
68
|
+
}, RESPONSE_TIMEOUT_MS);
|
|
69
|
+
|
|
70
|
+
const settle = (fn: () => void) => {
|
|
71
|
+
if (settled) return;
|
|
72
|
+
settled = true;
|
|
73
|
+
clearTimeout(connectTimer);
|
|
74
|
+
clearTimeout(responseTimer);
|
|
75
|
+
fn();
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
socket.setEncoding("utf8");
|
|
79
|
+
socket.on("connect", () => {
|
|
80
|
+
clearTimeout(connectTimer);
|
|
81
|
+
socket.write(`${JSON.stringify(payload)}\n`);
|
|
82
|
+
});
|
|
83
|
+
socket.on("data", (chunk) => {
|
|
84
|
+
buffer += chunk;
|
|
85
|
+
const newline = buffer.indexOf("\n");
|
|
86
|
+
if (newline < 0) return;
|
|
87
|
+
const line = buffer.slice(0, newline);
|
|
88
|
+
settle(() => {
|
|
89
|
+
socket.end();
|
|
90
|
+
try {
|
|
91
|
+
const parsed = JSON.parse(line);
|
|
92
|
+
if (isBridgeResponse(parsed)) {
|
|
93
|
+
resolve(parsed);
|
|
94
|
+
} else {
|
|
95
|
+
reject(new BoardToolUserError("Desktop app returned an invalid bridge response"));
|
|
96
|
+
}
|
|
97
|
+
} catch (error) {
|
|
98
|
+
reject(new BoardToolUserError(`Desktop app returned invalid JSON: ${errorMessage(error)}`));
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
socket.on("error", (error) => {
|
|
103
|
+
settle(() => {
|
|
104
|
+
reject(
|
|
105
|
+
new BoardToolUserError(
|
|
106
|
+
`Desktop app is not available for writes: failed to connect to bridge ${endpoint.host}:${endpoint.port}. Details: ${error.message}`,
|
|
107
|
+
),
|
|
108
|
+
);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
socket.on("close", () => {
|
|
112
|
+
if (settled) return;
|
|
113
|
+
settle(() => {
|
|
114
|
+
reject(new BoardToolUserError("Desktop app bridge closed before returning a write result"));
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function isEndpoint(value: unknown): value is BridgeEndpoint {
|
|
121
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
122
|
+
const endpoint = value as Partial<BridgeEndpoint>;
|
|
123
|
+
return (
|
|
124
|
+
endpoint.version === 1 &&
|
|
125
|
+
endpoint.host === "127.0.0.1" &&
|
|
126
|
+
typeof endpoint.port === "number" &&
|
|
127
|
+
Number.isInteger(endpoint.port) &&
|
|
128
|
+
endpoint.port > 0 &&
|
|
129
|
+
typeof endpoint.token === "string" &&
|
|
130
|
+
endpoint.token.length > 0 &&
|
|
131
|
+
typeof endpoint.updatedAt === "number"
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function isBridgeResponse(value: unknown): value is BridgeResponse {
|
|
136
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
137
|
+
const response = value as Partial<BridgeResponse>;
|
|
138
|
+
if (response.ok === true) return typeof response.data === "object" && response.data !== null;
|
|
139
|
+
if (response.ok === false) return typeof response.error === "string";
|
|
140
|
+
return false;
|
|
141
|
+
}
|