@neta-art/cohub-cli 8.4.1 → 9.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.
Files changed (70) hide show
  1. package/README.md +66 -30
  2. package/dist/board-export.d.ts +21 -36
  3. package/dist/board-export.js +163 -88
  4. package/dist/board-kernel.d.ts +2 -3
  5. package/dist/board-kernel.js +3428 -3770
  6. package/dist/board-time.d.ts +3 -0
  7. package/dist/board-time.js +28 -0
  8. package/dist/commands/apps.js +2 -2
  9. package/dist/commands/auth.js +2 -2
  10. package/dist/commands/boards/examples.d.ts +8 -2
  11. package/dist/commands/boards/examples.js +79 -411
  12. package/dist/commands/boards.d.ts +4 -9
  13. package/dist/commands/boards.js +365 -286
  14. package/dist/commands/desktop.d.ts +1 -5
  15. package/dist/commands/desktop.js +5 -16
  16. package/dist/commands/generations.js +1 -1
  17. package/dist/commands/me.js +1 -1
  18. package/dist/commands/public.js +3 -3
  19. package/dist/commands/references.js +3 -0
  20. package/dist/commands/referrals.js +2 -2
  21. package/dist/commands/run.js +3 -3
  22. package/dist/commands/runtime.d.ts +1 -1
  23. package/dist/commands/runtime.js +14 -9
  24. package/dist/commands/sandboxd-binary.d.ts +1 -1
  25. package/dist/commands/sandboxd-binary.js +2 -23
  26. package/dist/commands/search.js +15 -7
  27. package/dist/commands/space-activity.js +3 -5
  28. package/dist/commands/space-displays.d.ts +2 -0
  29. package/dist/commands/space-displays.js +347 -0
  30. package/dist/commands/space-files.d.ts +5 -0
  31. package/dist/commands/space-files.js +215 -0
  32. package/dist/commands/space-invitations.js +3 -3
  33. package/dist/commands/spaces.d.ts +3 -11
  34. package/dist/commands/spaces.js +146 -75
  35. package/dist/index.js +8 -5
  36. package/dist/runtime/launch.d.ts +2 -0
  37. package/dist/runtime/launch.js +38 -8
  38. package/dist/runtime/presentation.d.ts +1 -0
  39. package/dist/runtime/presentation.js +3 -2
  40. package/dist/runtime/supervisor.d.ts +1 -0
  41. package/dist/runtime/supervisor.js +2 -1
  42. package/dist/space-files/limit.d.ts +2 -0
  43. package/dist/space-files/limit.js +16 -0
  44. package/dist/space-files/operands.d.ts +16 -0
  45. package/dist/space-files/operands.js +33 -0
  46. package/dist/space-files/transfer.d.ts +53 -0
  47. package/dist/space-files/transfer.js +296 -0
  48. package/dist/space-files/upload.d.ts +14 -0
  49. package/dist/space-files/upload.js +53 -0
  50. package/dist/space.d.ts +18 -16
  51. package/dist/space.js +86 -39
  52. package/dist/web.d.ts +1 -0
  53. package/dist/web.js +6 -0
  54. package/package.json +2 -2
  55. package/dist/commands/board-domain.d.ts +0 -2
  56. package/dist/commands/board-domain.js +0 -16
  57. package/dist/commands/boards/animation.d.ts +0 -2
  58. package/dist/commands/boards/animation.js +0 -171
  59. package/dist/commands/boards/appearance.d.ts +0 -2
  60. package/dist/commands/boards/appearance.js +0 -127
  61. package/dist/commands/boards/batch.d.ts +0 -4
  62. package/dist/commands/boards/batch.js +0 -62
  63. package/dist/commands/boards/connections.d.ts +0 -2
  64. package/dist/commands/boards/connections.js +0 -95
  65. package/dist/commands/boards/context.d.ts +0 -31
  66. package/dist/commands/boards/context.js +0 -71
  67. package/dist/commands/boards/items.d.ts +0 -2
  68. package/dist/commands/boards/items.js +0 -184
  69. package/dist/commands/boards/transactions.d.ts +0 -12
  70. package/dist/commands/boards/transactions.js +0 -69
@@ -1,10 +1,18 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { BOARD_CREATE_INPUT_MAX_BYTES, parseBoardJsonObject, readBoardJsonObject, resolveBoardId, writeBoardOutput, } from "../board-command-support.js";
3
- import { registerBoardDomainCommands } from "./board-domain.js";
2
+ import { existsSync } from "node:fs";
3
+ import { mkdir } from "node:fs/promises";
4
+ import { dirname } from "node:path";
5
+ import { BOARD_PRESET_NAMES, BOARD_SCHEMA_TARGETS, boardJsonSchema, boardPresetTracks, boardSchemaOverview, isBoardPresetName, listBoardPresets, } from "@neta-art/cohub/board";
6
+ import { BOARD_CREATE_INPUT_MAX_BYTES, BOARD_TRANSACTION_INPUT_MAX_BYTES, parseBoardJsonObject, readBoardJsonObject, resolveBoardId, writeBoardOutput, } from "../board-command-support.js";
7
+ import { formatBoardTime, parseBoardTime, parseBoardTimes } from "../board-time.js";
4
8
  import { createClient, createRealtimeClient } from "../client.js";
5
9
  import { error, handleHttp, json as outJson, jsonRequested, ok, table } from "../output.js";
6
10
  import { resolveSpace } from "../space.js";
11
+ import { registerBoardExampleCommands } from "./boards/examples.js";
7
12
  export const parseJsonObject = parseBoardJsonObject;
