@neta-art/cohub-cli 3.11.0 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/board-command-support.d.ts +7 -0
- package/dist/board-command-support.js +99 -0
- package/dist/board-export.js +28 -22
- package/dist/commands/board-domain.d.ts +2 -0
- package/dist/commands/board-domain.js +10 -0
- package/dist/commands/boards/animation.d.ts +2 -0
- package/dist/commands/boards/animation.js +145 -0
- package/dist/commands/boards/appearance.d.ts +2 -0
- package/dist/commands/boards/appearance.js +92 -0
- package/dist/commands/boards/context.d.ts +12 -0
- package/dist/commands/boards/context.js +25 -0
- package/dist/commands/boards/items.d.ts +2 -0
- package/dist/commands/boards/items.js +144 -0
- package/dist/commands/boards/nodes.d.ts +3 -0
- package/dist/commands/boards/nodes.js +53 -0
- package/dist/commands/boards.d.ts +4 -3
- package/dist/commands/boards.js +135 -68
- package/dist/commands/works.js +2 -2
- package/dist/safe-remote-image.d.ts +24 -0
- package/dist/safe-remote-image.js +160 -0
- package/package.json +2 -2
|
@@ -0,0 +1,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
|
+
}
|
package/dist/board-export.js
CHANGED
|
@@ -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,
|
|
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 =
|
|
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 ??
|
|
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
|
|
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,10 @@
|
|
|
1
|
+
import { registerBoardAnimationCommands } from "./boards/animation.js";
|
|
2
|
+
import { registerBoardAppearanceCommands } from "./boards/appearance.js";
|
|
3
|
+
import { registerBoardItemCommands } from "./boards/items.js";
|
|
4
|
+
import { registerBoardNodeCommands } from "./boards/nodes.js";
|
|
5
|
+
export function registerBoardDomainCommands(boards) {
|
|
6
|
+
registerBoardAppearanceCommands(boards);
|
|
7
|
+
registerBoardNodeCommands(boards);
|
|
8
|
+
registerBoardItemCommands(boards);
|
|
9
|
+
registerBoardAnimationCommands(boards);
|
|
10
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { boardCompositionApplyOperation, boardCompositionDeleteOperation, boardEffectDeleteOperation, boardEffectUpsertOperation, } from "@neta-art/cohub/board";
|
|
2
|
+
import { BoardEffectSchema, parseBoardCompositionInput, } from "@neta-art/cohub";
|
|
3
|
+
import { BOARD_DOMAIN_INPUT_MAX_BYTES, readBoardJsonObject, } from "../../board-command-support.js";
|
|
4
|
+
import { handleHttp, json, jsonRequested, table } from "../../output.js";
|
|
5
|
+
import { resolvedBoard, showUpdated, withJson, } from "./context.js";
|
|
6
|
+
export function registerBoardAnimationCommands(boards) {
|
|
7
|
+
const effects = boards.command("effects").description("Manage Board effects");
|
|
8
|
+
withJson(effects.command("list <board>").alias("ls").description("List effects"))
|
|
9
|
+
.action(async (target, options) => {
|
|
10
|
+
try {
|
|
11
|
+
const board = await resolvedBoard(boards, target);
|
|
12
|
+
const result = await board.inspect({ include: ["effects"] });
|
|
13
|
+
if (jsonRequested(options))
|
|
14
|
+
return json(result.effects);
|
|
15
|
+
table(result.effects, [
|
|
16
|
+
{ key: "id", label: "ID" },
|
|
17
|
+
{ key: "kind", label: "KIND" },
|
|
18
|
+
{ key: "enabled", label: "ENABLED" },
|
|
19
|
+
{ key: "layer", label: "LAYER" },
|
|
20
|
+
]);
|
|
21
|
+
}
|
|
22
|
+
catch (cause) {
|
|
23
|
+
handleHttp(cause);
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
withJson(effects.command("apply <board>")
|
|
27
|
+
.description("Atomically create or replace an effect")
|
|
28
|
+
.requiredOption("-i, --input <file>", "Board effect JSON; use - for stdin"))
|
|
29
|
+
.action(async (target, options) => {
|
|
30
|
+
try {
|
|
31
|
+
const input = await readBoardJsonObject(options.input, BOARD_DOMAIN_INPUT_MAX_BYTES);
|
|
32
|
+
const effect = BoardEffectSchema.omit({ boardId: true, revision: true }).parse(input);
|
|
33
|
+
const board = await resolvedBoard(boards, target);
|
|
34
|
+
showUpdated(await board.mutate({ build: () => [boardEffectUpsertOperation(effect)] }), options);
|
|
35
|
+
}
|
|
36
|
+
catch (cause) {
|
|
37
|
+
handleHttp(cause);
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
withJson(effects.command("delete <board> <effect-id>").alias("rm").description("Delete an effect"))
|
|
41
|
+
.action(async (target, effectId, options) => {
|
|
42
|
+
try {
|
|
43
|
+
const board = await resolvedBoard(boards, target);
|
|
44
|
+
showUpdated(await board.mutate({ build: () => [boardEffectDeleteOperation(effectId)] }), options);
|
|
45
|
+
}
|
|
46
|
+
catch (cause) {
|
|
47
|
+
handleHttp(cause);
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
const compositions = boards
|
|
51
|
+
.command("compositions")
|
|
52
|
+
.description("Manage atomic Board animation compositions");
|
|
53
|
+
withJson(compositions.command("list <board>").alias("ls").description("List compositions"))
|
|
54
|
+
.action(async (target, options) => {
|
|
55
|
+
try {
|
|
56
|
+
const board = await resolvedBoard(boards, target);
|
|
57
|
+
const result = await board.inspect({ include: ["compositions"] });
|
|
58
|
+
if (jsonRequested(options))
|
|
59
|
+
return json(result.compositions);
|
|
60
|
+
table(result.compositions.map((composition) => ({
|
|
61
|
+
id: composition.id,
|
|
62
|
+
name: composition.name,
|
|
63
|
+
duration: composition.timeline.duration,
|
|
64
|
+
tracks: composition.timeline.tracks.length,
|
|
65
|
+
clips: composition.timeline.clips.length,
|
|
66
|
+
revision: composition.revision,
|
|
67
|
+
})), [
|
|
68
|
+
{ key: "id", label: "ID" },
|
|
69
|
+
{ key: "name", label: "NAME" },
|
|
70
|
+
{ key: "duration", label: "DURATION" },
|
|
71
|
+
{ key: "tracks", label: "TRACKS" },
|
|
72
|
+
{ key: "clips", label: "CLIPS" },
|
|
73
|
+
{ key: "revision", label: "REVISION" },
|
|
74
|
+
]);
|
|
75
|
+
}
|
|
76
|
+
catch (cause) {
|
|
77
|
+
handleHttp(cause);
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
withJson(compositions.command("get <board> <composition-id>").description("Get one complete composition"))
|
|
81
|
+
.action(async (target, compositionId, options) => {
|
|
82
|
+
try {
|
|
83
|
+
const board = await resolvedBoard(boards, target);
|
|
84
|
+
const result = await board.inspect({ include: ["compositions"] });
|
|
85
|
+
const composition = result.compositions.find((item) => item.id === compositionId);
|
|
86
|
+
if (!composition)
|
|
87
|
+
throw new Error(`Composition not found: ${compositionId}`);
|
|
88
|
+
if (jsonRequested(options))
|
|
89
|
+
return json(composition);
|
|
90
|
+
table([{
|
|
91
|
+
id: composition.id,
|
|
92
|
+
name: composition.name,
|
|
93
|
+
duration: composition.timeline.duration,
|
|
94
|
+
tracks: composition.timeline.tracks.length,
|
|
95
|
+
clips: composition.timeline.clips.length,
|
|
96
|
+
revision: composition.revision,
|
|
97
|
+
}], [
|
|
98
|
+
{ key: "id", label: "ID" },
|
|
99
|
+
{ key: "name", label: "NAME" },
|
|
100
|
+
{ key: "duration", label: "DURATION" },
|
|
101
|
+
{ key: "tracks", label: "TRACKS" },
|
|
102
|
+
{ key: "clips", label: "CLIPS" },
|
|
103
|
+
{ key: "revision", label: "REVISION" },
|
|
104
|
+
]);
|
|
105
|
+
}
|
|
106
|
+
catch (cause) {
|
|
107
|
+
handleHttp(cause);
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
withJson(compositions.command("apply <board>")
|
|
111
|
+
.description("Atomically create or replace a composition")
|
|
112
|
+
.requiredOption("-i, --input <file>", "BoardComposition JSON; use - for stdin")
|
|
113
|
+
.addHelpText("after", `
|
|
114
|
+
Property changes use timeline.tracks with registered channels and keyframes.
|
|
115
|
+
Procedural behavior such as text reveal, particles, and camera focus uses timeline.clips.
|
|
116
|
+
Run boards capabilities to discover channels and clip schemas.
|
|
117
|
+
|
|
118
|
+
Minimal fade composition:
|
|
119
|
+
{"id":"intro","name":"Intro","timeline":{"duration":800,"tracks":[{"id":"title-opacity","target":{"type":"item","itemId":"title"},"channel":"style.opacity","fill":"both","keyframes":[{"time":0,"value":0},{"time":800,"value":1,"easing":"ease-out-cubic"}]}],"clips":[],"markers":[]},"playback":{"loop":false,"endBehavior":"hold","reducedMotion":{"mode":"base"}}}`))
|
|
120
|
+
.action(async (target, options) => {
|
|
121
|
+
try {
|
|
122
|
+
const input = await readBoardJsonObject(options.input, BOARD_DOMAIN_INPUT_MAX_BYTES);
|
|
123
|
+
const composition = parseBoardCompositionInput(input);
|
|
124
|
+
const board = await resolvedBoard(boards, target);
|
|
125
|
+
showUpdated(await board.mutate({
|
|
126
|
+
build: () => [boardCompositionApplyOperation(composition)],
|
|
127
|
+
}), options);
|
|
128
|
+
}
|
|
129
|
+
catch (cause) {
|
|
130
|
+
handleHttp(cause);
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
withJson(compositions.command("delete <board> <composition-id>").alias("rm").description("Delete a composition"))
|
|
134
|
+
.action(async (target, compositionId, options) => {
|
|
135
|
+
try {
|
|
136
|
+
const board = await resolvedBoard(boards, target);
|
|
137
|
+
showUpdated(await board.mutate({
|
|
138
|
+
build: () => [boardCompositionDeleteOperation(compositionId)],
|
|
139
|
+
}), options);
|
|
140
|
+
}
|
|
141
|
+
catch (cause) {
|
|
142
|
+
handleHttp(cause);
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
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("--composition <id>", "Composition to play")
|
|
71
|
+
.option("--delay <ms>", "Delay before playback", "0")
|
|
72
|
+
.option("--clear", "Remove the playback policy"))
|
|
73
|
+
.action(async (target, options) => {
|
|
74
|
+
try {
|
|
75
|
+
if (options.clear === Boolean(options.composition))
|
|
76
|
+
throw new Error("Use --composition or --clear");
|
|
77
|
+
const delayMs = finite(options.delay, "delay");
|
|
78
|
+
if (delayMs < 0)
|
|
79
|
+
throw new Error("delay must be non-negative");
|
|
80
|
+
const policy = options.clear
|
|
81
|
+
? null
|
|
82
|
+
: { compositionId: options.composition, delayMs };
|
|
83
|
+
const board = await resolvedBoard(boards, target);
|
|
84
|
+
showUpdated(await board.mutate({
|
|
85
|
+
build: (current) => [boardPlaybackPolicyOperation(current.board.metadata, policy)],
|
|
86
|
+
}), options);
|
|
87
|
+
}
|
|
88
|
+
catch (cause) {
|
|
89
|
+
handleHttp(cause);
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
}
|
|
@@ -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,144 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { BoardAuthoringItemSchema, BoardItemPatchSchema, } from "@neta-art/cohub";
|
|
3
|
+
import { BOARD_DOMAIN_INPUT_MAX_BYTES, readBoardJsonObject, } from "../../board-command-support.js";
|
|
4
|
+
import { handleHttp, json, jsonRequested, ok, table } from "../../output.js";
|
|
5
|
+
import { resolvedBoard, withJson, } from "./context.js";
|
|
6
|
+
const baseVersion = (value, fallback) => {
|
|
7
|
+
if (value === undefined)
|
|
8
|
+
return fallback;
|
|
9
|
+
const parsed = Number(value);
|
|
10
|
+
if (!Number.isSafeInteger(parsed) || parsed < 0) {
|
|
11
|
+
throw new Error("base version must be a non-negative integer");
|
|
12
|
+
}
|
|
13
|
+
return parsed;
|
|
14
|
+
};
|
|
15
|
+
async function execute(boards, target, command, options) {
|
|
16
|
+
const board = await resolvedBoard(boards, target);
|
|
17
|
+
const snapshot = await board.authoring();
|
|
18
|
+
const mutation = {
|
|
19
|
+
mutationId: options.mutationId?.trim() || randomUUID(),
|
|
20
|
+
baseVersion: baseVersion(options.baseVersion, snapshot.board.version),
|
|
21
|
+
commands: [command],
|
|
22
|
+
};
|
|
23
|
+
if (options.dryRun) {
|
|
24
|
+
const result = {
|
|
25
|
+
mutationId: mutation.mutationId,
|
|
26
|
+
status: "prepared",
|
|
27
|
+
board: { id: snapshot.board.id, version: snapshot.board.version },
|
|
28
|
+
commands: mutation.commands,
|
|
29
|
+
};
|
|
30
|
+
if (jsonRequested(options))
|
|
31
|
+
return json(result);
|
|
32
|
+
ok(`Prepared against Board version ${snapshot.board.version}; no server validation or write performed`);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
const receipt = await board.mutateSemantic(mutation);
|
|
36
|
+
if (jsonRequested(options))
|
|
37
|
+
return json(receipt);
|
|
38
|
+
ok(`${receipt.replayed ? "Replayed" : "Applied"} mutation at Board version ${receipt.board.version}`);
|
|
39
|
+
}
|
|
40
|
+
function mutationOptions(command) {
|
|
41
|
+
return withJson(command)
|
|
42
|
+
.option("--base-version <version>", "Expected Board version; defaults to latest")
|
|
43
|
+
.option("--mutation-id <id>", "Stable id for safe retries")
|
|
44
|
+
.option("--dry-run", "Prepare locally without server validation or writing");
|
|
45
|
+
}
|
|
46
|
+
export function registerBoardItemCommands(boards) {
|
|
47
|
+
const items = boards.command("items").description("Author Board items with semantic JSON");
|
|
48
|
+
withJson(items.command("list <board>").alias("ls").description("List semantic Board items"))
|
|
49
|
+
.action(async (target, options) => {
|
|
50
|
+
try {
|
|
51
|
+
const board = await resolvedBoard(boards, target);
|
|
52
|
+
const snapshot = await board.authoring();
|
|
53
|
+
if (jsonRequested(options))
|
|
54
|
+
return json(snapshot.items);
|
|
55
|
+
table(snapshot.items.map((item) => ({
|
|
56
|
+
id: item.id,
|
|
57
|
+
type: item.type,
|
|
58
|
+
x: item.frame.x,
|
|
59
|
+
y: item.frame.y,
|
|
60
|
+
width: item.frame.width,
|
|
61
|
+
height: item.frame.height,
|
|
62
|
+
})), [
|
|
63
|
+
{ key: "id", label: "ID" },
|
|
64
|
+
{ key: "type", label: "TYPE" },
|
|
65
|
+
{ key: "x", label: "X" },
|
|
66
|
+
{ key: "y", label: "Y" },
|
|
67
|
+
{ key: "width", label: "WIDTH" },
|
|
68
|
+
{ key: "height", label: "HEIGHT" },
|
|
69
|
+
]);
|
|
70
|
+
}
|
|
71
|
+
catch (cause) {
|
|
72
|
+
handleHttp(cause);
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
mutationOptions(items.command("create <board>")
|
|
76
|
+
.description("Create one item")
|
|
77
|
+
.requiredOption("-i, --input <file>", "Board Item JSON; use - for stdin")
|
|
78
|
+
.addHelpText("after", `
|
|
79
|
+
Minimal text item:
|
|
80
|
+
{"id":"title","type":"text","frame":{"x":120,"y":80,"width":320,"height":48,"rotation":0},"props":{"text":"Launch plan","fontSize":32},"style":{"color":"brand"}}`))
|
|
81
|
+
.action(async (target, options) => {
|
|
82
|
+
try {
|
|
83
|
+
const value = await readBoardJsonObject(options.input, BOARD_DOMAIN_INPUT_MAX_BYTES);
|
|
84
|
+
await execute(boards, target, {
|
|
85
|
+
type: "item.create",
|
|
86
|
+
item: BoardAuthoringItemSchema.parse(value),
|
|
87
|
+
}, options);
|
|
88
|
+
}
|
|
89
|
+
catch (cause) {
|
|
90
|
+
handleHttp(cause);
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
mutationOptions(items.command("patch <board> <item-id>")
|
|
94
|
+
.description("Recursively merge item fields")
|
|
95
|
+
.requiredOption("-i, --input <file>", "Board Item patch JSON; use - for stdin")
|
|
96
|
+
.addHelpText("after", `
|
|
97
|
+
Objects merge recursively, arrays replace, and null clears optional fields.
|
|
98
|
+
Move and rename a text item without replacing its size or font:
|
|
99
|
+
{"frame":{"x":160,"y":120},"props":{"text":"Updated"}}`))
|
|
100
|
+
.action(async (target, itemId, options) => {
|
|
101
|
+
try {
|
|
102
|
+
const value = await readBoardJsonObject(options.input, BOARD_DOMAIN_INPUT_MAX_BYTES);
|
|
103
|
+
await execute(boards, target, {
|
|
104
|
+
type: "item.patch",
|
|
105
|
+
itemId,
|
|
106
|
+
patch: BoardItemPatchSchema.parse(value),
|
|
107
|
+
}, options);
|
|
108
|
+
}
|
|
109
|
+
catch (cause) {
|
|
110
|
+
handleHttp(cause);
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
mutationOptions(items.command("replace <board> <item-id>")
|
|
114
|
+
.description("Replace one complete item")
|
|
115
|
+
.requiredOption("-i, --input <file>", "Complete Board Item JSON; use - for stdin"))
|
|
116
|
+
.action(async (target, itemId, options) => {
|
|
117
|
+
try {
|
|
118
|
+
const value = await readBoardJsonObject(options.input, BOARD_DOMAIN_INPUT_MAX_BYTES);
|
|
119
|
+
await execute(boards, target, {
|
|
120
|
+
type: "item.replace",
|
|
121
|
+
itemId,
|
|
122
|
+
item: BoardAuthoringItemSchema.parse(value),
|
|
123
|
+
}, options);
|
|
124
|
+
}
|
|
125
|
+
catch (cause) {
|
|
126
|
+
handleHttp(cause);
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
mutationOptions(items.command("delete <board> <item-id>").alias("rm")
|
|
130
|
+
.description("Delete one item")
|
|
131
|
+
.option("--cascade", "Atomically remove relations, effects, and animation references"))
|
|
132
|
+
.action(async (target, itemId, options) => {
|
|
133
|
+
try {
|
|
134
|
+
await execute(boards, target, {
|
|
135
|
+
type: "item.delete",
|
|
136
|
+
itemId,
|
|
137
|
+
cascade: Boolean(options.cascade),
|
|
138
|
+
}, options);
|
|
139
|
+
}
|
|
140
|
+
catch (cause) {
|
|
141
|
+
handleHttp(cause);
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
}
|