@neta-art/cohub-cli 3.10.2 → 3.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -329,17 +329,21 @@ through `client.work.realtime` in the SDK rather than as CLI commands.
329
329
 
330
330
  ## Drive the Cohub UI
331
331
 
332
- Show a Work preview in the Cohub tab that started the current work, and call
332
+ Show a file or Work preview in the Cohub tab that started the current work, and call
333
333
  methods the Work exposes.
334
334
 
335
335
  ```bash
336
- cohub ui preview <workId|url|cohub://works/...|username/space/work>
337
- cohub ui preview <work> --call selection.get
336
+ cohub ui preview <workId|url|cohub://works/...|username/space/work|file://path>
337
+ cohub ui preview file://src/main.ts
338
+ cohub ui preview work://alice/studio/launch
339
+ cohub ui preview <work-or-file> --call selection.get
338
340
  cohub ui preview <work> --call board.focus --data '{"nodeId":"n1"}'
339
341
  cohub ui preview <work> --call report.build --input payload.json --json
340
342
  ```
341
343
 
342
- `ui preview` accepts the same Work references as `works get`. Showing a preview is
344
+ `ui preview` accepts `file://` Space-relative paths, `work://` Work references, and
345
+ legacy bare targets. A bare target checks the current Space for a file first, then
346
+ falls back to the same Work references as `works get`. Showing a preview is
343
347
  idempotent: repeating it re-activates the same tab and refreshes any launch state
344
348
  carried by the reference. With `--call`, the command waits for the Work to announce readiness, invokes the method,
345
349
  and waits for the Work to complete the same UI command with `client.ui.reportResult()`.
