pmx-canvas 0.1.27 → 0.1.29
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/CHANGELOG.md +68 -0
- package/dist/canvas/index.js +49 -49
- package/dist/json-render/index.css +1 -1
- package/dist/json-render/index.js +108 -108
- package/dist/types/json-render/charts/components.d.ts +10 -0
- package/dist/types/json-render/directives.d.ts +11 -1
- package/dist/types/server/canvas-operations.d.ts +8 -0
- package/dist/types/server/index.d.ts +12 -1
- package/docs/sdk.md +4 -4
- package/package.json +1 -1
- package/src/cli/agent.ts +35 -1
- package/src/client/nodes/ExtAppFrame.tsx +25 -0
- package/src/client/nodes/LedgerNode.tsx +39 -5
- package/src/json-render/charts/components.tsx +31 -6
- package/src/json-render/charts/tufte-components.tsx +17 -5
- package/src/json-render/directives.ts +37 -2
- package/src/json-render/renderer/index.css +6 -1
- package/src/json-render/server.ts +37 -2
- package/src/mcp/canvas-access.ts +20 -2
- package/src/mcp/server.ts +8 -5
- package/src/server/canvas-operations.ts +16 -2
- package/src/server/canvas-schema.ts +1 -0
- package/src/server/index.ts +32 -6
- package/src/server/server.ts +23 -3
- package/src/server/web-artifacts.ts +31 -5
|
@@ -540,6 +540,7 @@ export function describeCanvasSchema(): {
|
|
|
540
540
|
},
|
|
541
541
|
components: clone(describeJsonRenderCatalog()),
|
|
542
542
|
directives: [
|
|
543
|
+
{ name: '$state', usage: '{ "$state": "/path/to/value" } — read a value from the state model by path (one-way). Use this to bind a value by path; there is no $path directive.' },
|
|
543
544
|
{ name: '$format', usage: '{ "$format": "currency"|"number"|"percent"|"date", "value": <num|state-ref>, "currency"?: "USD", "locale"?, "style"?, "options"? } — Intl-formatted string' },
|
|
544
545
|
{ name: '$math', usage: '{ "$math": "add"|"subtract"|"multiply"|"divide"|"mod"|"min"|"max"|"round"|"floor"|"ceil"|"abs", "a": <num>, "b"?: <num> }' },
|
|
545
546
|
{ name: '$concat', usage: '{ "$concat": [<value>, <value>, ...] } — join values into one string' },
|
package/src/server/index.ts
CHANGED
|
@@ -36,6 +36,8 @@ import {
|
|
|
36
36
|
createCanvasStreamingJsonRenderNode,
|
|
37
37
|
MARKDOWN_NODE_DEFAULT_SIZE,
|
|
38
38
|
MCP_APP_NODE_DEFAULT_SIZE,
|
|
39
|
+
IMAGE_NODE_DEFAULT_SIZE,
|
|
40
|
+
LEDGER_NODE_DEFAULT_SIZE,
|
|
39
41
|
applyCanvasNodeUpdates,
|
|
40
42
|
arrangeCanvasNodes,
|
|
41
43
|
clearCanvas,
|
|
@@ -128,8 +130,14 @@ export class PmxCanvas extends EventEmitter {
|
|
|
128
130
|
async start(options?: {
|
|
129
131
|
open?: boolean;
|
|
130
132
|
automationWebView?: boolean | CanvasAutomationWebViewOptions;
|
|
133
|
+
/**
|
|
134
|
+
* Bind a nearby free port when the preferred one is taken instead of
|
|
135
|
+
* failing. Default false (an explicit SDK port is honored exactly); the
|
|
136
|
+
* MCP auto-start opts in so a daemon already on the port can't crash it.
|
|
137
|
+
*/
|
|
138
|
+
allowPortFallback?: boolean;
|
|
131
139
|
}): Promise<void> {
|
|
132
|
-
const base = startCanvasServer({ port: this._port, allowPortFallback: false });
|
|
140
|
+
const base = startCanvasServer({ port: this._port, allowPortFallback: options?.allowPortFallback ?? false });
|
|
133
141
|
if (!base) {
|
|
134
142
|
throw new Error(`Failed to start canvas server on port ${this._port}`);
|
|
135
143
|
}
|
|
@@ -186,6 +194,11 @@ export class PmxCanvas extends EventEmitter {
|
|
|
186
194
|
this._server = null;
|
|
187
195
|
}
|
|
188
196
|
|
|
197
|
+
/**
|
|
198
|
+
* Add a node to the canvas and return the created node (including its `id`,
|
|
199
|
+
* resolved geometry, and data). Destructure `const { id } = canvas.addNode(...)`
|
|
200
|
+
* or keep the whole node — both work. (Previously returned a bare id string.)
|
|
201
|
+
*/
|
|
189
202
|
addNode(input: {
|
|
190
203
|
type: CanvasNodeState['type'];
|
|
191
204
|
title?: string;
|
|
@@ -205,12 +218,12 @@ export class PmxCanvas extends EventEmitter {
|
|
|
205
218
|
width?: number;
|
|
206
219
|
height?: number;
|
|
207
220
|
strictSize?: boolean;
|
|
208
|
-
}):
|
|
221
|
+
}): CanvasNodeState {
|
|
209
222
|
if (input.type === 'webpage') {
|
|
210
223
|
throw new Error('Use addWebpageNode for webpage nodes so page content is fetched and cached on the server.');
|
|
211
224
|
}
|
|
212
225
|
if (input.type === 'group') {
|
|
213
|
-
|
|
226
|
+
const groupId = this.createGroup({
|
|
214
227
|
...(typeof input.title === 'string' ? { title: input.title } : {}),
|
|
215
228
|
childIds: input.childIds ?? input.children ?? [],
|
|
216
229
|
...(typeof input.x === 'number' ? { x: input.x } : {}),
|
|
@@ -220,6 +233,9 @@ export class PmxCanvas extends EventEmitter {
|
|
|
220
233
|
...(typeof input.color === 'string' ? { color: input.color } : {}),
|
|
221
234
|
...(input.childLayout ? { childLayout: input.childLayout } : {}),
|
|
222
235
|
});
|
|
236
|
+
const groupNode = canvasState.getNode(groupId);
|
|
237
|
+
if (!groupNode) throw new Error(`Group node "${groupId}" was not created.`);
|
|
238
|
+
return groupNode;
|
|
223
239
|
}
|
|
224
240
|
const { id, needsCodeGraphRecompute } = addCanvasNode({
|
|
225
241
|
...input,
|
|
@@ -227,12 +243,20 @@ export class PmxCanvas extends EventEmitter {
|
|
|
227
243
|
? MARKDOWN_NODE_DEFAULT_SIZE.width
|
|
228
244
|
: input.type === 'mcp-app'
|
|
229
245
|
? MCP_APP_NODE_DEFAULT_SIZE.width
|
|
230
|
-
:
|
|
246
|
+
: input.type === 'image'
|
|
247
|
+
? IMAGE_NODE_DEFAULT_SIZE.width
|
|
248
|
+
: input.type === 'ledger'
|
|
249
|
+
? LEDGER_NODE_DEFAULT_SIZE.width
|
|
250
|
+
: 360,
|
|
231
251
|
defaultHeight: input.type === 'markdown'
|
|
232
252
|
? MARKDOWN_NODE_DEFAULT_SIZE.height
|
|
233
253
|
: input.type === 'mcp-app'
|
|
234
254
|
? MCP_APP_NODE_DEFAULT_SIZE.height
|
|
235
|
-
:
|
|
255
|
+
: input.type === 'image'
|
|
256
|
+
? IMAGE_NODE_DEFAULT_SIZE.height
|
|
257
|
+
: input.type === 'ledger'
|
|
258
|
+
? LEDGER_NODE_DEFAULT_SIZE.height
|
|
259
|
+
: 200,
|
|
236
260
|
fileMode: 'path',
|
|
237
261
|
...(input.strictSize ? { strictSize: true } : {}),
|
|
238
262
|
});
|
|
@@ -245,7 +269,9 @@ export class PmxCanvas extends EventEmitter {
|
|
|
245
269
|
});
|
|
246
270
|
}
|
|
247
271
|
|
|
248
|
-
|
|
272
|
+
const node = canvasState.getNode(id);
|
|
273
|
+
if (!node) throw new Error(`Node "${id}" was not created.`);
|
|
274
|
+
return node;
|
|
249
275
|
}
|
|
250
276
|
|
|
251
277
|
async addWebpageNode(input: {
|
package/src/server/server.ts
CHANGED
|
@@ -93,6 +93,8 @@ import {
|
|
|
93
93
|
addCanvasEdge,
|
|
94
94
|
MARKDOWN_NODE_DEFAULT_SIZE,
|
|
95
95
|
MCP_APP_NODE_DEFAULT_SIZE,
|
|
96
|
+
IMAGE_NODE_DEFAULT_SIZE,
|
|
97
|
+
LEDGER_NODE_DEFAULT_SIZE,
|
|
96
98
|
applyCanvasNodeUpdates,
|
|
97
99
|
appendCanvasJsonRenderStream,
|
|
98
100
|
buildStructuredNodeUpdate,
|
|
@@ -1506,7 +1508,13 @@ async function handleCanvasImage(pathname: string): Promise<Response> {
|
|
|
1506
1508
|
if (!src || src.startsWith('data:') || src.startsWith('http')) {
|
|
1507
1509
|
return responseText('Not a file-based image', 400);
|
|
1508
1510
|
}
|
|
1509
|
-
|
|
1511
|
+
// Contain the file read to the active workspace. `src` comes from node data,
|
|
1512
|
+
// which any unauthenticated local caller can set — without this guard the
|
|
1513
|
+
// image route serves arbitrary host files (e.g. ../../etc/passwd).
|
|
1514
|
+
const safePath = resolveWorkspaceArtifactPath(src);
|
|
1515
|
+
if (!safePath) {
|
|
1516
|
+
return responseText('Image path is outside the workspace', 403);
|
|
1517
|
+
}
|
|
1510
1518
|
if (!existsSync(safePath)) {
|
|
1511
1519
|
return responseText('Image file not found', 404);
|
|
1512
1520
|
}
|
|
@@ -1699,14 +1707,22 @@ async function handleCanvasAddNode(req: Request): Promise<Response> {
|
|
|
1699
1707
|
? MARKDOWN_NODE_DEFAULT_SIZE.width
|
|
1700
1708
|
: type === 'mcp-app'
|
|
1701
1709
|
? MCP_APP_NODE_DEFAULT_SIZE.width
|
|
1702
|
-
:
|
|
1710
|
+
: type === 'image'
|
|
1711
|
+
? IMAGE_NODE_DEFAULT_SIZE.width
|
|
1712
|
+
: type === 'ledger'
|
|
1713
|
+
? LEDGER_NODE_DEFAULT_SIZE.width
|
|
1714
|
+
: 360,
|
|
1703
1715
|
defaultHeight: type === 'html'
|
|
1704
1716
|
? 640
|
|
1705
1717
|
: type === 'markdown'
|
|
1706
1718
|
? MARKDOWN_NODE_DEFAULT_SIZE.height
|
|
1707
1719
|
: type === 'mcp-app'
|
|
1708
1720
|
? MCP_APP_NODE_DEFAULT_SIZE.height
|
|
1709
|
-
:
|
|
1721
|
+
: type === 'image'
|
|
1722
|
+
? IMAGE_NODE_DEFAULT_SIZE.height
|
|
1723
|
+
: type === 'ledger'
|
|
1724
|
+
? LEDGER_NODE_DEFAULT_SIZE.height
|
|
1725
|
+
: 200,
|
|
1710
1726
|
fileMode: 'auto',
|
|
1711
1727
|
});
|
|
1712
1728
|
} catch (error) {
|
|
@@ -2072,6 +2088,10 @@ async function handleCanvasBuildWebArtifact(req: Request): Promise<Response> {
|
|
|
2072
2088
|
...(typeof body.outputPath === 'string'
|
|
2073
2089
|
? { outputPath: resolveWorkspacePath(body.outputPath, activeWorkspaceRoot) }
|
|
2074
2090
|
: {}),
|
|
2091
|
+
// Script-path overrides are honored only when contained inside the
|
|
2092
|
+
// workspace (enforced by resolveTrustedScriptPath in
|
|
2093
|
+
// executeWebArtifactBuild), so they cannot point at an arbitrary host
|
|
2094
|
+
// script for bash execution.
|
|
2075
2095
|
...(typeof body.initScriptPath === 'string'
|
|
2076
2096
|
? { initScriptPath: body.initScriptPath }
|
|
2077
2097
|
: {}),
|
|
@@ -5,11 +5,12 @@ import {
|
|
|
5
5
|
readdirSync,
|
|
6
6
|
mkdirSync,
|
|
7
7
|
readFileSync,
|
|
8
|
+
realpathSync,
|
|
8
9
|
statSync,
|
|
9
10
|
unlinkSync,
|
|
10
11
|
writeFileSync,
|
|
11
12
|
} from 'node:fs';
|
|
12
|
-
import { basename, delimiter, dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
13
|
+
import { basename, delimiter, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
13
14
|
import { ensureArtifactsDir, getWorkspaceRoot } from './artifact-paths.js';
|
|
14
15
|
import { canvasState, type CanvasNodeState } from './canvas-state.js';
|
|
15
16
|
import { findOpenCanvasPosition } from './placement.js';
|
|
@@ -338,6 +339,33 @@ async function runProcess(
|
|
|
338
339
|
return { stdout: stdout.trim(), stderr: stderr.trim() };
|
|
339
340
|
}
|
|
340
341
|
|
|
342
|
+
/**
|
|
343
|
+
* Resolve the init/bundle script path. Without an override the trusted bundled
|
|
344
|
+
* resolver is used. An override (a test/debugging escape hatch exposed on every
|
|
345
|
+
* surface — CLI --init-script-path/--bundle-script-path, the MCP tool, the HTTP
|
|
346
|
+
* endpoint, and the SDK) is only honored when it resolves inside the active
|
|
347
|
+
* workspace root — otherwise it would let a caller exec an arbitrary host script
|
|
348
|
+
* via bash as the server user. This containment is the single chokepoint that
|
|
349
|
+
* makes forwarding the field safe across all of those surfaces.
|
|
350
|
+
*/
|
|
351
|
+
function resolveTrustedScriptPath(override: string | undefined, kind: 'init' | 'bundle'): string {
|
|
352
|
+
if (!override) return resolveWebArtifactScriptPath(kind);
|
|
353
|
+
const resolved = resolve(override);
|
|
354
|
+
// Compare real (symlink-resolved) paths on both sides so a legitimately
|
|
355
|
+
// contained override under a symlinked root (e.g. macOS /var -> /private/var,
|
|
356
|
+
// or a workspace that itself lives beneath a symlink) is not wrongly rejected.
|
|
357
|
+
// The candidate is realpath'd only when it exists; a non-existent escape path
|
|
358
|
+
// still fails the containment check below (or the existsSync guard at the call
|
|
359
|
+
// site). This keeps the guard strict — genuine escapes are still rejected.
|
|
360
|
+
const realRoot = realpathSync(currentWorkspaceRoot());
|
|
361
|
+
const realCandidate = existsSync(resolved) ? realpathSync(resolved) : resolved;
|
|
362
|
+
const workspaceRel = relative(realRoot, realCandidate);
|
|
363
|
+
if (workspaceRel === '..' || workspaceRel.startsWith(`..${sep}`) || isAbsolute(workspaceRel)) {
|
|
364
|
+
throw new Error(`Web-artifact ${kind} script override must resolve inside the workspace: ${override}`);
|
|
365
|
+
}
|
|
366
|
+
return resolved;
|
|
367
|
+
}
|
|
368
|
+
|
|
341
369
|
export function resolveWebArtifactScriptPath(kind: 'init' | 'bundle'): string {
|
|
342
370
|
const scriptFile = kind === 'init' ? 'init-artifact.sh' : 'bundle-artifact.sh';
|
|
343
371
|
const candidates = [
|
|
@@ -459,10 +487,8 @@ export async function executeWebArtifactBuild(
|
|
|
459
487
|
const slug = slugify(input.title);
|
|
460
488
|
const projectPath = resolve(input.projectPath ?? join(artifactsDir, '.web-artifacts', slug));
|
|
461
489
|
const outputPath = resolve(input.outputPath ?? join(artifactsDir, `${slug}.html`));
|
|
462
|
-
const initScriptPath =
|
|
463
|
-
const bundleScriptPath =
|
|
464
|
-
input.bundleScriptPath ?? resolveWebArtifactScriptPath('bundle'),
|
|
465
|
-
);
|
|
490
|
+
const initScriptPath = resolveTrustedScriptPath(input.initScriptPath, 'init');
|
|
491
|
+
const bundleScriptPath = resolveTrustedScriptPath(input.bundleScriptPath, 'bundle');
|
|
466
492
|
const timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
467
493
|
|
|
468
494
|
if (!existsSync(initScriptPath)) {
|