@neta-art/cohub-cli 8.4.1 → 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 (68) hide show
  1. package/README.md +25 -2
  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 +346 -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 +12 -8
  24. package/dist/commands/search.js +15 -7
  25. package/dist/commands/space-activity.js +3 -5
  26. package/dist/commands/space-displays.d.ts +2 -0
  27. package/dist/commands/space-displays.js +347 -0
  28. package/dist/commands/space-files.d.ts +5 -0
  29. package/dist/commands/space-files.js +215 -0
  30. package/dist/commands/space-invitations.js +3 -3
  31. package/dist/commands/spaces.d.ts +3 -11
  32. package/dist/commands/spaces.js +146 -75
  33. package/dist/index.js +7 -4
  34. package/dist/runtime/launch.d.ts +2 -0
  35. package/dist/runtime/launch.js +35 -8
  36. package/dist/runtime/presentation.d.ts +1 -0
  37. package/dist/runtime/presentation.js +3 -2
  38. package/dist/runtime/supervisor.d.ts +1 -0
  39. package/dist/runtime/supervisor.js +2 -1
  40. package/dist/space-files/limit.d.ts +2 -0
  41. package/dist/space-files/limit.js +16 -0
  42. package/dist/space-files/operands.d.ts +16 -0
  43. package/dist/space-files/operands.js +33 -0
  44. package/dist/space-files/transfer.d.ts +53 -0
  45. package/dist/space-files/transfer.js +296 -0
  46. package/dist/space-files/upload.d.ts +14 -0
  47. package/dist/space-files/upload.js +53 -0
  48. package/dist/space.d.ts +18 -16
  49. package/dist/space.js +86 -39
  50. package/dist/web.d.ts +1 -0
  51. package/dist/web.js +6 -0
  52. package/package.json +2 -2
  53. package/dist/commands/board-domain.d.ts +0 -2
  54. package/dist/commands/board-domain.js +0 -16
  55. package/dist/commands/boards/animation.d.ts +0 -2
  56. package/dist/commands/boards/animation.js +0 -171
  57. package/dist/commands/boards/appearance.d.ts +0 -2
  58. package/dist/commands/boards/appearance.js +0 -127
  59. package/dist/commands/boards/batch.d.ts +0 -4
  60. package/dist/commands/boards/batch.js +0 -62
  61. package/dist/commands/boards/connections.d.ts +0 -2
  62. package/dist/commands/boards/connections.js +0 -95
  63. package/dist/commands/boards/context.d.ts +0 -31
  64. package/dist/commands/boards/context.js +0 -71
  65. package/dist/commands/boards/items.d.ts +0 -2
  66. package/dist/commands/boards/items.js +0 -184
  67. package/dist/commands/boards/transactions.d.ts +0 -12
  68. 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
148
  .option("--background <mode>", "paper or transparent", "paper")
173
- .option("--format <format>", `Override format (${BOARD_EXPORT_FORMATS.join(", ")})`)
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.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
+ }
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.background, "--background", ["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,208 @@ 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
+ examples starter documents
256
+ Times are milliseconds; 500ms, 12.5s and 2m also work on the command line.`);
227
257
  withJson(boards.command("create <path>")
228
- .description("Create a Board")
258
+ .description("Create a Board file, optionally with an initial document")
229
259
  .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")
260
+ .option("-i, --input <file>", "Initial document JSON; - for stdin")
261
+ .option("--mutation-id <id>", "Stable id for safe retries"))
232
262
  .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`))
263
+ cohub boards examples lesson > lesson.json
264
+ cohub boards create lesson.board -i lesson.json`)
237
265
  .action(async (path, options) => {
238
266
  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
- }
267
+ const document = options.input ? await readBoardJsonObject(options.input, BOARD_CREATE_INPUT_MAX_BYTES) : undefined;
245
268
  const input = {
246
- ...content,
247
269
  path,
248
- mutationId: options.mutationId ??
249
- (typeof content.mutationId === "string" ? content.mutationId : randomUUID()),
270
+ mutationId: options.mutationId ?? randomUUID(),
250
271
  ...(options.title ? { title: options.title } : {}),
272
+ ...(document ? { document: document } : {}),
251
273
  };
252
274
  const result = await createClient().space(await resolveSpace(boards)).boards.create(input);
253
275
  if (jsonRequested(options))
254
- return outJson({ ...result, path });
255
- ok(`Board created: ${path}`);
256
- 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
+ ]);
257
284
  }
258
285
  catch (cause) {
259
286
  handleHttp(cause);
260
287
  }
261
288
  });
262
- withJson(boards.command("inspect <board>")
263
- .alias("get")
264
- .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")
265
298
  .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`))