@@ -0,0 +1,7 @@
1
+ export declare const BOARD_TRANSACTION_INPUT_MAX_BYTES: number;
2
+ export declare const BOARD_CREATE_INPUT_MAX_BYTES: number;
3
+ export declare const BOARD_DOMAIN_INPUT_MAX_BYTES: number;
4
+ export declare function parseBoardJsonObject(text: string, source?: string): Record<string, unknown>;
5
+ export declare function readBoardJsonObject(source: string, maxBytes?: number): Promise<Record<string, unknown>>;
6
+ export declare function resolveBoardId(spaceId: string, target: string): Promise<string>;
7
+ export declare function writeBoardOutput(path: string, bytes: Uint8Array, force?: boolean): Promise<void>;
@@ -0,0 +1,99 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { constants } from "node:fs";
3
+ import { access, link, open, rename, unlink } from "node:fs/promises";
4
+ import { basename, dirname, join } from "node:path";
5
+ import { isBoardPath, parseBoardManifest, } from "@neta-art/cohub/board";
6
+ import { createClient } from "./client.js";
7
+ export const BOARD_TRANSACTION_INPUT_MAX_BYTES = 16 * 1024 * 1024;
8
+ export const BOARD_CREATE_INPUT_MAX_BYTES = 32 * 1024 * 1024;
9
+ export const BOARD_DOMAIN_INPUT_MAX_BYTES = 1024 * 1024;
10
+ export function parseBoardJsonObject(text, source = "input") {
11
+ if (!text.trim())
12
+ throw new Error(`${source} is empty`);
13
+ let value;
14
+ try {
15
+ value = JSON.parse(text);
16
+ }
17
+ catch (cause) {
18
+ throw new Error(`${source} must contain valid JSON`, { cause });
19
+ }
20
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
21
+ throw new Error(`${source} must contain a JSON object`);
22
+ }
23
+ return value;
24
+ }
25
+ async function readStdinBounded(maxBytes) {
26
+ const chunks = [];
27
+ let size = 0;
28
+ for await (const chunk of process.stdin) {
29
+ const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
30
+ size += bytes.byteLength;
31
+ if (size > maxBytes)
32
+ throw new Error(`stdin exceeds the ${maxBytes} byte input limit`);
33
+ chunks.push(bytes);
34
+ }
35
+ return Buffer.concat(chunks).toString("utf8");
36
+ }
37
+ export async function readBoardJsonObject(source, maxBytes = BOARD_DOMAIN_INPUT_MAX_BYTES) {
38
+ if (source === "-")
39
+ return parseBoardJsonObject(await readStdinBounded(maxBytes), "stdin");
40
+ const handle = await open(source, constants.O_RDONLY | constants.O_NONBLOCK);
41
+ try {
42
+ const info = await handle.stat();
43
+ if (!info.isFile())
44
+ throw new Error(`${source} must be a regular file`);
45
+ if (info.size > maxBytes)
46
+ throw new Error(`${source} exceeds the ${maxBytes} byte input limit`);
47
+ const chunks = [];
48
+ const buffer = Buffer.allocUnsafe(Math.max(1, Math.min(64 * 1024, maxBytes + 1)));
49
+ let size = 0;
50
+ for (;;) {
51
+ const { bytesRead } = await handle.read(buffer, 0, buffer.byteLength, null);
52
+ if (bytesRead === 0)
53
+ break;
54
+ size += bytesRead;
55
+ if (size > maxBytes)
56
+ throw new Error(`${source} exceeds the ${maxBytes} byte input limit`);
57
+ chunks.push(Buffer.from(buffer.subarray(0, bytesRead)));
58
+ }
59
+ return parseBoardJsonObject(Buffer.concat(chunks).toString("utf8"), source);
60
+ }
61
+ finally {
62
+ await handle.close();
63
+ }
64
+ }
65
+ export async function resolveBoardId(spaceId, target) {
66
+ if (!isBoardPath(target))
67
+ return target;
68
+ const file = await createClient().space(spaceId).files.read(target);
69
+ if (!("content" in file) || typeof file.content !== "string") {
70
+ throw new Error(`${target} is not a readable Board manifest`);
71
+ }
72
+ return parseBoardManifest(file.content).boardId;
73
+ }
74
+ export async function writeBoardOutput(path, bytes, force = false) {
75
+ if (!force) {
76
+ await access(path).then(() => { throw new Error(`Output already exists: ${path}; use --force to replace it`); }, () => undefined);
77
+ }
78
+ const temp = join(dirname(path), `.${basename(path)}.${randomUUID()}.tmp`);
79
+ try {
80
+ const handle = await open(temp, "wx");
81
+ try {
82
+ await handle.writeFile(bytes);
83
+ await handle.sync();
84
+ }
85
+ finally {
86
+ await handle.close();
87
+ }
88
+ if (force)
89
+ await rename(temp, path);
90
+ else {
91
+ await link(temp, path);
92
+ await unlink(temp);
93
+ }
94
+ }
95
+ catch (cause) {
96
+ await unlink(temp).catch(() => undefined);
97
+ throw cause;
98
+ }
99
+ }
@@ -9,9 +9,11 @@
9
9
  import { existsSync } from "node:fs";
10
10
  import { createRequire } from "node:module";
11
11
  import { dirname, join } from "node:path";
12
- import { boardBootstrapToDocument, boardImageKeySource, imageAssetKey, isBoardPath, parseBoardManifest, planBoardExport, selectBoardExportAssets, } from "@neta-art/cohub/board";
12
+ import { boardBootstrapToDocument, boardImageKeySource, imageAssetKey, planBoardExport, selectBoardExportAssets, } from "@neta-art/cohub/board";
13
13
  import { createBoardHeadlessRenderer, exportBoardImageBytes, } from "@neta-art/cohub/board/headless";
14
+ import { resolveBoardId } from "./board-command-support.js";
14
15
  import { createClient } from "./client.js";
16
+ import { downloadPublicImage } from "./safe-remote-image.js";
15
17
  export const BOARD_EXPORT_FORMATS = ["png", "jpeg", "webp"];
16
18
  /** Infer the output format from the file extension, defaulting to PNG. */
