@neta-art/cohub-cli 3.11.0 → 4.0.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/board-command-support.d.ts +7 -0
- package/dist/board-command-support.js +99 -0
- package/dist/board-export.js +28 -22
- package/dist/commands/board-domain.d.ts +2 -0
- package/dist/commands/board-domain.js +10 -0
- package/dist/commands/boards/animation.d.ts +2 -0
- package/dist/commands/boards/animation.js +145 -0
- package/dist/commands/boards/appearance.d.ts +2 -0
- package/dist/commands/boards/appearance.js +92 -0
- package/dist/commands/boards/context.d.ts +12 -0
- package/dist/commands/boards/context.js +25 -0
- package/dist/commands/boards/items.d.ts +2 -0
- package/dist/commands/boards/items.js +144 -0
- package/dist/commands/boards/nodes.d.ts +3 -0
- package/dist/commands/boards/nodes.js +53 -0
- package/dist/commands/boards.d.ts +4 -3
- package/dist/commands/boards.js +135 -68
- package/dist/commands/works.js +2 -2
- package/dist/safe-remote-image.d.ts +24 -0
- package/dist/safe-remote-image.js +160 -0
- package/package.json +2 -2
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { createBoardConnection } from "@neta-art/cohub/board";
|
|
3
|
+
import { handleHttp } from "../../output.js";
|
|
4
|
+
import { resolvedBoard, showUpdated, withJson, } from "./context.js";
|
|
5
|
+
/** Connection shortcuts; Item authoring lives in ./items.ts. */
|
|
6
|
+
export function registerBoardNodeCommands(boards) {
|
|
7
|
+
withJson(boards.command("connect <board> <source> <target>")
|
|
8
|
+
.description("Connect two Board items")
|
|
9
|
+
.option("--id <id>", "Connection id")
|
|
10
|
+
.option("--relation <relation>", "Relation type")
|
|
11
|
+
.option("--direction <direction>", "none, forward, backward, or both", "forward")
|
|
12
|
+
.option("--label <label>", "Connection label")
|
|
13
|
+
.option("--source-port <id>", "Source port id")
|
|
14
|
+
.option("--target-port <id>", "Target port id"))
|
|
15
|
+
.action(async (target, source, destination, options) => {
|
|
16
|
+
try {
|
|
17
|
+
const direction = options.direction ?? "forward";
|
|
18
|
+
if (!["none", "forward", "backward", "both"].includes(direction)) {
|
|
19
|
+
throw new Error("--direction must be none, forward, backward, or both");
|
|
20
|
+
}
|
|
21
|
+
const board = await resolvedBoard(boards, target);
|
|
22
|
+
const connection = createBoardConnection({
|
|
23
|
+
id: options.id ?? randomUUID(),
|
|
24
|
+
sourceNodeId: source,
|
|
25
|
+
targetNodeId: destination,
|
|
26
|
+
relation: options.relation,
|
|
27
|
+
direction: direction,
|
|
28
|
+
label: options.label,
|
|
29
|
+
sourcePortId: options.sourcePort,
|
|
30
|
+
targetPortId: options.targetPort,
|
|
31
|
+
});
|
|
32
|
+
showUpdated(await board.mutate({
|
|
33
|
+
build: () => [{ type: "connection.create", payload: { connection } }],
|
|
34
|
+
}), options);
|
|
35
|
+
}
|
|
36
|
+
catch (cause) {
|
|
37
|
+
handleHttp(cause);
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
withJson(boards.command("disconnect <board> <connection-id>")
|
|
41
|
+
.description("Remove a Board connection"))
|
|
42
|
+
.action(async (target, connectionId, options) => {
|
|
43
|
+
try {
|
|
44
|
+
const board = await resolvedBoard(boards, target);
|
|
45
|
+
showUpdated(await board.mutate({
|
|
46
|
+
build: () => [{ type: "connection.delete", payload: { connectionId } }],
|
|
47
|
+
}), options);
|
|
48
|
+
}
|
|
49
|
+
catch (cause) {
|
|
50
|
+
handleHttp(cause);
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
}
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import type { BoardInspectInput, BoardTransactionInput } from "@neta-art/cohub";
|
|
2
2
|
import type { Command } from "commander";
|
|
3
|
-
|
|
3
|
+
import { parseBoardJsonObject } from "../board-command-support.js";
|
|
4
|
+
declare const INSPECT_SECTIONS: readonly ["nodes", "connections", "effects", "compositions", "playback"];
|
|
4
5
|
type InspectSection = (typeof INSPECT_SECTIONS)[number];
|
|
5
|
-
export declare
|
|
6
|
-
export declare function readJsonObject(source: string): Promise<Record<string, unknown>>;
|
|
6
|
+
export declare const parseJsonObject: typeof parseBoardJsonObject;
|
|
7
|
+
export declare function readJsonObject(source: string, maxBytes?: number): Promise<Record<string, unknown>>;
|
|
7
8
|
export declare function parseInspectSections(value?: string): InspectSection[] | undefined;
|
|
8
9
|
export declare function parseViewport(value?: string): BoardInspectInput["viewport"];
|
|
9
10
|
export declare function createTransactionInput(input: Record<string, unknown>, options: {
|
package/dist/commands/boards.js
CHANGED
|
@@ -1,36 +1,14 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
import {
|
|
2
|
+
import { BOARD_CREATE_INPUT_MAX_BYTES, BOARD_TRANSACTION_INPUT_MAX_BYTES, parseBoardJsonObject, readBoardJsonObject, resolveBoardId, writeBoardOutput, } from "../board-command-support.js";
|
|
3
3
|
import { BOARD_EXPORT_FORMATS, formatFromPath, runBoardExport } from "../board-export.js";
|
|
4
|
+
import { registerBoardDomainCommands } from "./board-domain.js";
|
|
4
5
|
import { createClient, createRealtimeClient } from "../client.js";
|
|
5
6
|
import { error, handleHttp, json as outJson, jsonRequested, ok, table } from "../output.js";
|
|
6
7
|
import { resolveSpace } from "../space.js";
|
|
7
|
-
const INSPECT_SECTIONS = ["nodes", "connections", "effects", "
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
export function parseJsonObject(text, source = "input") {
|
|
12
|
-
if (!text.trim())
|
|
13
|
-
throw new Error(`${source} is empty`);
|
|
14
|
-
let value;
|
|
15
|
-
try {
|
|
16
|
-
value = JSON.parse(text);
|
|
17
|
-
}
|
|
18
|
-
catch (cause) {
|
|
19
|
-
throw new Error(`${source} must contain valid JSON`, { cause });
|
|
20
|
-
}
|
|
21
|
-
if (!isObject(value))
|
|
22
|
-
throw new Error(`${source} must contain a JSON object`);
|
|
23
|
-
return value;
|
|
24
|
-
}
|
|
25
|
-
async function readStdin() {
|
|
26
|
-
const chunks = [];
|
|
27
|
-
for await (const chunk of process.stdin)
|
|
28
|
-
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
29
|
-
return Buffer.concat(chunks).toString("utf8");
|
|
30
|
-
}
|
|
31
|
-
export async function readJsonObject(source) {
|
|
32
|
-
const text = source === "-" ? await readStdin() : await readFile(source, "utf8");
|
|
33
|
-
return parseJsonObject(text, source === "-" ? "stdin" : source);
|
|
8
|
+
const INSPECT_SECTIONS = ["nodes", "connections", "effects", "compositions", "playback"];
|
|
9
|
+
export const parseJsonObject = parseBoardJsonObject;
|
|
10
|
+
export async function readJsonObject(source, maxBytes = BOARD_TRANSACTION_INPUT_MAX_BYTES) {
|
|
11
|
+
return readBoardJsonObject(source, maxBytes);
|
|
34
12
|
}
|
|
35
13
|
function parseNumber(value, name, options = {}) {
|
|
36
14
|
if (!value.trim())
|
|
@@ -102,8 +80,7 @@ function showBoard(result) {
|
|
|
102
80
|
nodes: result.nodes.length,
|
|
103
81
|
connections: result.connections.length,
|
|
104
82
|
effects: result.effects.length,
|
|
105
|
-
|
|
106
|
-
clips: result.clips.length,
|
|
83
|
+
compositions: result.compositions.length,
|
|
107
84
|
},
|
|
108
85
|
], [
|
|
109
86
|
{ key: "id", label: "ID" },
|
|
@@ -112,8 +89,28 @@ function showBoard(result) {
|
|
|
112
89
|
{ key: "nodes", label: "Nodes" },
|
|
113
90
|
{ key: "connections", label: "Connections" },
|
|
114
91
|
{ key: "effects", label: "Effects" },
|
|
115
|
-
{ key: "
|
|
116
|
-
|
|
92
|
+
{ key: "compositions", label: "Compositions" },
|
|
93
|
+
]);
|
|
94
|
+
}
|
|
95
|
+
function showSummary(result) {
|
|
96
|
+
const background = result.board.metadata.appearance;
|
|
97
|
+
table([{
|
|
98
|
+
id: result.board.id,
|
|
99
|
+
title: result.board.title,
|
|
100
|
+
version: result.board.version,
|
|
101
|
+
...result.counts,
|
|
102
|
+
background: background?.background?.kind ?? "default",
|
|
103
|
+
updatedAt: result.board.updatedAt,
|
|
104
|
+
}], [
|
|
105
|
+
{ key: "id", label: "ID" },
|
|
106
|
+
{ key: "title", label: "TITLE" },
|
|
107
|
+
{ key: "version", label: "VERSION" },
|
|
108
|
+
{ key: "nodes", label: "NODES" },
|
|
109
|
+
{ key: "connections", label: "CONNECTIONS" },
|
|
110
|
+
{ key: "effects", label: "EFFECTS" },
|
|
111
|
+
{ key: "compositions", label: "COMPOSITIONS" },
|
|
112
|
+
{ key: "background", label: "BACKGROUND" },
|
|
113
|
+
{ key: "updatedAt", label: "UPDATED" },
|
|
117
114
|
]);
|
|
118
115
|
}
|
|
119
116
|
function showValidation(result) {
|
|
@@ -136,7 +133,7 @@ function showValidation(result) {
|
|
|
136
133
|
function showPlayback(result) {
|
|
137
134
|
table([result], [
|
|
138
135
|
{ key: "playbackId", label: "Playback ID" },
|
|
139
|
-
{ key: "
|
|
136
|
+
{ key: "compositionId", label: "Composition" },
|
|
140
137
|
{ key: "status", label: "Status" },
|
|
141
138
|
{ key: "position", label: "Position" },
|
|
142
139
|
{ key: "timeScale", label: "Time Scale" },
|
|
@@ -145,24 +142,45 @@ function showPlayback(result) {
|
|
|
145
142
|
function withJson(command) {
|
|
146
143
|
return command.option("--json", "Output as JSON");
|
|
147
144
|
}
|
|
145
|
+
function capabilityUnits(schema) {
|
|
146
|
+
const params = schema?.params;
|
|
147
|
+
if (!params || typeof params !== "object" || Array.isArray(params))
|
|
148
|
+
return "";
|
|
149
|
+
return Object.entries(params).flatMap(([field, value]) => {
|
|
150
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
151
|
+
return [];
|
|
152
|
+
const meta = value;
|
|
153
|
+
const detail = [meta.coordinateSpace, meta.unit]
|
|
154
|
+
.filter((item) => typeof item === "string")
|
|
155
|
+
.join("/");
|
|
156
|
+
return detail ? [`${field}:${detail}`] : [];
|
|
157
|
+
}).join(", ");
|
|
158
|
+
}
|
|
148
159
|
function registerTransactionCommand(boards, name) {
|
|
149
|
-
withJson(boards.command(`${name} <board
|
|
160
|
+
withJson(boards.command(`${name} <board>`)
|
|
150
161
|
.description(name === "validate" ? "Validate a transaction" : "Apply a transaction")
|
|
151
162
|
.requiredOption("-i, --input <file>", "Transaction JSON file; use - for stdin")
|
|
152
163
|
.option("--tx-id <id>", "Override txId; generated when omitted")
|
|
153
|
-
.option("--base-version <version>", "Override baseVersion")
|
|
154
|
-
.
|
|
164
|
+
.option("--base-version <version>", "Override baseVersion")
|
|
165
|
+
.addHelpText("after", `
|
|
166
|
+
Input example:
|
|
167
|
+
{"baseVersion":12,"operations":[{"type":"board.patch","payload":{"patch":{"title":"Launch plan"}}}]}
|
|
168
|
+
|
|
169
|
+
Prefer semantic commands such as boards background, nodes, effects, or compositions for common edits.`))
|
|
170
|
+
.action(async (target, options) => {
|
|
155
171
|
try {
|
|
156
|
-
const transaction = createTransactionInput(await readJsonObject(options.input), options);
|
|
157
|
-
const
|
|
172
|
+
const transaction = createTransactionInput(await readJsonObject(options.input, BOARD_TRANSACTION_INPUT_MAX_BYTES), options);
|
|
173
|
+
const spaceId = resolveSpace(boards);
|
|
174
|
+
const boardId = await resolveBoardId(spaceId, target);
|
|
175
|
+
const board = createClient().space(spaceId).board(boardId);
|
|
158
176
|
const result = await board[name](transaction);
|
|
159
177
|
if (jsonRequested(options))
|
|
160
178
|
return outJson(result);
|
|
161
179
|
if (name === "validate")
|
|
162
180
|
showValidation(result);
|
|
163
181
|
else {
|
|
164
|
-
|
|
165
|
-
|
|
182
|
+
const receipt = result;
|
|
183
|
+
ok(`Board updated to version ${receipt.board.version}`);
|
|
166
184
|
}
|
|
167
185
|
}
|
|
168
186
|
catch (cause) {
|
|
@@ -240,7 +258,8 @@ function registerExportCommand(boards) {
|
|
|
240
258
|
.option("--background <mode>", "paper or transparent", "paper")
|
|
241
259
|
.option("--format <format>", `Override format (${BOARD_EXPORT_FORMATS.join(", ")})`)
|
|
242
260
|
.option("--quality <q>", "JPEG/WebP quality from 0 to 1", "0.92")
|
|
243
|
-
.option("--no-images", "Skip image downloads and draw placeholders")
|
|
261
|
+
.option("--no-images", "Skip image downloads and draw placeholders")
|
|
262
|
+
.option("--force", "Replace an existing output file"))
|
|
244
263
|
.action(async (board, options) => {
|
|
245
264
|
try {
|
|
246
265
|
const out = options.out;
|
|
@@ -263,7 +282,7 @@ function registerExportCommand(boards) {
|
|
|
263
282
|
if (!result) {
|
|
264
283
|
return error("Nothing to export", "The selected region contains no items.");
|
|
265
284
|
}
|
|
266
|
-
await
|
|
285
|
+
await writeBoardOutput(out, result.bytes, Boolean(options.force));
|
|
267
286
|
if (jsonRequested(options)) {
|
|
268
287
|
return outJson({
|
|
269
288
|
path: out,
|
|
@@ -293,14 +312,26 @@ export function registerBoards(program) {
|
|
|
293
312
|
withJson(boards.command("create <path>")
|
|
294
313
|
.description("Create a Board")
|
|
295
314
|
.option("--title <title>", "Board title")
|
|
296
|
-
.option("-
|
|
315
|
+
.option("--mutation-id <id>", "Stable id for safe retries")
|
|
316
|
+
.option("-i, --input <file>", "BoardCreateInput fields; use - for stdin")
|
|
317
|
+
.addHelpText("after", `
|
|
318
|
+
For normal use, create an empty Board and add content with boards nodes, effects, and compositions.
|
|
319
|
+
--input is intended for bulk creation and accepts BoardCreateInput fields except path and title.`))
|
|
297
320
|
.action(async (path, options) => {
|
|
298
321
|
try {
|
|
299
|
-
const content = options.input
|
|
322
|
+
const content = options.input
|
|
323
|
+
? await readJsonObject(options.input, BOARD_CREATE_INPUT_MAX_BYTES)
|
|
324
|
+
: {};
|
|
300
325
|
if ("path" in content || "title" in content) {
|
|
301
326
|
throw new Error("create input must not contain path or title; use the command argument and --title");
|
|
302
327
|
}
|
|
303
|
-
const input = {
|
|
328
|
+
const input = {
|
|
329
|
+
...content,
|
|
330
|
+
path,
|
|
331
|
+
mutationId: options.mutationId ??
|
|
332
|
+
(typeof content.mutationId === "string" ? content.mutationId : randomUUID()),
|
|
333
|
+
...(options.title ? { title: options.title } : {}),
|
|
334
|
+
};
|
|
304
335
|
const result = await createClient().space(resolveSpace(boards)).boards.create(input);
|
|
305
336
|
if (jsonRequested(options))
|
|
306
337
|
return outJson(result);
|
|
@@ -311,14 +342,20 @@ export function registerBoards(program) {
|
|
|
311
342
|
handleHttp(cause);
|
|
312
343
|
}
|
|
313
344
|
});
|
|
314
|
-
withJson(boards.command("inspect <board
|
|
345
|
+
withJson(boards.command("inspect <board>")
|
|
315
346
|
.alias("get")
|
|
316
347
|
.description("Inspect a Board")
|
|
317
|
-
.option("--include <sections>", "Comma-separated nodes,connections,effects,
|
|
348
|
+
.option("--include <sections>", "Comma-separated nodes,connections,effects,compositions,playback")
|
|
318
349
|
.option("--viewport <rect>", "Viewport as x,y,width,height"))
|
|
319
|
-
.action(async (
|
|
350
|
+
.action(async (target, options) => {
|
|
320
351
|
try {
|
|
321
|
-
const
|
|
352
|
+
const spaceId = resolveSpace(boards);
|
|
353
|
+
const boardId = await resolveBoardId(spaceId, target);
|
|
354
|
+
const board = createClient().space(spaceId).board(boardId);
|
|
355
|
+
if (!jsonRequested(options) && !options.include && !options.viewport) {
|
|
356
|
+
return showSummary(await board.summary());
|
|
357
|
+
}
|
|
358
|
+
const result = await board.inspect({
|
|
322
359
|
include: parseInspectSections(options.include),
|
|
323
360
|
viewport: parseViewport(options.viewport),
|
|
324
361
|
});
|
|
@@ -330,21 +367,25 @@ export function registerBoards(program) {
|
|
|
330
367
|
handleHttp(cause);
|
|
331
368
|
}
|
|
332
369
|
});
|
|
333
|
-
withJson(boards.command("capabilities <board
|
|
370
|
+
withJson(boards.command("capabilities <board>")
|
|
334
371
|
.description("Show supported capabilities"))
|
|
335
|
-
.action(async (
|
|
372
|
+
.action(async (target, options) => {
|
|
336
373
|
try {
|
|
337
|
-
const
|
|
374
|
+
const spaceId = resolveSpace(boards);
|
|
375
|
+
const boardId = await resolveBoardId(spaceId, target);
|
|
376
|
+
const result = await createClient().space(spaceId).board(boardId).capabilities();
|
|
338
377
|
if (jsonRequested(options))
|
|
339
378
|
return outJson(result);
|
|
340
379
|
table(result.capabilities.map((capability) => ({
|
|
341
380
|
...capability,
|
|
342
381
|
renderers: capability.renderers?.join(", ") ?? "",
|
|
382
|
+
coordinates: capabilityUnits(capability.schema),
|
|
343
383
|
})), [
|
|
344
384
|
{ key: "kind", label: "Kind" },
|
|
345
385
|
{ key: "id", label: "ID" },
|
|
346
386
|
{ key: "version", label: "Version" },
|
|
347
387
|
{ key: "renderers", label: "Renderers" },
|
|
388
|
+
{ key: "coordinates", label: "Coordinates / units" },
|
|
348
389
|
{ key: "digest", label: "Digest" },
|
|
349
390
|
]);
|
|
350
391
|
const nodes = result.nodes;
|
|
@@ -371,19 +412,22 @@ export function registerBoards(program) {
|
|
|
371
412
|
});
|
|
372
413
|
registerTransactionCommand(boards, "validate");
|
|
373
414
|
registerTransactionCommand(boards, "apply");
|
|
415
|
+
registerBoardDomainCommands(boards);
|
|
374
416
|
registerExportCommand(boards);
|
|
375
|
-
withJson(boards.command("play <board
|
|
417
|
+
withJson(boards.command("play <board> <composition-id>")
|
|
376
418
|
.description("Start shared playback")
|
|
377
419
|
.option("--position <time>", "Initial position in milliseconds")
|
|
378
420
|
.option("--time-scale <scale>", "Playback speed from 0 to 4")
|
|
379
421
|
.option("--seed <seed>", "Deterministic playback seed")
|
|
380
422
|
.option("--command-id <id>", "Idempotency command ID"))
|
|
381
|
-
.action(async (
|
|
423
|
+
.action(async (target, compositionId, options) => {
|
|
382
424
|
try {
|
|
383
|
-
const
|
|
425
|
+
const spaceId = resolveSpace(boards);
|
|
426
|
+
const boardId = await resolveBoardId(spaceId, target);
|
|
427
|
+
const result = await createClient().space(spaceId).board(boardId).play({
|
|
384
428
|
commandId: commandId(options),
|
|
385
429
|
type: "play",
|
|
386
|
-
|
|
430
|
+
compositionId,
|
|
387
431
|
shared: true,
|
|
388
432
|
...(options.position === undefined ? {} : { position: parseNumber(options.position, "position", { min: 0 }) }),
|
|
389
433
|
...(options.timeScale === undefined ? {} : { timeScale: parseNumber(options.timeScale, "timeScale", { min: Number.EPSILON, max: 4 }) }),
|
|
@@ -397,9 +441,11 @@ export function registerBoards(program) {
|
|
|
397
441
|
handleHttp(cause);
|
|
398
442
|
}
|
|
399
443
|
});
|
|
400
|
-
const playbackAction = (type) => async (
|
|
444
|
+
const playbackAction = (type) => async (target, playbackId, options) => {
|
|
401
445
|
try {
|
|
402
|
-
const
|
|
446
|
+
const spaceId = resolveSpace(boards);
|
|
447
|
+
const boardId = await resolveBoardId(spaceId, target);
|
|
448
|
+
const board = createClient().space(spaceId).board(boardId);
|
|
403
449
|
const id = commandId(options);
|
|
404
450
|
const result = type === "pause"
|
|
405
451
|
? await board.pause({ commandId: id, type: "pause", playbackId })
|
|
@@ -412,16 +458,18 @@ export function registerBoards(program) {
|
|
|
412
458
|
handleHttp(cause);
|
|
413
459
|
}
|
|
414
460
|
};
|
|
415
|
-
withJson(boards.command("pause <board
|
|
461
|
+
withJson(boards.command("pause <board> <playback-id>")
|
|
416
462
|
.description("Pause playback")
|
|
417
463
|
.option("--command-id <id>", "Idempotency command ID"))
|
|
418
464
|
.action(playbackAction("pause"));
|
|
419
|
-
withJson(boards.command("seek <board
|
|
465
|
+
withJson(boards.command("seek <board> <playback-id> <position>")
|
|
420
466
|
.description("Seek playback")
|
|
421
467
|
.option("--command-id <id>", "Idempotency command ID"))
|
|
422
|
-
.action(async (
|
|
468
|
+
.action(async (target, playbackId, position, options) => {
|
|
423
469
|
try {
|
|
424
|
-
const
|
|
470
|
+
const spaceId = resolveSpace(boards);
|
|
471
|
+
const boardId = await resolveBoardId(spaceId, target);
|
|
472
|
+
const result = await createClient().space(spaceId).board(boardId).seek({
|
|
425
473
|
commandId: commandId(options),
|
|
426
474
|
type: "seek",
|
|
427
475
|
playbackId,
|
|
@@ -435,18 +483,32 @@ export function registerBoards(program) {
|
|
|
435
483
|
handleHttp(cause);
|
|
436
484
|
}
|
|
437
485
|
});
|
|
438
|
-
withJson(boards.command("stop <board
|
|
486
|
+
withJson(boards.command("stop <board> <playback-id>")
|
|
439
487
|
.description("Stop playback")
|
|
440
488
|
.option("--command-id <id>", "Idempotency command ID"))
|
|
441
489
|
.action(playbackAction("stop"));
|
|
442
|
-
withJson(boards.command("watch <board
|
|
490
|
+
withJson(boards.command("watch <board>")
|
|
443
491
|
.description("Stream Board events"))
|
|
444
|
-
.action((
|
|
492
|
+
.action(async (target, options) => {
|
|
445
493
|
try {
|
|
446
|
-
const
|
|
494
|
+
const spaceId = resolveSpace(boards);
|
|
495
|
+
const boardId = await resolveBoardId(spaceId, target);
|
|
496
|
+
const client = createRealtimeClient();
|
|
497
|
+
const board = client.space(spaceId).board(boardId);
|
|
447
498
|
if (!jsonRequested(options))
|
|
448
499
|
process.stderr.write(`Listening for Board ${boardId} events...\n`);
|
|
449
|
-
|
|
500
|
+
const offConnection = client.onConnection((state) => {
|
|
501
|
+
if (jsonRequested(options)) {
|
|
502
|
+
process.stdout.write(`${JSON.stringify({ type: "connection", ...state })}\n`);
|
|
503
|
+
}
|
|
504
|
+
else {
|
|
505
|
+
const detail = state.state === "reconnecting" && state.attempt
|
|
506
|
+
? ` (attempt ${state.attempt})`
|
|
507
|
+
: "";
|
|
508
|
+
process.stderr.write(`${state.state}${detail}\n`);
|
|
509
|
+
}
|
|
510
|
+
});
|
|
511
|
+
const offBoard = board.subscribe({
|
|
450
512
|
event(event) {
|
|
451
513
|
if (jsonRequested(options)) {
|
|
452
514
|
process.stdout.write(`${JSON.stringify(event)}\n`);
|
|
@@ -456,13 +518,18 @@ export function registerBoards(program) {
|
|
|
456
518
|
process.stdout.write(`version ${event.payload.version} transaction ${event.payload.txId} operations ${event.payload.operations.length}\n`);
|
|
457
519
|
}
|
|
458
520
|
else if (event.type === "board.playback.changed") {
|
|
459
|
-
process.stdout.write(`${event.payload.status}
|
|
521
|
+
process.stdout.write(`${event.payload.status} composition ${event.payload.compositionId} position ${event.payload.position}\n`);
|
|
460
522
|
}
|
|
461
523
|
else {
|
|
462
524
|
process.stdout.write(`awareness ${event.payload.actorName} ${event.payload.update.type}\n`);
|
|
463
525
|
}
|
|
464
526
|
},
|
|
465
527
|
});
|
|
528
|
+
process.once("SIGINT", () => {
|
|
529
|
+
offBoard();
|
|
530
|
+
offConnection();
|
|
531
|
+
process.exit(0);
|
|
532
|
+
});
|
|
466
533
|
}
|
|
467
534
|
catch (cause) {
|
|
468
535
|
handleHttp(cause);
|
package/dist/commands/works.js
CHANGED
|
@@ -259,7 +259,7 @@ export function registerWorks(program) {
|
|
|
259
259
|
.option("--status <status>", "Work status: published, disabled")
|
|
260
260
|
.option("--visibility <visibility>", "Work visibility: public, space")
|
|
261
261
|
.option("--work-scope <scope>", "Scope granted to the work runtime (space.view, session.view, file.view, taskrun.view)", collectOption, [])
|
|
262
|
-
.option("--viewer-scope <scope>", "Scope viewers may request (session.prompt.readonly, session.prompt.fullaccess, generation.create, user.space.list, user.session.list, user.usage.read)", collectOption, [])
|
|
262
|
+
.option("--viewer-scope <scope>", "Scope viewers may request (taskrun.view, session.prompt.readonly, session.prompt.fullaccess, generation.create, user.space.list, user.session.list, user.usage.read)", collectOption, [])
|
|
263
263
|
.option("--meta <json>", "Work metadata as a JSON object")
|
|
264
264
|
.option("--hide-cohub-bar", "Hide the Cohub footer bar on the public work page")
|
|
265
265
|
.option("--show-cohub-bar", "Show the Cohub footer bar on the public work page")
|
|
@@ -338,7 +338,7 @@ export function registerWorks(program) {
|
|
|
338
338
|
.option("--status <status>", "Work status: published, disabled")
|
|
339
339
|
.option("--visibility <visibility>", "Work visibility: public, space")
|
|
340
340
|
.option("--work-scope <scope>", "Scope granted to the work runtime (space.view, session.view, file.view, taskrun.view)", collectOption, [])
|
|
341
|
-
.option("--viewer-scope <scope>", "Scope viewers may request (session.prompt.readonly, session.prompt.fullaccess, generation.create, user.space.list, user.session.list, user.usage.read)", collectOption, [])
|
|
341
|
+
.option("--viewer-scope <scope>", "Scope viewers may request (taskrun.view, session.prompt.readonly, session.prompt.fullaccess, generation.create, user.space.list, user.session.list, user.usage.read)", collectOption, [])
|
|
342
342
|
.option("--clear-work-scopes", "Clear work runtime scopes")
|
|
343
343
|
.option("--clear-viewer-scopes", "Clear viewer-requestable scopes")
|
|
344
344
|
.option("--meta <json>", "Work metadata as a JSON object")
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export declare const REMOTE_IMAGE_MAX_BYTES: number;
|
|
2
|
+
export declare const REMOTE_IMAGE_TIMEOUT_MS = 15000;
|
|
3
|
+
type ResolvedAddress = {
|
|
4
|
+
address: string;
|
|
5
|
+
family: 4 | 6;
|
|
6
|
+
};
|
|
7
|
+
type Lookup = (hostname: string) => Promise<readonly ResolvedAddress[]>;
|
|
8
|
+
type RemoteResponse = {
|
|
9
|
+
status: number;
|
|
10
|
+
headers: Headers;
|
|
11
|
+
bytes: Uint8Array;
|
|
12
|
+
};
|
|
13
|
+
type Requester = (url: URL, address: ResolvedAddress, timeoutMs: number, maxBytes: number) => Promise<RemoteResponse>;
|
|
14
|
+
export type RemoteImageDownloadOptions = {
|
|
15
|
+
lookup?: Lookup;
|
|
16
|
+
requester?: Requester;
|
|
17
|
+
maxBytes?: number;
|
|
18
|
+
timeoutMs?: number;
|
|
19
|
+
};
|
|
20
|
+
export declare function downloadPublicImage(input: string, options?: RemoteImageDownloadOptions): Promise<{
|
|
21
|
+
bytes: Uint8Array;
|
|
22
|
+
mimeType: string;
|
|
23
|
+
}>;
|
|
24
|
+
export {};
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import dns from "node:dns/promises";
|
|
2
|
+
import http from "node:http";
|
|
3
|
+
import https from "node:https";
|
|
4
|
+
import { isIP } from "node:net";
|
|
5
|
+
import { isPublicBoardRemoteAddress, normalizeBoardRemoteUrl, } from "@neta-art/cohub/board";
|
|
6
|
+
export const REMOTE_IMAGE_MAX_BYTES = 16 * 1024 * 1024;
|
|
7
|
+
export const REMOTE_IMAGE_TIMEOUT_MS = 15_000;
|
|
8
|
+
const MAX_REDIRECTS = 3;
|
|
9
|
+
const IMAGE_MIME_TYPES = new Set([
|
|
10
|
+
"image/avif",
|
|
11
|
+
"image/gif",
|
|
12
|
+
"image/jpeg",
|
|
13
|
+
"image/png",
|
|
14
|
+
"image/webp",
|
|
15
|
+
]);
|
|
16
|
+
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
|
|
17
|
+
async function defaultLookup(hostname) {
|
|
18
|
+
const records = await dns.lookup(hostname, { all: true, verbatim: true });
|
|
19
|
+
return records.map((record) => ({
|
|
20
|
+
address: record.address,
|
|
21
|
+
family: record.family,
|
|
22
|
+
}));
|
|
23
|
+
}
|
|
24
|
+
function responseHeaders(input) {
|
|
25
|
+
const headers = new Headers();
|
|
26
|
+
for (const [key, value] of Object.entries(input)) {
|
|
27
|
+
if (Array.isArray(value)) {
|
|
28
|
+
for (const item of value)
|
|
29
|
+
headers.append(key, item);
|
|
30
|
+
}
|
|
31
|
+
else if (value !== undefined) {
|
|
32
|
+
headers.set(key, value);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return headers;
|
|
36
|
+
}
|
|
37
|
+
function requestPinned(url, address, timeoutMs, maxBytes) {
|
|
38
|
+
return new Promise((resolve, reject) => {
|
|
39
|
+
const client = url.protocol === "https:" ? https : http;
|
|
40
|
+
const request = client.request(url, {
|
|
41
|
+
agent: false,
|
|
42
|
+
headers: { Accept: "image/avif,image/webp,image/png,image/jpeg,image/gif" },
|
|
43
|
+
lookup: (_hostname, _options, callback) => {
|
|
44
|
+
callback(null, address.address, address.family);
|
|
45
|
+
},
|
|
46
|
+
}, (response) => {
|
|
47
|
+
const status = response.statusCode ?? 0;
|
|
48
|
+
const headers = responseHeaders(response.headers);
|
|
49
|
+
if (REDIRECT_STATUSES.has(status)) {
|
|
50
|
+
response.resume();
|
|
51
|
+
resolve({ status, headers, bytes: new Uint8Array() });
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
const declaredLength = Number(headers.get("content-length") ?? 0);
|
|
55
|
+
if (Number.isFinite(declaredLength) && declaredLength > maxBytes) {
|
|
56
|
+
response.destroy();
|
|
57
|
+
reject(new Error(`Image exceeds the ${maxBytes} byte download limit`));
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
const chunks = [];
|
|
61
|
+
let total = 0;
|
|
62
|
+
response.on("data", (chunk) => {
|
|
63
|
+
total += chunk.byteLength;
|
|
64
|
+
if (total > maxBytes) {
|
|
65
|
+
response.destroy(new Error(`Image exceeds the ${maxBytes} byte download limit`));
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
chunks.push(chunk);
|
|
69
|
+
});
|
|
70
|
+
response.once("error", reject);
|
|
71
|
+
response.once("end", () => {
|
|
72
|
+
const bytes = new Uint8Array(total);
|
|
73
|
+
let offset = 0;
|
|
74
|
+
for (const chunk of chunks) {
|
|
75
|
+
bytes.set(chunk, offset);
|
|
76
|
+
offset += chunk.byteLength;
|
|
77
|
+
}
|
|
78
|
+
resolve({ status, headers, bytes });
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
request.setTimeout(timeoutMs, () => {
|
|
82
|
+
request.destroy(new Error(`Image download timed out after ${timeoutMs}ms`));
|
|
83
|
+
});
|
|
84
|
+
request.once("error", reject);
|
|
85
|
+
request.end();
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
function remainingMs(deadline) {
|
|
89
|
+
const remaining = deadline - Date.now();
|
|
90
|
+
if (remaining <= 0)
|
|
91
|
+
throw new Error("Image download timed out");
|
|
92
|
+
return remaining;
|
|
93
|
+
}
|
|
94
|
+
async function withDeadline(promise, deadline) {
|
|
95
|
+
const timeoutMs = remainingMs(deadline);
|
|
96
|
+
let timer;
|
|
97
|
+
try {
|
|
98
|
+
return await Promise.race([
|
|
99
|
+
promise,
|
|
100
|
+
new Promise((_, reject) => {
|
|
101
|
+
timer = setTimeout(() => reject(new Error(`Image download timed out after ${timeoutMs}ms`)), timeoutMs);
|
|
102
|
+
}),
|
|
103
|
+
]);
|
|
104
|
+
}
|
|
105
|
+
finally {
|
|
106
|
+
if (timer)
|
|
107
|
+
clearTimeout(timer);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
async function resolvePublicUrl(value, lookup, deadline) {
|
|
111
|
+
const normalized = normalizeBoardRemoteUrl(value);
|
|
112
|
+
if (!normalized)
|
|
113
|
+
throw new Error("Image URL must be a public HTTP(S) URL");
|
|
114
|
+
const url = new URL(normalized);
|
|
115
|
+
const hostname = url.hostname.replace(/^\[|\]$/g, "");
|
|
116
|
+
const addresses = isIP(hostname)
|
|
117
|
+
? [{ address: hostname, family: isIP(hostname) }]
|
|
118
|
+
: await withDeadline(lookup(hostname), deadline);
|
|
119
|
+
if (addresses.length === 0 ||
|
|
120
|
+
addresses.some((entry) => !isPublicBoardRemoteAddress(entry.address))) {
|
|
121
|
+
throw new Error("Image URL resolves to a private address");
|
|
122
|
+
}
|
|
123
|
+
return { url, address: addresses[0] };
|
|
124
|
+
}
|
|
125
|
+
export async function downloadPublicImage(input, options = {}) {
|
|
126
|
+
const lookup = options.lookup ?? defaultLookup;
|
|
127
|
+
const requester = options.requester ?? requestPinned;
|
|
128
|
+
const maxBytes = options.maxBytes ?? REMOTE_IMAGE_MAX_BYTES;
|
|
129
|
+
const deadline = Date.now() + (options.timeoutMs ?? REMOTE_IMAGE_TIMEOUT_MS);
|
|
130
|
+
let current = input;
|
|
131
|
+
for (let redirect = 0; redirect <= MAX_REDIRECTS; redirect += 1) {
|
|
132
|
+
const { url, address } = await resolvePublicUrl(current, lookup, deadline);
|
|
133
|
+
const response = await withDeadline(requester(url, address, remainingMs(deadline), maxBytes), deadline);
|
|
134
|
+
if (REDIRECT_STATUSES.has(response.status)) {
|
|
135
|
+
const location = response.headers.get("location");
|
|
136
|
+
if (!location)
|
|
137
|
+
throw new Error("Image redirect is missing a location");
|
|
138
|
+
if (redirect === MAX_REDIRECTS)
|
|
139
|
+
throw new Error("Too many image redirects");
|
|
140
|
+
current = new URL(location, url).toString();
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
if (response.status < 200 || response.status >= 300) {
|
|
144
|
+
throw new Error(`HTTP ${response.status}`);
|
|
145
|
+
}
|
|
146
|
+
if (response.bytes.byteLength > maxBytes) {
|
|
147
|
+
throw new Error(`Image exceeds the ${maxBytes} byte download limit`);
|
|
148
|
+
}
|
|
149
|
+
const mimeType = response.headers
|
|
150
|
+
.get("content-type")
|
|
151
|
+
?.split(";", 1)[0]
|
|
152
|
+
?.trim()
|
|
153
|
+
.toLowerCase();
|
|
154
|
+
if (!mimeType || !IMAGE_MIME_TYPES.has(mimeType)) {
|
|
155
|
+
throw new Error("Remote background must be a supported raster image");
|
|
156
|
+
}
|
|
157
|
+
return { bytes: response.bytes, mimeType };
|
|
158
|
+
}
|
|
159
|
+
throw new Error("Too many image redirects");
|
|
160
|
+
}
|