299
+ cohub boards get plan.board
300
+ cohub boards get plan.board --within s2
301
+ cohub boards get plan.board --only animations`)
273
302
  .action(async (target, options) => {
274
303
  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))
279
- return outJson(result);
280
- 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));
281
317
  }
282
318
  catch (cause) {
283
319
  handleHttp(cause);
284
320
  }
285
321
  });
286
- withJson(boards.command("capabilities <board>")
287
- .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"))
288
330
  .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) => {
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) => {
295
336
  try {
296
- const spaceId = await resolveSpace(boards);
297
- const boardId = await resolveBoardId(spaceId, target);
298
- 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
+ });
299
345
  if (jsonRequested(options))
300
346
  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
- ]);
347
+ showApplied(result);
327
348
  }
328
349
  catch (cause) {
329
350
  handleHttp(cause);
330
351
  }
331
352
  });
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) => {
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) => {
342
359
  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 } : {}),
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 }),
353
370
  });
354
371
  if (jsonRequested(options))
355
- return outJson(result);
356
- 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}`);
357
388
  }
358
389
  catch (cause) {
359
390
  handleHttp(cause);
360
391
  }
361
392
  });
362
- 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) => {
363
399
  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);
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);
374
406
  }
375
407
  catch (cause) {
376
408
  handleHttp(cause);
377
409
  }
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) => {
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) => {
387
423
  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 }),
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 } : {}),
395
435
  });
396
- if (jsonRequested(options))
397
- return outJson(result);
398
- showPlayback(result);
436
+ outJson({ animations: { [options.animation]: { tracks } } });
399
437
  }
400
438
  catch (cause) {
401
439
  handleHttp(cause);
402
440
  }
403
441
  });
404
- withJson(playback.command("stop <board> <playback-id>")
405
- .description("Stop playback")
406
- .option("--command-id <id>", "Idempotency command ID"))
407
- .action(playbackAction("stop"));
442
+ registerBoardExampleCommands(boards);
443
+ registerExportCommand(boards);
444
+ registerPlaybackCommands(boards);
408
445
  withJson(boards.command("watch <board>")
409
- .description("Stream Board events"))
446
+ .description("Stream Board changes and playback as they happen"))
410
447
  .action(async (target, options) => {
411
448
  try {
412
449
  const spaceId = await resolveSpace(boards);
@@ -414,17 +451,12 @@ Use boards examples for editable starter JSON.`))
414
451
  const client = createRealtimeClient();
415
452
  const board = client.space(spaceId).board(boardId);
416
453
  if (!jsonRequested(options))
417
- process.stderr.write(`Listening for Board ${boardId} events...\n`);
454
+ process.stderr.write(`Watching Board ${boardId}…\n`);
418
455
  const offConnection = client.onConnection((state) => {
419
- if (jsonRequested(options)) {
456
+ if (jsonRequested(options))
420
457
  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
- }
458
+ else
459
+ process.stderr.write(`${state.state}${state.state === "reconnecting" && state.attempt ? ` (attempt ${state.attempt})` : ""}\n`);
428
460
  });
429
461
  const offBoard = board.subscribe({
430
462
  event(event) {
@@ -433,13 +465,12 @@ Use boards examples for editable starter JSON.`))
433
465
  return;
434
466
  }
435
467
  if (event.type === "board.changed") {
436
- 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`);
437
470
  }
438
471
  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`);
472
+ const { playback } = event.payload;
473
+ process.stdout.write(playback ? `${playback.status} ${playback.animationId} ${formatBoardTime(Math.round(playback.position))}\n` : "stopped\n");
443
474
  }
444
475
  },
445
476
  });
@@ -455,3 +486,32 @@ Use boards examples for editable starter JSON.`))
455
486
  });
456
487
  return boards;
457
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
+ }