17
19
  export function formatFromPath(path) {
@@ -61,9 +63,7 @@ export function resolveBundledFonts() {
61
63
  */
62
64
  export async function loadBoardDocument(spaceId, target) {
63
65
  const client = createClient();
64
- const boardId = isBoardPath(target)
65
- ? await resolveManifestBoardId(spaceId, target)
66
- : target;
66
+ const boardId = await resolveBoardId(spaceId, target);
67
67
  const bootstrap = await client.space(spaceId).board(boardId).inspect({ include: ["nodes"] });
68
68
  return {
69
69
  document: boardBootstrapToDocument(bootstrap),
@@ -71,13 +71,6 @@ export async function loadBoardDocument(spaceId, target) {
71
71
  title: bootstrap.board.title ?? null,
72
72
  };
73
73
  }
74
- async function resolveManifestBoardId(spaceId, path) {
75
- const file = await createClient().space(spaceId).files.read(path);
76
- if (!("content" in file) || typeof file.content !== "string") {
77
- throw new Error(`${path} is not a readable board manifest.`);
78
- }
79
- return parseBoardManifest(file.content).boardId;
80
- }
81
74
  /**
82
75
  * Fetch every image in `items`, keyed the way the renderers ask for it.
83
76
  *
@@ -92,7 +85,7 @@ export async function loadBoardTextures(headless, spaceId, items, options = {})
92
85
  const textures = new Map();
93
86
  const failed = [];
94
87
  const pending = [...selection.keys];
95
- const concurrency = Math.max(1, Math.min(options.concurrency ?? 6, 16));
88
+ const concurrency = Math.max(1, Math.min(options.concurrency ?? 4, 16));
96
89
  const client = createClient();
97
90
  async function worker() {
98
91
  for (;;) {
@@ -107,7 +100,7 @@ export async function loadBoardTextures(headless, spaceId, items, options = {})
107
100
  try {
108
101
  const { bytes, mimeType } = source.kind === "file"
109
102
  ? await readSpaceFileBytes(client, spaceId, source.value)
110
- : await readUrlBytes(source.value);
103
+ : await downloadPublicImage(source.value);
111
104
  const texture = await headless.decodeImage(bytes, mimeType);
112
105
  textures.set(key, texture);
113
106
  }
@@ -123,15 +116,6 @@ async function readSpaceFileBytes(client, spaceId, path) {
123
116
  const { blob, mimeType } = await client.space(spaceId).files.download(path);
124
117
  return { bytes: new Uint8Array(await blob.arrayBuffer()), mimeType };
125
118
  }
126
- async function readUrlBytes(url) {
127
- const response = await fetch(url);
128
- if (!response.ok)
129
- throw new Error(`HTTP ${response.status}`);
130
- return {
131
- bytes: new Uint8Array(await response.arrayBuffer()),
132
- mimeType: response.headers.get("content-type") ?? "image/png",
133
- };
134
- }
135
119
  /** Render a board to image bytes. Returns null when the region is empty. */
136
120
  export async function runBoardExport(options) {
137
121
  const { document } = await loadBoardDocument(options.spaceId, options.target);
@@ -149,6 +133,7 @@ export async function runBoardExport(options) {
149
133
  try {
150
134
  const warnings = [];
151
135
  let textures;
136
+ let backgroundTexture;
152
137
  let omittedKeys = new Set();
153
138
  if (options.withImages) {
154
139
  const loaded = await loadBoardTextures(headless, options.spaceId, plan.items);
@@ -161,6 +146,19 @@ export async function runBoardExport(options) {
161
146
  warnings.push(`${loaded.omitted.length} previews were drawn as placeholders to stay within the export texture limit.`);
162
147
  }
163
148
  }
149
+ const declaredBackground = document.appearance.background;
150
+ if (options.withImages &&
151
+ options.background === "paper" &&
152
+ declaredBackground.kind === "image" &&
153
+ declaredBackground.imageUrl) {
154
+ try {
155
+ const { bytes, mimeType } = await downloadPublicImage(declaredBackground.imageUrl);
156
+ backgroundTexture = await headless.decodeImage(bytes, mimeType);
157
+ }
158
+ catch {
159
+ warnings.push("The board background image could not be loaded; the fallback color was exported.");
160
+ }
161
+ }
164
162
  const videoCount = plan.items.filter((item) => item.type === "video").length;
165
163
  if (videoCount > 0) {
166
164
  warnings.push(`${videoCount} video preview${videoCount === 1 ? " was" : "s were"} drawn as placeholders; headless video decoding is unavailable.`);
@@ -172,6 +170,14 @@ export async function runBoardExport(options) {
172
170
  colorScheme: options.colorScheme,
173
171
  background: options.background,
174
172
  textures,
173
+ backgroundImage: backgroundTexture
174
+ ? {
175
+ texture: backgroundTexture,
176
+ fit: declaredBackground.fit ?? "cover",
177
+ position: declaredBackground.position ?? "center",
178
+ opacity: declaredBackground.opacity ?? 1,
179
+ }
180
+ : undefined,
175
181
  ...(options.withImages
176
182
  ? {
177
183
  assetKey: (item) => {
@@ -0,0 +1,2 @@
1
+ import type { Command } from "commander";
2
+ export declare function registerBoardDomainCommands(boards: Command): void;
@@ -0,0 +1,8 @@
1
+ import { registerBoardAnimationCommands } from "./boards/animation.js";
2
+ import { registerBoardAppearanceCommands } from "./boards/appearance.js";
3
+ import { registerBoardNodeCommands } from "./boards/nodes.js";
4
+ export function registerBoardDomainCommands(boards) {
5
+ registerBoardAppearanceCommands(boards);
6
+ registerBoardNodeCommands(boards);
7
+ registerBoardAnimationCommands(boards);
8
+ }
@@ -0,0 +1,2 @@
1
+ import type { Command } from "commander";
2
+ export declare function registerBoardAnimationCommands(boards: Command): void;
@@ -0,0 +1,227 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { BoardCameraFocusParamsSchema, boardEffectDeleteOperation, boardEffectUpsertOperation, boardSequenceDeleteOperation, boardSequenceUpsertOperation, } from "@neta-art/cohub/board";
3
+ import { BOARD_DOMAIN_INPUT_MAX_BYTES, readBoardJsonObject, } from "../../board-command-support.js";
4
+ import { handleHttp, json, jsonRequested, table } from "../../output.js";
5
+ import { finite, resolvedBoard, showUpdated, withJson, } from "./context.js";
6
+ function rect(value) {
7
+ const parts = value.split(",").map(Number);
8
+ if (parts.length !== 4 || parts.some((part) => !Number.isFinite(part))) {
9
+ throw new Error("--rect must be x,y,width,height");
10
+ }
11
+ const [x, y, width, height] = parts;
12
+ if (width <= 0 || height <= 0)
13
+ throw new Error("--rect width and height must be positive");
14
+ return { x, y, width, height };
15
+ }
16
+ export function registerBoardAnimationCommands(boards) {
17
+ const effects = boards.command("effects").description("Manage Board effects");
18
+ withJson(effects.command("list <board>").alias("ls").description("List effects"))
19
+ .action(async (target, options) => {
20
+ try {
21
+ const board = await resolvedBoard(boards, target);
22
+ const result = await board.inspect({ include: ["effects"] });
23
+ if (jsonRequested(options))
24
+ return json(result.effects);
25
+ table(result.effects, [
26
+ { key: "id", label: "ID" },
27
+ { key: "kind", label: "KIND" },
28
+ { key: "enabled", label: "ENABLED" },
29
+ { key: "layer", label: "LAYER" },
30
+ ]);
31
+ }
32
+ catch (cause) {
33
+ handleHttp(cause);
34
+ }
35
+ });
36
+ withJson(effects.command("upsert <board>")
37
+ .description("Create or replace an effect")
38
+ .requiredOption("-i, --input <file>", "Board effect JSON; use - for stdin")
39
+ .addHelpText("after", `
40
+ Minimal pulse effect:
41
+ {"id":"pulse-title","target":{"type":"node","nodeId":"title"},"kind":"effects.pulse","kindVersion":1,"lifecycle":"when-visible","timeOrigin":"visible","seed":"pulse-title"}
42
+
43
+ Run boards capabilities <board> to discover supported effect kinds.`))
44
+ .action(async (target, options) => {
45
+ try {
46
+ const effect = await readBoardJsonObject(options.input, BOARD_DOMAIN_INPUT_MAX_BYTES);
47
+ const board = await resolvedBoard(boards, target);
48
+ showUpdated(await board.mutate({ build: () => [boardEffectUpsertOperation(effect)] }), options);
49
+ }
50
+ catch (cause) {
51
+ handleHttp(cause);
52
+ }
53
+ });
54
+ withJson(effects.command("delete <board> <effect-id>").alias("rm").description("Delete an effect"))
55
+ .action(async (target, effectId, options) => {
56
+ try {
57
+ const board = await resolvedBoard(boards, target);
58
+ showUpdated(await board.mutate({ build: () => [boardEffectDeleteOperation(effectId)] }), options);
59
+ }
60
+ catch (cause) {
61
+ handleHttp(cause);
62
+ }
63
+ });
64
+ const sequences = boards.command("sequences").description("Manage Board sequences");
65
+ withJson(sequences.command("list <board>").alias("ls").description("List sequences"))
66
+ .action(async (target, options) => {
67
+ try {
68
+ const board = await resolvedBoard(boards, target);
69
+ const result = await board.inspect({ include: ["sequences"] });
70
+ if (jsonRequested(options))
71
+ return json(result.sequences);
72
+ table(result.sequences, [
73
+ { key: "id", label: "ID" },
74
+ { key: "name", label: "NAME" },
75
+ { key: "duration", label: "DURATION" },
76
+ { key: "revision", label: "REVISION" },
77
+ ]);
78
+ }
79
+ catch (cause) {
80
+ handleHttp(cause);
81
+ }
82
+ });
83
+ withJson(sequences.command("get <board> <sequence-id>").description("Get a sequence and its clips"))
84
+ .action(async (target, sequenceId, options) => {
85
+ try {
86
+ const board = await resolvedBoard(boards, target);
87
+ const result = await board.inspect({ include: ["sequences", "clips"] });
88
+ const sequence = result.sequences.find((item) => item.id === sequenceId);
89
+ if (!sequence)
90
+ throw new Error(`Sequence not found: ${sequenceId}`);
91
+ const output = {
92
+ sequence,
93
+ clips: result.clips.filter((clip) => clip.sequenceId === sequenceId),
94
+ };
95
+ if (jsonRequested(options))
96
+ return json(output);
97
+ table([sequence], [
98
+ { key: "id", label: "ID" },
99
+ { key: "name", label: "NAME" },
100
+ { key: "duration", label: "DURATION" },
101
+ { key: "revision", label: "REVISION" },
102
+ ]);
103
+ table(output.clips, [
104
+ { key: "id", label: "CLIP" },
105
+ { key: "kind", label: "KIND" },
106
+ { key: "start", label: "START" },
107
+ { key: "duration", label: "DURATION" },
108
+ ]);
109
+ }
110
+ catch (cause) {
111
+ handleHttp(cause);
112
+ }
113
+ });
114
+ withJson(sequences.command("upsert <board>")
115
+ .description("Create or replace a sequence and clips")
116
+ .requiredOption("-i, --input <file>", "{ sequence, clips } JSON; use - for stdin")
117
+ .addHelpText("after", `
118
+ Minimal sequence with one clip:
119
+ {"sequence":{"id":"intro","name":"Intro","duration":1200,"seed":"intro"},"clips":[{"id":"reveal-title","kind":"text.reveal","kindVersion":1,"target":{"type":"node","nodeId":"title"},"start":0,"duration":600,"seed":"reveal-title"}]}
120
+
121
+ Coordinate rules:
122
+ node motion x/y and path points are Board-world offsets; camera.pan x/y are screen-pixel offsets.
123
+
124
+ Edit an existing sequence:
125
+ cohub boards sequences get <board> intro --json > intro.json
126
+ cohub boards sequences upsert <board> -i intro.json`))
127
+ .action(async (target, options) => {
128
+ try {
129
+ const input = await readBoardJsonObject(options.input, BOARD_DOMAIN_INPUT_MAX_BYTES);
130
+ const board = await resolvedBoard(boards, target);
131
+ showUpdated(await board.mutate({ build: () => [boardSequenceUpsertOperation(input)] }), options);
132
+ }
133
+ catch (cause) {
134
+ handleHttp(cause);
135
+ }
136
+ });
137
+ withJson(sequences.command("camera-focus <board> <sequence-id>")
138
+ .description("Add or replace a semantic camera focus clip")
139
+ .option("--id <id>", "Stable clip id")
140
+ .option("--node <id>", "Focus one node")
141
+ .option("--nodes <ids>", "Focus comma-separated nodes")
142
+ .option("--frame <id>", "Focus a frame")
143
+ .option("--rect <rect>", "Board world rect as x,y,width,height")
144
+ .requiredOption("--at <ms>", "Clip start time")
145
+ .option("--duration <ms>", "Transition duration", "700")
146
+ .option("--padding <px>", "Screen padding in CSS pixels", "32")
147
+ .option("--fit <mode>", "contain or cover", "contain")
148
+ .option("--min-zoom <zoom>", "Minimum zoom multiplier")
149
+ .option("--max-zoom <zoom>", "Maximum zoom multiplier")
150
+ .option("--easing <name>", "Easing", "ease-out-cubic")
151
+ .addHelpText("after", `
152
+ Examples:
153
+ cohub boards sequences camera-focus plan.board intro --node hero --at 1200
154
+ cohub boards sequences camera-focus plan.board intro --rect 120,80,640,360 --at 2000 --duration 800`))
155
+ .action(async (target, sequenceId, options) => {
156
+ try {
157
+ const selected = [options.node, options.nodes, options.frame, options.rect].filter(Boolean);
158
+ if (selected.length !== 1)
159
+ throw new Error("Choose one of --node, --nodes, --frame, or --rect");
160
+ const focus = options.node
161
+ ? { type: "node", nodeId: options.node }
162
+ : options.nodes
163
+ ? { type: "nodes", nodeIds: options.nodes.split(",").map((id) => id.trim()).filter(Boolean) }
164
+ : options.frame
165
+ ? { type: "frame", frameId: options.frame }
166
+ : { type: "rect", rect: rect(options.rect) };
167
+ const params = BoardCameraFocusParamsSchema.parse({
168
+ focus,
169
+ fit: options.fit,
170
+ padding: finite(options.padding, "padding"),
171
+ ...(options.minZoom === undefined ? {} : { minZoom: finite(options.minZoom, "min zoom") }),
172
+ ...(options.maxZoom === undefined ? {} : { maxZoom: finite(options.maxZoom, "max zoom") }),
173
+ });
174
+ const start = finite(options.at, "start");
175
+ const duration = finite(options.duration, "duration");
176
+ if (start < 0 || duration <= 0)
177
+ throw new Error("start must be non-negative and duration must be positive");
178
+ const clipId = options.id ?? randomUUID();
179
+ const board = await resolvedBoard(boards, target);
180
+ showUpdated(await board.mutate({
181
+ include: ["sequences", "clips"],
182
+ build(current) {
183
+ const currentSequence = current.sequences.find((sequence) => sequence.id === sequenceId);
184
+ if (!currentSequence)
185
+ throw new Error(`Sequence not found: ${sequenceId}`);
186
+ const { boardId: _boardId, revision: _revision, ...sequence } = currentSequence;
187
+ const clips = current.clips
188
+ .filter((clip) => clip.sequenceId === sequenceId && clip.id !== clipId)
189
+ .map(({ sequenceId: _sequenceId, ...clip }) => clip);
190
+ clips.push({
191
+ id: clipId,
192
+ kind: "camera.focus",
193
+ kindVersion: 1,
194
+ target: { type: "camera" },
195
+ start,
196
+ duration,
197
+ layer: "screen",
198
+ fill: "forwards",
199
+ easing: options.easing,
200
+ params,
201
+ keyframes: [],
202
+ assetRefs: [],
203
+ seed: clipId,
204
+ metadata: {},
205
+ });
206
+ return [boardSequenceUpsertOperation({
207
+ sequence: { ...sequence, duration: Math.max(sequence.duration, start + duration) },
208
+ clips,
209
+ })];
210
+ },
211
+ }), options);
212
+ }
213
+ catch (cause) {
214
+ handleHttp(cause);
215
+ }
216
+ });
217
+ withJson(sequences.command("delete <board> <sequence-id>").alias("rm").description("Delete a sequence and its clips"))
218
+ .action(async (target, sequenceId, options) => {
219
+ try {
220
+ const board = await resolvedBoard(boards, target);
221
+ showUpdated(await board.mutate({ build: () => [boardSequenceDeleteOperation(sequenceId)] }), options);
222
+ }
223
+ catch (cause) {
224
+ handleHttp(cause);
225
+ }
226
+ });
227
+ }
@@ -0,0 +1,2 @@
1
+ import type { Command } from "commander";
2
+ export declare function registerBoardAppearanceCommands(boards: Command): void;
@@ -0,0 +1,93 @@
1
+ import { BoardAppearanceSchema, DEFAULT_BOARD_APPEARANCE, boardAppearanceOperation, boardPlaybackPolicyOperation, boardTitleOperation, patchBoardAppearance, } from "@neta-art/cohub/board";
2
+ import { handleHttp } from "../../output.js";
3
+ import { finite, resolvedBoard, showUpdated, withJson, } from "./context.js";
4
+ function appearanceFrom(metadata) {
5
+ const parsed = BoardAppearanceSchema.safeParse(metadata.appearance);
6
+ return parsed.success ? parsed.data : DEFAULT_BOARD_APPEARANCE;
7
+ }
8
+ export function registerBoardAppearanceCommands(boards) {
9
+ withJson(boards.command("rename <board> <title>").description("Rename a Board"))
10
+ .action(async (target, title, options) => {
11
+ try {
12
+ const board = await resolvedBoard(boards, target);
13
+ showUpdated(await board.mutate({ build: () => [boardTitleOperation(title)] }), options);
14
+ }
15
+ catch (cause) {
16
+ handleHttp(cause);
17
+ }
18
+ });
19
+ withJson(boards.command("background <board>")
20
+ .description("Configure the Board background")
21
+ .option("--color <color>", "Solid CSS color")
22
+ .option("--image <url>", "Public image URL")
23
+ .option("--fit <mode>", "cover, contain, or repeat", "cover")
24
+ .option("--position <position>", "center, top, bottom, left, or right", "center")
25
+ .option("--opacity <value>", "Image opacity from 0 to 1", "1")
26
+ .option("--reset", "Restore the default background")
27
+ .addHelpText("after", `
28
+ Examples:
29
+ cohub boards background plan.board --color "#123456"
30
+ cohub boards background plan.board --image https://example.com/bg.webp --fit cover --opacity 0.8`))
31
+ .action(async (target, options) => {
32
+ try {
33
+ const selected = [options.color, options.image, options.reset ? "reset" : undefined].filter(Boolean);
34
+ if (selected.length !== 1)
35
+ throw new Error("Choose one of --color, --image, or --reset");
36
+ if (!["cover", "contain", "repeat"].includes(options.fit))
37
+ throw new Error("--fit must be cover, contain, or repeat");
38
+ if (!["center", "top", "bottom", "left", "right"].includes(options.position))
39
+ throw new Error("--position must be center, top, bottom, left, or right");
40
+ const opacity = finite(options.opacity, "opacity");
41
+ if (opacity < 0 || opacity > 1)
42
+ throw new Error("opacity must be between 0 and 1");
43
+ const board = await resolvedBoard(boards, target);
44
+ const result = await board.mutate({
45
+ build(current) {
46
+ const appearance = appearanceFrom(current.board.metadata);
47
+ const background = options.reset
48
+ ? { kind: "solid" }
49
+ : options.color
50
+ ? { kind: "solid", color: options.color }
51
+ : {
52
+ kind: "image",
53
+ imageUrl: options.image,
54
+ fit: options.fit,
55
+ position: options.position,
56
+ opacity,
57
+ color: appearance.background.color,
58
+ };
59
+ return [boardAppearanceOperation(patchBoardAppearance(appearance, { background }))];
60
+ },
61
+ });
62
+ showUpdated(result, options);
63
+ }
64
+ catch (cause) {
65
+ handleHttp(cause);
66
+ }
67
+ });
68
+ withJson(boards.command("playback-policy <board>")
69
+ .description("Configure automatic Board playback")
70
+ .option("--sequence <id>", "Sequence to play")
71
+ .option("--delay <ms>", "Delay before playback", "0")
72
+ .option("--loop", "Loop the sequence")
73
+ .option("--clear", "Remove the playback policy"))
74
+ .action(async (target, options) => {
75
+ try {
76
+ if (options.clear === Boolean(options.sequence))
77
+ throw new Error("Use --sequence or --clear");
78
+ const delayMs = finite(options.delay, "delay");
79
+ if (delayMs < 0)
80
+ throw new Error("delay must be non-negative");
81
+ const policy = options.clear
82
+ ? null
83
+ : { sequenceId: options.sequence, delayMs, loop: Boolean(options.loop) };
84
+ const board = await resolvedBoard(boards, target);
85
+ showUpdated(await board.mutate({
86
+ build: (current) => [boardPlaybackPolicyOperation(current.board.metadata, policy)],
87
+ }), options);
88
+ }
89
+ catch (cause) {
90
+ handleHttp(cause);
91
+ }
92
+ });
93
+ }
@@ -0,0 +1,12 @@
1
+ import type { Command } from "commander";
2
+ export type JsonOptions = {
3
+ json?: boolean;
4
+ };
5
+ export declare function withJson(command: Command): Command;
6
+ export declare function finite(value: string | undefined, name: string, fallback?: number): number;
7
+ export declare function resolvedBoard(boards: Command, target: string): Promise<import("@neta-art/cohub").BoardClient>;
8
+ export declare function showUpdated(result: {
9
+ board: {
10
+ version: number;
11
+ };
12
+ }, options: JsonOptions): void;
@@ -0,0 +1,25 @@
1
+ import { createClient } from "../../client.js";
2
+ import { resolveBoardId } from "../../board-command-support.js";
3
+ import { json, jsonRequested, ok } from "../../output.js";
4
+ import { resolveSpace } from "../../space.js";
5
+ export function withJson(command) {
6
+ return command.option("--json", "Output as JSON");
7
+ }
8
+ export function finite(value, name, fallback) {
9
+ if (value === undefined && fallback !== undefined)
10
+ return fallback;
11
+ const result = Number(value);
12
+ if (!Number.isFinite(result))
13
+ throw new Error(`${name} must be a finite number`);
14
+ return result;
15
+ }
16
+ export async function resolvedBoard(boards, target) {
17
+ const spaceId = resolveSpace(boards);
18
+ const boardId = await resolveBoardId(spaceId, target);
19
+ return createClient().space(spaceId).board(boardId);
20
+ }
21
+ export function showUpdated(result, options) {
22
+ if (jsonRequested(options))
23
+ return json(result);
24
+ ok(`Board updated to version ${result.board.version}`);
25
+ }
@@ -0,0 +1,2 @@
1
+ import type { Command } from "commander";
2
+ export declare function registerBoardNodeCommands(boards: Command): void;