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