13
+ function withJson(command) {
14
+ return command.option("--json", "Output as JSON");
15
+ }
8
16
  function parseNumber(value, name, options = {}) {
9
17
  if (!value.trim())
10
18
  throw new Error(`${name} must be a finite number`);
@@ -19,115 +27,86 @@ function parseNumber(value, name, options = {}) {
19
27
  throw new Error(`${name} must be at most ${options.max}`);
20
28
  return parsed;
21
29
  }
22
- export function parseViewport(value) {
23
- if (!value)
24
- return undefined;
30
+ export function parseRect(value, name = "--rect") {
25
31
  const parts = value.split(",").map((part) => part.trim());
26
32
  if (parts.length !== 4)
27
- throw new Error("viewport must be x,y,width,height");
28
- const [xValue, yValue, widthValue, heightValue] = parts;
29
- if (xValue === undefined || yValue === undefined || widthValue === undefined || heightValue === undefined) {
30
- throw new Error("viewport must be x,y,width,height");
31
- }
32
- const x = parseNumber(xValue, "viewport x");
33
- const y = parseNumber(yValue, "viewport y");
34
- const width = parseNumber(widthValue, "viewport width");
35
- const height = parseNumber(heightValue, "viewport height");
33
+ throw new Error(`${name} must be x,y,width,height`);
34
+ const [x, y, width, height] = parts.map((part, index) => parseNumber(part, `${name} ${["x", "y", "width", "height"][index]}`));
36
35
  if (width <= 0 || height <= 0)
37
- throw new Error("viewport width and height must be greater than zero");
36
+ throw new Error(`${name} width and height must be greater than zero`);
38
37
  return { x, y, width, height };
39
38
  }
40
- export function showCreated(result, path) {
41
- table([{ path, ...result.board }], [
42
- { key: "path", label: "Path" },
43
- { key: "id", label: "ID" },
44
- { key: "title", label: "Title" },
45
- { key: "version", label: "Version" },
46
- ]);
39
+ function list(value) {
40
+ const entries = value?.split(",").map((entry) => entry.trim()).filter(Boolean);
41
+ return entries?.length ? entries : undefined;
47
42
  }
48
- function showSummary(result) {
49
- const background = result.board.metadata.appearance;
50
- table([{
51
- id: result.board.id,
52
- title: result.board.title,
53
- version: result.board.version,
54
- ...result.counts,
55
- background: background?.background?.kind ?? "default",
56
- updatedAt: result.board.updatedAt,
57
- }], [
58
- { key: "id", label: "ID" },
59
- { key: "title", label: "TITLE" },
60
- { key: "version", label: "VERSION" },
61
- { key: "items", label: "ITEMS" },
62
- { key: "connections", label: "CONNECTIONS" },
63
- { key: "effects", label: "EFFECTS" },
64
- { key: "compositions", label: "COMPOSITIONS" },
65
- { key: "background", label: "BACKGROUND" },
66
- { key: "updatedAt", label: "UPDATED" },
67
- ]);
43
+ async function boardClient(boards, target) {
44
+ const spaceId = await resolveSpace(boards);
45
+ return createClient().space(spaceId).board(await resolveBoardId(spaceId, target));
68
46
  }
69
- function showPlayback(result) {
70
- table([result], [
71
- { key: "playbackId", label: "Playback ID" },
72
- { key: "compositionId", label: "Composition" },
73
- { key: "status", label: "Status" },
74
- { key: "position", label: "Position" },
75
- { key: "timeScale", label: "Time Scale" },
76
- ]);
47
+ /** A patch from an inline argument, a file, or stdin (`-`). */
48
+ async function readPatch(inline, input, maxBytes) {
49
+ if (inline !== undefined && input !== undefined)
50
+ throw new Error("Pass the patch inline or with --input, not both.");
51
+ if (inline !== undefined)
52
+ return parseBoardJsonObject(inline, "patch");
53
+ if (input !== undefined)
54
+ return (await readBoardJsonObject(input, maxBytes));
55
+ throw new Error("Pass a patch inline or with --input <file|->.");
77
56
  }
78
- function withJson(command) {
79
- return command.option("--json", "Output as JSON");
57
+ function describeChanges(result) {
58
+ const parts = [
59
+ result.changed.board ? "board" : null,
60
+ result.changed.items.length ? `${result.changed.items.length} item${result.changed.items.length === 1 ? "" : "s"}` : null,
61
+ result.changed.animations.length ? `${result.changed.animations.length} animation${result.changed.animations.length === 1 ? "" : "s"}` : null,
62
+ ].filter(Boolean);
63
+ return parts.length ? parts.join(", ") : "nothing";
80
64
  }
81
- function capabilityUnits(schema) {
82
- const params = schema?.params;
83
- if (!params || typeof params !== "object" || Array.isArray(params))
84
- return "";
85
- return Object.entries(params).flatMap(([field, value]) => {
86
- if (!value || typeof value !== "object" || Array.isArray(value))
87
- return [];
88
- const meta = value;
89
- const detail = [meta.coordinateSpace, meta.unit]
90
- .filter((item) => typeof item === "string")
91
- .join("/");
92
- return detail ? [`${field}:${detail}`] : [];
93
- }).join(", ");
65
+ function showApplied(result) {
66
+ if (result.status === "validated")
67
+ ok(`Valid: would change ${describeChanges(result)}`);
68
+ else if (result.status === "unchanged")
69
+ ok(`Unchanged at version ${result.version}`);
70
+ else
71
+ ok(`Applied version ${result.version}${result.replayed ? " (replayed)" : ""}: ${describeChanges(result)}`);
72
+ for (const diagnostic of result.diagnostics)
73
+ console.log(` ! ${diagnostic.path}: ${diagnostic.message}`);
94
74
  }
95
- function commandId(options) {
96
- return options.commandId?.trim() || randomUUID();
75
+ function showPlayback(playback) {
76
+ if (!playback) {
77
+ ok("Stopped");
78
+ return;
79
+ }
80
+ table([{ ...playback, position: formatBoardTime(Math.round(playback.position)) }], [
81
+ { key: "animationId", label: "Animation" },
82
+ { key: "status", label: "Status" },
83
+ { key: "position", label: "Position" },
84
+ { key: "timeScale", label: "Speed" },
85
+ ]);
97
86
  }
98
- /**
99
- * Resolve the mutually exclusive region flags.
100
- *
101
- * Selecting more than one is rejected rather than silently ranked: "why did
102
- * --items win over --frame" is a worse experience than being told to pick one.
103
- */
87
+ /** One region flag at most: being told to pick beats guessing which one wins. */
104
88
  function parseExportRegion(options) {
105
- const chosen = [
106
- options.frame ? "--frame" : null,
107
- options.items ? "--items" : null,
108
- options.rect ? "--rect" : null,
109
- ].filter(Boolean);
110
- if (chosen.length > 1) {
111
- throw new Error(`Pick one region: ${chosen.join(", ")} cannot be combined`);
112
- }
89
+ const chosen = [options.frame && "--frame", options.items && "--items", options.rect && "--rect"].filter(Boolean);
90
+ if (chosen.length > 1)
91
+ throw new Error(`Pick one region: ${chosen.join(", ")} cannot be combined.`);
113
92
  if (options.frame)
114
93
  return { kind: "frame", id: options.frame };
115
94
  if (options.items) {
116
- const ids = options.items.split(",").map((id) => id.trim()).filter(Boolean);
117
- if (ids.length === 0)
118
- throw new Error("--items needs at least one node id");
95
+ const ids = list(options.items);
96
+ if (!ids)
97
+ throw new Error("--items needs at least one item id.");
119
98
  return { kind: "items", ids };
120
99
  }
121
- if (options.rect) {
122
- const rect = parseViewport(options.rect);
123
- if (!rect)
124
- throw new Error("--rect must be x,y,width,height");
125
- return { kind: "rect", rect };
126
- }
100
+ if (options.rect)
101
+ return { kind: "rect", rect: parseRect(options.rect) };
127
102
  return { kind: "all" };
128
103
  }
129
104
  const BOARD_EXPORT_FORMATS = ["png", "jpeg", "webp"];
130
- /** Infer the output format from the file extension, defaulting to PNG. */
105
+ const BOARD_VIDEO_FORMATS = ["mp4", "webm"];
106
+ function videoFormatFromPath(path) {
107
+ const lower = path.toLowerCase();
108
+ return lower.endsWith(".webm") ? "webm" : lower.endsWith(".mp4") ? "mp4" : null;
109
+ }
131
110
  function formatFromPath(path) {
132
111
  const lower = path.toLowerCase();
133
112
  if (lower.endsWith(".jpg") || lower.endsWith(".jpeg"))
@@ -140,77 +119,121 @@ function parseExportFormat(options, outPath) {
140
119
  if (!options.format)
141
120
  return formatFromPath(outPath);
142
121
  const format = options.format.toLowerCase();
143
- if (!BOARD_EXPORT_FORMATS.includes(format)) {
144
- throw new Error(`Unknown format "${options.format}"; use ${BOARD_EXPORT_FORMATS.join(", ")}`);
145
- }
122
+ if (!BOARD_EXPORT_FORMATS.includes(format))
123
+ throw new Error(`--format must be one of ${BOARD_EXPORT_FORMATS.join(", ")}.`);
146
124
  return format;
147
125
  }
148
- function parseColorMode(value) {
149
- if (!value || value === "dark")
150
- return "dark";
151
- if (value === "light")
152
- return "light";
153
- throw new Error('--theme must be "dark" or "light"');
126
+ function parseChoice(value, name, choices) {
127
+ const chosen = (value ?? choices[0]);
128
+ if (!choices.includes(chosen))
129
+ throw new Error(`${name} must be ${choices.join(" or ")}.`);
130
+ return chosen;
154
131
  }
155
- function parseBackground(value) {
156
- if (!value || value === "paper")
157
- return "paper";
158
- if (value === "transparent")
159
- return "transparent";
160
- throw new Error('--background must be "paper" or "transparent"');
132
+ /** `frames/%04d.png` → `frames/0012.png`; a sequence needs a `%d` placeholder. */
133
+ export function framePath(pattern, index) {
134
+ return pattern.replace(/%(0?)(\d*)d/, (_match, zero, width) => String(index).padStart(Number(width || 0), zero ? "0" : " "));
161
135
  }
162
136
  function registerExportCommand(boards) {
163
137
  withJson(boards.command("export <board>")
164
- .description("Render a Board to an image (board id or .board path)")
165
- .requiredOption("-o, --out <file>", "Output file; extension selects the format")
166
- .option("--scale <factor>", "Output pixels per world unit", "2")
167
- .option("--padding <units>", "World-space padding around the content")
168
- .option("--frame <node-id>", "Export a single frame as a page")
169
- .option("--items <ids>", "Comma-separated node ids to export")
138
+ .description("Render a Board to an image, or an animation to a frame sequence")
139
+ .requiredOption("-o, --out <file>", "Output file; the extension picks the format. Sequences need %d, e.g. frames/%04d.png")
140
+ .option("--at <time>", "A moment (12.5s) or a range start:end:step (0:10s:100ms)")
141
+ .option("--animation <id>", "Animation the --at times refer to")
142
+ .option("--frame <item-id>", "Export one frame as a page")
143
+ .option("--items <ids>", "Comma-separated item ids")
170
144
  .option("--rect <rect>", "World rect as x,y,width,height")
145
+ .option("--scale <factor>", "Output pixels per board unit", "2")
146
+ .option("--padding <units>", "Padding around the content in board units")
171
147
  .option("--theme <mode>", "dark or light", "dark")
172
- .option("--background <mode>", "paper or transparent", "paper")
173
- .option("--format <format>", `Override format (${BOARD_EXPORT_FORMATS.join(", ")})`)
148
+ .option("--paper <mode>", "Output paper: paper or transparent", "paper")
149
+ .option("--format <format>", `Override the format (${BOARD_EXPORT_FORMATS.join(", ")}, ${BOARD_VIDEO_FORMATS.join(", ")})`)
174
150
  .option("--quality <q>", "JPEG/WebP quality from 0 to 1", "0.92")
175
- .option("--no-images", "Skip image downloads and draw placeholders")
176
- .option("--force", "Replace an existing output file"))
177
- .action(async (board, options) => {
151
+ .option("--fps <rate>", "Video frame rate; defaults to the --at step")
152
+ .option("--no-images", "Draw placeholders instead of downloading images")
153
+ .option("--force", "Replace existing output files"))
154
+ .addHelpText("after", `
155
+ Examples:
156
+ cohub boards export plan.board -o plan.png
157
+ cohub boards export plan.board --frame s2 -o slide-2.png
158
+ cohub boards export plan.board --animation lecture --at 4.5s -o moment.png
159
+ cohub boards export plan.board --animation lecture --at 0:6s:40ms -o frames/%04d.png`)
160
+ .action(async (target, options) => {
178
161
  try {
179
162
  const out = options.out;
180
- if (!out)
181
- throw new Error("--out is required");
163
+ const times = options.at ? parseBoardTimes(options.at) : undefined;
164
+ const pathVideoFormat = videoFormatFromPath(out);
165
+ const requestedFormat = options.format?.toLowerCase();
166
+ if (requestedFormat && ![...BOARD_EXPORT_FORMATS, ...BOARD_VIDEO_FORMATS].includes(requestedFormat))
167
+ throw new Error(`--format must be one of ${[...BOARD_EXPORT_FORMATS, ...BOARD_VIDEO_FORMATS].join(", ")}.`);
168
+ const videoFormat = requestedFormat
169
+ ? BOARD_VIDEO_FORMATS.includes(requestedFormat) ? requestedFormat : null
170
+ : pathVideoFormat;
171
+ const sequence = (times?.length ?? 0) > 1;
172
+ if (sequence && !videoFormat && !/%0?\d*d/.test(out))
173
+ throw new Error("A range needs %d in --out, e.g. frames/%04d.png.");
174
+ if (videoFormat) {
175
+ if (!times || times.length < 2)
176
+ throw new Error("Video export needs an --at range.");
177
+ const step = times[1] - times[0];
178
+ const fps = options.fps ? parseNumber(options.fps, "--fps", { min: 0.01, max: 240 }) : 1000 / step;
179
+ if (existsSync(out) && !options.force)
180
+ throw new Error(`${out} already exists; use --force to replace it.`);
181
+ await mkdir(dirname(out), { recursive: true });
182
+ const { runBoardVideoExport } = await import("../board-export.js");
183
+ const result = await runBoardVideoExport({
184
+ spaceId: await resolveSpace(boards),
185
+ target,
186
+ region: parseExportRegion(options),
187
+ times,
188
+ ...(options.animation ? { animation: options.animation } : {}),
189
+ scale: parseNumber(options.scale ?? "2", "--scale", { min: 0.01, max: 16 }),
190
+ ...(options.padding === undefined ? {} : { padding: parseNumber(options.padding, "--padding", { min: 0 }) }),
191
+ colorScheme: parseChoice(options.theme, "--theme", ["dark", "light"]),
192
+ background: parseChoice(options.paper, "--paper", ["paper", "transparent"]),
193
+ format: videoFormat,
194
+ fps,
195
+ output: out,
196
+ quality: parseNumber(options.quality ?? "0.92", "--quality", { min: 0, max: 1 }),
197
+ withImages: options.images !== false,
198
+ });
199
+ if (jsonRequested(options))
200
+ return outJson({ file: out, ...result });
201
+ ok(`Exported ${result.width}×${result.height} ${videoFormat.toUpperCase()} to ${out}`);
202
+ for (const warning of result.warnings)
203
+ console.log(` ! ${warning}`);
204
+ return;
205
+ }
182
206
  const { runBoardExport } = await import("../board-export.js");
183
207
  const result = await runBoardExport({
184
208
  spaceId: await resolveSpace(boards),
185
- target: board,
209
+ target,
186
210
  region: parseExportRegion(options),
187
- scale: parseNumber(options.scale ?? "2", "scale", { min: 0.01, max: 16 }),
188
- ...(options.padding === undefined
189
- ? {}
190
- : { padding: parseNumber(options.padding, "padding", { min: 0 }) }),
191
- colorScheme: parseColorMode(options.theme),
192
- background: parseBackground(options.background),
211
+ ...(times ? { times } : {}),
212
+ ...(options.animation ? { animation: options.animation } : {}),
213
+ scale: parseNumber(options.scale ?? "2", "--scale", { min: 0.01, max: 16 }),
214
+ ...(options.padding === undefined ? {} : { padding: parseNumber(options.padding, "--padding", { min: 0 }) }),
215
+ colorScheme: parseChoice(options.theme, "--theme", ["dark", "light"]),
216
+ background: parseChoice(options.paper, "--paper", ["paper", "transparent"]),
193
217
  format: parseExportFormat(options, out),
194
- quality: parseNumber(options.quality ?? "0.92", "quality", { min: 0, max: 1 }),
218
+ quality: parseNumber(options.quality ?? "0.92", "--quality", { min: 0, max: 1 }),
195
219
  withImages: options.images !== false,
196
220
  });
197
- if (!result) {
198
- return error("Nothing to export", "The selected region contains no items.");
221
+ if (!result)
222
+ return error("Nothing to export: the region contains no items.");
223
+ const written = [];
224
+ for (const [index, frame] of result.frames.entries()) {
225
+ const path = sequence ? framePath(out, index) : out;
226
+ await mkdir(dirname(path), { recursive: true });
227
+ await writeBoardOutput(path, frame.bytes, Boolean(options.force));
228
+ written.push({ path, time: frame.time, width: frame.width, height: frame.height, bytes: frame.bytes.length });
199
229
  }
200
- await writeBoardOutput(out, result.bytes, Boolean(options.force));
201
- if (jsonRequested(options)) {
202
- return outJson({
203
- path: out,
204
- width: result.width,
205
- height: result.height,
206
- scale: result.scale,
207
- items: result.itemCount,
208
- format: result.format,
209
- bytes: result.bytes.length,
210
- warnings: result.warnings,
211
- });
230
+ if (jsonRequested(options))
231
+ return outJson({ files: written, warnings: result.warnings });
232
+ const first = result.frames[0];
233
+ if (first) {
234
+ const what = sequence ? `${written.length} frames` : (first.format ?? "png").toUpperCase();
235
+ ok(`Exported ${first.width}×${first.height} ${what} to ${sequence ? dirname(out) || "." : out}`);
212
236
  }
213
- ok(`Exported ${result.width}×${result.height} ${result.format.toUpperCase()} to ${out}`);
214
237
  for (const warning of result.warnings)
215
238
  console.log(` ! ${warning}`);
216
239
  }
@@ -219,194 +242,227 @@ function registerExportCommand(boards) {
219
242
  }
220
243
  });
221
244
  }
245
+ // ─── Commands ────────────────────────────────────────────────────────────────
222
246
  export function registerBoards(program) {
223
247
  const boards = program
224
248
  .command("boards")
225
- .description("Inspect and update Boards by ID or .board path")
226
- .hook("preAction", async () => { await resolveSpace(boards); });
249
+ .description("Read, write, animate and export Boards (by id or .board path)")
250
+ .addHelpText("after", `
251
+ A Board is one JSON document: { board, items, animations }.
252
+ get read it
253
+ apply write it with a JSON Merge Patch; null deletes
254
+ schema the schema of any part, and what tracks can animate
255
+ preset tracks for a preset motion, ready to apply
256
+ history list versions, or restore one
257
+ export render an image, a frame sequence or a video
258
+ examples starter documents
259
+ Times are milliseconds; 500ms, 12.5s and 2m also work on the command line.`);
227
260
  withJson(boards.command("create <path>")
228
- .description("Create a Board")
261
+ .description("Create a Board file, optionally with an initial document")
229
262
  .option("--title <title>", "Board title")
230
- .option("--mutation-id <id>", "Stable id for safe retries")
231
- .option("-i, --input <file>", "Semantic Board seed JSON; use - for stdin")
263
+ .option("-i, --input <file>", "Initial document JSON; - for stdin")
264
+ .option("--mutation-id <id>", "Stable id for safe retries"))
232
265
  .addHelpText("after", `
233
- Create an empty Board, or provide items, connections, effects, compositions, and metadata in --input.
234
- Generate an editable seed:
235
- cohub boards examples create > board.json
236
- cohub boards create plan.board -i board.json`))
266
+ cohub boards examples lesson > lesson.json
267
+ cohub boards create lesson.board -i lesson.json`)
237
268
  .action(async (path, options) => {
238
269
  try {
239
- const content = options.input
240
- ? await readBoardJsonObject(options.input, BOARD_CREATE_INPUT_MAX_BYTES)
241
- : {};
242
- if ("path" in content || "title" in content) {
243
- throw new Error("create input must not contain path or title; use the command argument and --title");
244
- }
270
+ const document = options.input ? await readBoardJsonObject(options.input, BOARD_CREATE_INPUT_MAX_BYTES) : undefined;
245
271
  const input = {
246
- ...content,
247
272
  path,
248
- mutationId: options.mutationId ??
249
- (typeof content.mutationId === "string" ? content.mutationId : randomUUID()),
273
+ mutationId: options.mutationId ?? randomUUID(),
250
274
  ...(options.title ? { title: options.title } : {}),
275
+ ...(document ? { document: document } : {}),
251
276
  };
252
277
  const result = await createClient().space(await resolveSpace(boards)).boards.create(input);
253
278
  if (jsonRequested(options))
254
- return outJson({ ...result, path });
255
- ok(`Board created: ${path}`);
256
- showCreated(result, path);
279
+ return outJson({ path, id: result.id, title: result.title, version: result.version });
280
+ ok(`Created ${path}`);
281
+ table([{ path, id: result.id, title: result.title, version: result.version }], [
282
+ { key: "path", label: "Path" },
283
+ { key: "id", label: "ID" },
284
+ { key: "title", label: "Title" },
285
+ { key: "version", label: "Version" },
286
+ ]);
257
287
  }
258
288
  catch (cause) {
259
289
  handleHttp(cause);
260
290
  }
261
291
  });
262
- withJson(boards.command("inspect <board>")
263
- .alias("get")
264
- .description("Show Board metadata and semantic resource counts")
292
+ boards.command("get <board>")
293
+ .description("Print the Board document as JSON")
294
+ .option("--only <sections>", "board, items, animations (comma-separated)")
295
+ .option("--items <ids>", "Only these items")
296
+ .option("--within <frame-id>", "A frame and everything inside it")
297
+ .option("--rect <rect>", "Items intersecting x,y,width,height")
298
+ .option("--animations <ids>", "Only these animations")
299
+ .option("--limit <n>", "Page size for items")
300
+ .option("--cursor <cursor>", "Continue from a previous page's next")
265
301
  .addHelpText("after", `
266
- Inspect resources with:
267
- cohub boards items list <board> --json
268
- cohub boards connections list <board> --json
269
- cohub boards effects list <board> --json
270
- cohub boards compositions list <board> --json
271
- Apply multiple changes atomically:
272
- cohub boards batch <board> --input changes.json --dry-run`))
302
+ cohub boards get plan.board
303
+ cohub boards get plan.board --within s2
304
+ cohub boards get plan.board --only board`)
273
305
  .action(async (target, options) => {
274
306
  try {
275
- const spaceId = await resolveSpace(boards);
276
- const boardId = await resolveBoardId(spaceId, target);
277
- const result = await createClient().space(spaceId).board(boardId).summary();
278
- if (jsonRequested(options))
307
+ const only = list(options.only);
308
+ if (only?.some((section) => section !== "board" && section !== "items" && section !== "animations"))
309
+ throw new Error("--only takes board, items, animations, or a combination.");
310
+ const sections = only ? new Set(only) : null;
311
+ const input = {
312
+ ...(only ? { only: only.filter((section) => section !== "board") } : {}),
313
+ ...(list(options.items) ? { items: list(options.items) } : {}),
314
+ ...(options.within ? { within: options.within } : {}),
315
+ ...(options.rect ? { rect: parseRect(options.rect) } : {}),
316
+ ...(list(options.animations) ? { animations: list(options.animations) } : {}),
317
+ ...(options.limit ? { limit: parseNumber(options.limit, "--limit", { min: 1, integer: true }) } : {}),
318
+ ...(options.cursor ? { cursor: options.cursor } : {}),
319
+ };
320
+ const result = await (await boardClient(boards, target)).get(input);
321
+ if (!sections)
279
322
  return outJson(result);
280
- showSummary(result);
323
+ outJson({
324
+ id: result.id,
325
+ title: result.title,
326
+ version: result.version,
327
+ updatedAt: result.updatedAt,
328
+ playback: result.playback,
329
+ ...(sections.has("board") ? { board: result.board } : {}),
330
+ ...(sections.has("items") && result.items ? { items: result.items } : {}),
331
+ ...(sections.has("animations") && result.animations ? { animations: result.animations } : {}),
332
+ ...(result.next ? { next: result.next } : {}),
333
+ });
281
334
  }
282
335
  catch (cause) {
283
336
  handleHttp(cause);
284
337
  }
285
338
  });
286
- withJson(boards.command("capabilities <board>")
287
- .description("Show authoring schemas and runtime capabilities")
339
+ withJson(boards.command("apply <board> [patch]")
340
+ .description("Merge a patch into the Board: fields merge, null deletes")
341
+ .option("-i, --input <file>", "Patch JSON; - for stdin")
342
+ .option("--replace", "Make the document equal to the input instead of merging")
343
+ .option("--cascade", "Also delete children and tracks of deleted items")
344
+ .option("--dry-run", "Validate without writing")
345
+ .option("--base-version <n>", "Fail if the Board moved past this version")
346
+ .option("--mutation-id <id>", "Stable id; retries never apply twice"))
288
347
  .addHelpText("after", `
289
- Use --json for complete Item, patch, mutation, effect, Composition, and create JSON Schemas.
290
- Coordinates:
291
- draw points and arrow start/end points are world-space.
292
- Position and size are optional; Board geometry derives the persisted frame automatically.
293
- Use boards examples for editable starter JSON.`))
294
- .action(async (target, options) => {
348
+ cohub boards apply plan.board '{"items":{"t1":{"type":"text","props":{"text":"Hi","fontSize":48}}}}'
349
+ cohub boards apply plan.board '{"items":{"t1":{"position":{"x":200}}}}'
350
+ cohub boards apply plan.board '{"items":{"t1":null}}'
351
+ cohub boards preset rise --targets a,b --animation intro | cohub boards apply plan.board -i -`)
352
+ .action(async (target, inline, options) => {
295
353
  try {
296
- const spaceId = await resolveSpace(boards);
297
- const boardId = await resolveBoardId(spaceId, target);
298
- const result = await createClient().space(spaceId).board(boardId).capabilities();
354
+ const patch = await readPatch(inline, options.input, BOARD_TRANSACTION_INPUT_MAX_BYTES);
355
+ const result = await (await boardClient(boards, target)).apply(patch, {
356
+ ...(options.replace ? { replace: true } : {}),
357
+ ...(options.cascade ? { cascade: true } : {}),
358
+ ...(options.dryRun ? { dryRun: true } : {}),
359
+ ...(options.baseVersion === undefined ? {} : { baseVersion: parseNumber(options.baseVersion, "--base-version", { min: 0, integer: true }) }),
360
+ ...(options.mutationId ? { mutationId: options.mutationId } : {}),
361
+ });
299
362
  if (jsonRequested(options))
300
363
  return outJson(result);
301
- table(result.capabilities.map((capability) => ({
302
- ...capability,
303
- renderers: capability.renderers?.join(", ") ?? "",
304
- coordinates: capabilityUnits(capability.schema),
305
- })), [
306
- { key: "kind", label: "Kind" },
307
- { key: "id", label: "ID" },
308
- { key: "version", label: "Version" },
309
- { key: "renderers", label: "Renderers" },
310
- { key: "coordinates", label: "Coordinates / units" },
311
- { key: "digest", label: "Digest" },
312
- ]);
313
- console.log();
314
- table([{
315
- types: result.items.types.join(", "),
316
- colors: result.items.colors.join(", "),
317
- shapes: result.items.shapes.join(", "),
318
- drawPoints: result.items.coordinates.drawPoints,
319
- arrowEndpoints: result.items.coordinates.arrowEndpoints,
320
- }], [
321
- { key: "types", label: "Item types" },
322
- { key: "colors", label: "Colors" },
323
- { key: "shapes", label: "Shapes" },
324
- { key: "drawPoints", label: "Draw points" },
325
- { key: "arrowEndpoints", label: "Arrow endpoints" },
326
- ]);
364
+ showApplied(result);
327
365
  }
328
366
  catch (cause) {
329
367
  handleHttp(cause);
330
368
  }
331
369
  });
332
- registerBoardDomainCommands(boards);
333
- registerExportCommand(boards);
334
- const playback = boards.command("playback").description("Control shared Board playback");
335
- withJson(playback.command("play <board> <composition-id>")
336
- .description("Start shared playback")
337
- .option("--position <time>", "Initial position in milliseconds")
338
- .option("--time-scale <scale>", "Playback speed from 0 to 4")
339
- .option("--seed <seed>", "Deterministic playback seed")
340
- .option("--command-id <id>", "Idempotency command ID"))
341
- .action(async (target, compositionId, options) => {
370
+ withJson(boards.command("history <board>")
371
+ .description("List Board versions, or restore one")
372
+ .option("--before <version>", "Versions older than this")
373
+ .option("--limit <n>", "Number of versions", "20")
374
+ .option("--restore <version>", "Return the Board to this version as a new write"))
375
+ .action(async (target, options) => {
342
376
  try {
343
- const spaceId = await resolveSpace(boards);
344
- const boardId = await resolveBoardId(spaceId, target);
345
- const result = await createClient().space(spaceId).board(boardId).play({
346
- commandId: commandId(options),
347
- type: "play",
348
- compositionId,
349
- shared: true,
350
- ...(options.position === undefined ? {} : { position: parseNumber(options.position, "position", { min: 0 }) }),
351
- ...(options.timeScale === undefined ? {} : { timeScale: parseNumber(options.timeScale, "timeScale", { min: Number.EPSILON, max: 4 }) }),
352
- ...(options.seed ? { seed: options.seed } : {}),
377
+ const board = await boardClient(boards, target);
378
+ if (options.restore !== undefined) {
379
+ const result = await board.restore(parseNumber(options.restore, "--restore", { min: 0, integer: true }));
380
+ if (jsonRequested(options))
381
+ return outJson(result);
382
+ return showApplied(result);
383
+ }
384
+ const page = await board.history({
385
+ ...(options.before ? { before: parseNumber(options.before, "--before", { min: 1, integer: true }) } : {}),
386
+ limit: parseNumber(options.limit ?? "20", "--limit", { min: 1, max: 500, integer: true }),
353
387
  });
354
388
  if (jsonRequested(options))
355
- return outJson(result);
356
- showPlayback(result);
389
+ return outJson(page);
390
+ table(page.transactions.map((transaction) => ({
391
+ version: transaction.version,
392
+ at: transaction.createdAt,
393
+ actor: transaction.source?.via ?? transaction.actorId,
394
+ items: Object.keys(transaction.after?.items ?? {}).length,
395
+ animations: new Set([...Object.keys(transaction.after?.animations ?? {}), ...Object.keys(transaction.after?.tracks ?? {})]).size,
396
+ })), [
397
+ { key: "version", label: "Version" },
398
+ { key: "at", label: "At" },
399
+ { key: "actor", label: "By" },
400
+ { key: "items", label: "Items" },
401
+ { key: "animations", label: "Animations" },
402
+ ]);
403
+ if (page.nextBefore)
404
+ console.log(` More: --before ${page.nextBefore}`);
357
405
  }
358
406
  catch (cause) {
359
407
  handleHttp(cause);
360
408
  }
361
409
  });
362
- const playbackAction = (type) => async (target, playbackId, options) => {
410
+ boards.command("schema [target]")
411
+ .description("Print JSON Schema for a part of a Board")
412
+ .addHelpText("after", `
413
+ With a target: one item type, or animation, track, or camera.
414
+ Targets: ${BOARD_SCHEMA_TARGETS.join(", ")}
415
+ Without a target: units, item types, colors and every animatable property.`)
416
+ .action((target) => {
363
417
  try {
364
- const spaceId = await resolveSpace(boards);
365
- const boardId = await resolveBoardId(spaceId, target);
366
- const board = createClient().space(spaceId).board(boardId);
367
- const id = commandId(options);
368
- const result = type === "pause"
369
- ? await board.pause({ commandId: id, type: "pause", playbackId })
370
- : await board.stop({ commandId: id, type: "stop", playbackId });
371
- if (jsonRequested(options))
372
- return outJson(result);
373
- showPlayback(result);
418
+ if (!target)
419
+ return outJson({ ...boardSchemaOverview(), presets: listBoardPresets() });
420
+ const schema = boardJsonSchema(target);
421
+ if (!schema)
422
+ throw new Error(`Unknown target ${target}; expected ${BOARD_SCHEMA_TARGETS.join(", ")}.`);
423
+ outJson(schema);
374
424
  }
375
425
  catch (cause) {
376
426
  handleHttp(cause);
377
427
  }
378
- };
379
- withJson(playback.command("pause <board> <playback-id>")
380
- .description("Pause playback")
381
- .option("--command-id <id>", "Idempotency command ID"))
382
- .action(playbackAction("pause"));
383
- withJson(playback.command("seek <board> <playback-id> <position>")
384
- .description("Seek playback")
385
- .option("--command-id <id>", "Idempotency command ID"))
386
- .action(async (target, playbackId, position, options) => {
428
+ });
429
+ boards.command("preset <name>")
430
+ .description("Print tracks for a preset motion as a patch for apply")
431
+ .requiredOption("--targets <ids>", "Comma-separated item ids")
432
+ .option("--animation <id>", "Wrap the tracks in this animation; omit to get bare tracks")
433
+ .option("--at <time>", "Start of the first target", "0")
434
+ .option("--stagger <time>", "Delay added per target", "0")
435
+ .option("--duration <time>", "Length of each target's motion")
436
+ .option("--ease <ease>", "CSS easing, e.g. ease-out or cubic-bezier(.2,.8,.2,1)")
437
+ .addHelpText("after", `
438
+ Presets: ${BOARD_PRESET_NAMES.join(", ")}
439
+ cohub boards preset rise --targets a,b,c --animation intro --stagger 120ms | cohub boards apply plan.board -i -
440
+ cohub boards preset float --targets ship | jq -c '{animations:{idle:.}}' | cohub boards apply plan.board -i -`)
441
+ .action((name, options) => {
387
442
  try {
388
- const spaceId = await resolveSpace(boards);
389
- const boardId = await resolveBoardId(spaceId, target);
390
- const result = await createClient().space(spaceId).board(boardId).seek({
391
- commandId: commandId(options),
392
- type: "seek",
393
- playbackId,
394
- position: parseNumber(position, "position", { min: 0 }),
443
+ if (!isBoardPresetName(name))
444
+ throw new Error(`Unknown preset ${name}; expected ${BOARD_PRESET_NAMES.join(", ")}.`);
445
+ const targets = list(options.targets);
446
+ if (!targets)
447
+ throw new Error("--targets needs at least one item id.");
448
+ const tracks = boardPresetTracks(name, {
449
+ targets,
450
+ at: parseBoardTime(options.at, "--at"),
451
+ stagger: parseBoardTime(options.stagger, "--stagger"),
452
+ ...(options.duration ? { duration: parseBoardTime(options.duration, "--duration") } : {}),
453
+ ...(options.ease ? { ease: options.ease } : {}),
395
454
  });
396
- if (jsonRequested(options))
397
- return outJson(result);
398
- showPlayback(result);
455
+ outJson(options.animation ? { animations: { [options.animation]: { tracks } } } : { tracks });
399
456
  }
400
457
  catch (cause) {
401
458
  handleHttp(cause);
402
459
  }
403
460
  });
404
- withJson(playback.command("stop <board> <playback-id>")
405
- .description("Stop playback")
406
- .option("--command-id <id>", "Idempotency command ID"))
407
- .action(playbackAction("stop"));
461
+ registerBoardExampleCommands(boards);
462
+ registerExportCommand(boards);
463
+ registerPlaybackCommands(boards);
408
464
  withJson(boards.command("watch <board>")
409
- .description("Stream Board events"))
465
+ .description("Stream Board changes and playback as they happen"))
410
466
  .action(async (target, options) => {
411
467
  try {
412
468
  const spaceId = await resolveSpace(boards);
@@ -414,17 +470,12 @@ Use boards examples for editable starter JSON.`))
414
470
  const client = createRealtimeClient();
415
471
  const board = client.space(spaceId).board(boardId);
416
472
  if (!jsonRequested(options))
417
- process.stderr.write(`Listening for Board ${boardId} events...\n`);
473
+ process.stderr.write(`Watching Board ${boardId}…\n`);
418
474
  const offConnection = client.onConnection((state) => {
419
- if (jsonRequested(options)) {
475
+ if (jsonRequested(options))
420
476
  process.stdout.write(`${JSON.stringify({ type: "connection", ...state })}\n`);
421
- }
422
- else {
423
- const detail = state.state === "reconnecting" && state.attempt
424
- ? ` (attempt ${state.attempt})`
425
- : "";
426
- process.stderr.write(`${state.state}${detail}\n`);
427
- }
477
+ else
478
+ process.stderr.write(`${state.state}${state.state === "reconnecting" && state.attempt ? ` (attempt ${state.attempt})` : ""}\n`);
428
479
  });
429
480
  const offBoard = board.subscribe({
430
481
  event(event) {
@@ -433,13 +484,12 @@ Use boards examples for editable starter JSON.`))
433
484
  return;
434
485
  }
435
486
  if (event.type === "board.changed") {
436
- process.stdout.write(`version ${event.payload.version} mutation ${event.payload.mutationId}\n`);
487
+ const { changed } = event.payload;
488
+ process.stdout.write(`version ${event.payload.version} ${[changed.board ? "board" : "", ...changed.items, ...changed.animations].filter(Boolean).join(", ")}\n`);
437
489
  }
438
490
  else if (event.type === "board.playback.changed") {
439
- process.stdout.write(`${event.payload.status} composition ${event.payload.compositionId} position ${event.payload.position}\n`);
440
- }
441
- else if (event.type === "board.awareness.updated") {
442
- process.stdout.write(`awareness ${event.payload.actorName} ${event.payload.update.type}\n`);
491
+ const { playback } = event.payload;
492
+ process.stdout.write(playback ? `${playback.status} ${playback.animationId} ${formatBoardTime(Math.round(playback.position))}\n` : "stopped\n");
443
493
  }
444
494
  },
445
495
  });
@@ -455,3 +505,32 @@ Use boards examples for editable starter JSON.`))
455
505
  });
456
506
  return boards;
457
507
  }
508
+ function registerPlaybackCommands(boards) {
509
+ const run = async (target, options, command) => {
510
+ try {
511
+ const playback = await command(await boardClient(boards, target));
512
+ if (jsonRequested(options))
513
+ return outJson({ playback });
514
+ showPlayback(playback);
515
+ }
516
+ catch (cause) {
517
+ handleHttp(cause);
518
+ }
519
+ };
520
+ withJson(boards.command("play <board> <animation>")
521
+ .description("Play an animation for everyone viewing the Board")
522
+ .option("--at <time>", "Start position, e.g. 2.5s")
523
+ .option("--speed <factor>", "Playback speed, up to 4")
524
+ .option("--seed <seed>", "Seed for procedural effects"))
525
+ .action((target, animation, options) => run(target, options, (board) => board.play(animation, {
526
+ ...(options.at ? { position: parseBoardTime(options.at, "--at") } : {}),
527
+ ...(options.speed ? { timeScale: parseNumber(options.speed, "--speed", { min: Number.EPSILON, max: 4 }) } : {}),
528
+ ...(options.seed ? { seed: options.seed } : {}),
529
+ })));
530
+ withJson(boards.command("pause <board>").description("Pause playback")).action((target, options) => run(target, options, (board) => board.pause()));
531
+ withJson(boards.command("resume <board>").description("Resume paused playback")).action((target, options) => run(target, options, (board) => board.resume()));
532
+ withJson(boards.command("seek <board> <time>").description("Move the playhead, e.g. 12.5s"))
533
+ .action((target, time, options) => run(target, options, (board) => board.seek(parseBoardTime(time))));
534
+ withJson(boards.command("next <board>").description("Continue past the marker a presentation holds at")).action((target, options) => run(target, options, (board) => board.next()));
535
+ withJson(boards.command("stop <board>").description("Stop playback")).action((target, options) => run(target, options, (board) => board.stop()));
536
+ }