@dodi-education/cli 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,444 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ DEFAULT_SCREENSHOT_VIEWPORT,
4
+ SCREENSHOT_CONTRACT_VERSION,
5
+ SCREENSHOT_LIMITS,
6
+ createBridgeToken
7
+ } from "./chunk-KP4U5Z6H.js";
8
+ import "./chunk-2ZK44UVJ.js";
9
+
10
+ // ../../screenshot/src/renderer.ts
11
+ import { randomUUID } from "node:crypto";
12
+ import { chromium } from "playwright";
13
+
14
+ // ../../screenshot/src/host-page.ts
15
+ function buildHostPage(viewport) {
16
+ return `<!doctype html>
17
+ <html>
18
+ <head>
19
+ <meta charset="utf-8" />
20
+ <style>
21
+ html, body { margin: 0; padding: 0; background: #fff; overflow: hidden; }
22
+ iframe { border: 0; display: block; width: ${viewport.width}px; height: ${viewport.height}px; }
23
+ </style>
24
+ </head>
25
+ <body>
26
+ <iframe id="game" title="Game" sandbox="allow-scripts" referrerpolicy="no-referrer"></iframe>
27
+ <script>
28
+ (function () {
29
+ var frame = document.getElementById('game');
30
+ window.addEventListener('message', function (e) {
31
+ if (!frame || e.source !== frame.contentWindow) return;
32
+ var json;
33
+ try { json = JSON.stringify(e.data); } catch (err) { return; }
34
+ if (typeof json !== 'string') return;
35
+ if (typeof window.__onGameMessage === 'function') window.__onGameMessage(json);
36
+ });
37
+ window.__postToGame = function (msg) {
38
+ if (frame && frame.contentWindow) frame.contentWindow.postMessage(msg, '*');
39
+ };
40
+ window.__loadGame = function (doc) {
41
+ return new Promise(function (resolve) {
42
+ frame.addEventListener('load', function () { resolve(); }, { once: true });
43
+ frame.srcdoc = doc;
44
+ });
45
+ };
46
+ })();
47
+ </script>
48
+ </body>
49
+ </html>`;
50
+ }
51
+
52
+ // ../../screenshot/src/layout-probe.ts
53
+ function probeLayout(options) {
54
+ const MIN_SIDE = 8;
55
+ const MIN_OVERLAP_SIDE = 4;
56
+ const MIN_OVERLAP_SHARE = 0.08;
57
+ const MAX_SCANNED = 1500;
58
+ const PAGE_LAYER_SHARE = 0.5;
59
+ const INTERACTIVE = /* @__PURE__ */ new Set([
60
+ "BUTTON",
61
+ "INPUT",
62
+ "SELECT",
63
+ "TEXTAREA",
64
+ "PROGRESS",
65
+ "METER",
66
+ "IMG",
67
+ "CANVAS",
68
+ "VIDEO",
69
+ "svg"
70
+ ]);
71
+ const SKIP = /* @__PURE__ */ new Set(["SCRIPT", "STYLE", "TEMPLATE", "NOSCRIPT", "BR", "HEAD", "META", "LINK"]);
72
+ const vw = window.innerWidth;
73
+ const vh = window.innerHeight;
74
+ const viewArea = vw * vh;
75
+ const hasOwnText = (el) => {
76
+ for (const node of Array.from(el.childNodes)) {
77
+ if (node.nodeType === Node.TEXT_NODE && (node.textContent ?? "").trim()) return true;
78
+ }
79
+ return false;
80
+ };
81
+ const hasVisibleBox = (style) => {
82
+ const bg = style.backgroundColor;
83
+ const transparent = bg === "transparent" || /rgba\(.*,\s*0\)$/.test(bg);
84
+ if (!transparent) return true;
85
+ if (style.backgroundImage && style.backgroundImage !== "none") return true;
86
+ if (style.boxShadow && style.boxShadow !== "none") return true;
87
+ return ["Top", "Right", "Bottom", "Left"].some(
88
+ (side) => parseFloat(style.getPropertyValue(`border-${side.toLowerCase()}-width`)) > 0 && style.getPropertyValue(`border-${side.toLowerCase()}-style`) !== "none"
89
+ );
90
+ };
91
+ const depthOf = (el) => {
92
+ let depth = 0;
93
+ for (let node = el.parentElement; node; node = node.parentElement) depth++;
94
+ return depth;
95
+ };
96
+ const pathOf = (el) => {
97
+ const parts = [];
98
+ for (let node = el; node && node !== document.documentElement; node = node.parentElement) {
99
+ const parent = node.parentElement;
100
+ const index = parent ? Array.from(parent.children).indexOf(node) : 0;
101
+ parts.push(`${node.tagName}:${index}`);
102
+ }
103
+ return parts.reverse().join("/");
104
+ };
105
+ const nameOf = (el) => {
106
+ let name = el.tagName.toLowerCase();
107
+ if (el.id) name += `#${el.id}`;
108
+ const classes = Array.from(el.classList).slice(0, 2);
109
+ if (classes.length) name += `.${classes.join(".")}`;
110
+ const text = (el.textContent ?? "").replace(/\s+/g, " ").trim();
111
+ if (text) name += ` "${text.length > 24 ? `${text.slice(0, 23)}\u2026` : text}"`;
112
+ return name;
113
+ };
114
+ const candidates = [];
115
+ const all = document.body ? Array.from(document.body.querySelectorAll("*")) : [];
116
+ for (const el of all.slice(0, MAX_SCANNED)) {
117
+ if (SKIP.has(el.tagName)) continue;
118
+ if (el.tagName !== "svg" && el.closest("svg")) continue;
119
+ if (el.closest("[data-overlap-ok]")) continue;
120
+ const visible = el.checkVisibility?.({ checkOpacity: true, checkVisibilityCSS: true });
121
+ if (visible === false) continue;
122
+ const style = getComputedStyle(el);
123
+ const box = el.getBoundingClientRect();
124
+ const rect = {
125
+ left: Math.max(0, box.left),
126
+ top: Math.max(0, box.top),
127
+ right: Math.min(vw, box.right),
128
+ bottom: Math.min(vh, box.bottom)
129
+ };
130
+ const width = rect.right - rect.left;
131
+ const height = rect.bottom - rect.top;
132
+ if (width < MIN_SIDE || height < MIN_SIDE) continue;
133
+ const area = width * height;
134
+ if (area > viewArea * PAGE_LAYER_SHARE) continue;
135
+ const text = hasOwnText(el);
136
+ const interactive = INTERACTIVE.has(el.tagName) || el.getAttribute("role") === "button" || el.getAttribute("role") === "progressbar" || el.tagName === "A" && el.hasAttribute("href");
137
+ if (style.pointerEvents === "none" && !text && !interactive) continue;
138
+ if (!text && !interactive && !hasVisibleBox(style)) continue;
139
+ candidates.push({ el, rect, area, depth: depthOf(el) });
140
+ }
141
+ const found = [];
142
+ for (let i = 0; i < candidates.length; i++) {
143
+ for (let j = i + 1; j < candidates.length; j++) {
144
+ const a = candidates[i];
145
+ const b = candidates[j];
146
+ if (a.el.contains(b.el) || b.el.contains(a.el)) continue;
147
+ const left = Math.max(a.rect.left, b.rect.left);
148
+ const top = Math.max(a.rect.top, b.rect.top);
149
+ const width = Math.min(a.rect.right, b.rect.right) - left;
150
+ const height = Math.min(a.rect.bottom, b.rect.bottom) - top;
151
+ if (width < MIN_OVERLAP_SIDE || height < MIN_OVERLAP_SIDE) continue;
152
+ const overlap = width * height;
153
+ if (overlap < MIN_OVERLAP_SHARE * Math.min(a.area, b.area)) continue;
154
+ const hit = document.elementFromPoint(left + width / 2, top + height / 2);
155
+ const onTop = hit && (a.el === hit || a.el.contains(hit)) ? "a" : hit && (b.el === hit || b.el.contains(hit)) ? "b" : null;
156
+ const [small, big, smallKey] = a.area <= b.area ? [a, b, "a"] : [b, a, "b"];
157
+ const inside = small.rect.left >= big.rect.left && small.rect.top >= big.rect.top && small.rect.right <= big.rect.right && small.rect.bottom <= big.rect.bottom;
158
+ if (inside && onTop === smallKey) continue;
159
+ found.push({
160
+ a: { name: nameOf(a.el), path: pathOf(a.el) },
161
+ b: { name: nameOf(b.el), path: pathOf(b.el) },
162
+ top: onTop,
163
+ x: Math.round(left),
164
+ y: Math.round(top),
165
+ width: Math.round(width),
166
+ height: Math.round(height),
167
+ aEl: a.el,
168
+ bEl: b.el,
169
+ depth: a.depth + b.depth
170
+ });
171
+ }
172
+ }
173
+ found.sort((x, y) => x.depth - y.depth || y.width * y.height - x.width * x.height);
174
+ const reported = [];
175
+ const within = (outer, inner) => outer === inner || outer.contains(inner);
176
+ for (const pair of found) {
177
+ const covered = reported.some(
178
+ (r) => within(r.aEl, pair.aEl) && within(r.bEl, pair.bEl) || within(r.aEl, pair.bEl) && within(r.bEl, pair.aEl)
179
+ );
180
+ if (covered) continue;
181
+ reported.push(pair);
182
+ if (reported.length >= options.maxOverlaps) break;
183
+ }
184
+ return reported.map(({ a, b, top, x, y, width, height }) => ({ a, b, top, x, y, width, height }));
185
+ }
186
+ function probeLayoutScript(options) {
187
+ return `(() => { const __name = (fn) => fn; return (${probeLayout.toString()})(${JSON.stringify(options)}); })()`;
188
+ }
189
+ function collectLayoutIssues(perFrame, frameLabels) {
190
+ const byPair = /* @__PURE__ */ new Map();
191
+ perFrame.forEach((overlaps, frameIndex) => {
192
+ for (const overlap of overlaps) {
193
+ const key = [overlap.a.path, overlap.b.path].sort().join("|");
194
+ const entry = byPair.get(key);
195
+ if (entry) {
196
+ if (!entry.frames.includes(frameIndex)) entry.frames.push(frameIndex);
197
+ } else {
198
+ byPair.set(key, { overlap, frames: [frameIndex] });
199
+ }
200
+ }
201
+ });
202
+ const lines = [];
203
+ for (const { overlap, frames } of byPair.values()) {
204
+ const { a, b, top } = overlap;
205
+ const relation = top === "a" ? `${a.name} covers ${b.name}` : top === "b" ? `${b.name} covers ${a.name}` : `${a.name} overlaps ${b.name}`;
206
+ const where = `${overlap.width}\xD7${overlap.height} px at ${overlap.x},${overlap.y}`;
207
+ const shownIn = frames.map((i) => `${i + 1} (${frameLabels[i] ?? "?"})`).join(", ");
208
+ const line = `${relation} (${where}), frame${frames.length > 1 ? "s" : ""} ${shownIn}`;
209
+ lines.push(line.slice(0, SCREENSHOT_LIMITS.MAX_ERROR_CHARS));
210
+ if (lines.length >= SCREENSHOT_LIMITS.MAX_LAYOUT_ISSUES) break;
211
+ }
212
+ return lines;
213
+ }
214
+
215
+ // ../../screenshot/src/renderer.ts
216
+ var INIT_RETRY_MS = 300;
217
+ var POST_COMMAND_PAINT_MS = 150;
218
+ var MAX_QUEUE_FACTOR = 2;
219
+ var RenderBusyError = class extends Error {
220
+ constructor() {
221
+ super("render queue is full");
222
+ this.name = "RenderBusyError";
223
+ }
224
+ };
225
+ var RenderTimeoutError = class extends Error {
226
+ constructor(ms) {
227
+ super(`render exceeded ${ms}ms`);
228
+ this.name = "RenderTimeoutError";
229
+ }
230
+ };
231
+ function note(list, text) {
232
+ if (list.length >= SCREENSHOT_LIMITS.MAX_ERRORS) return;
233
+ const trimmed = text.replace(/\s+/g, " ").trim();
234
+ if (trimmed) list.push(trimmed.slice(0, SCREENSHOT_LIMITS.MAX_ERROR_CHARS));
235
+ }
236
+ var GameInbox = class {
237
+ #waiters = /* @__PURE__ */ new Set();
238
+ push(json) {
239
+ let parsed;
240
+ try {
241
+ parsed = JSON.parse(json);
242
+ } catch {
243
+ return;
244
+ }
245
+ if (!parsed || typeof parsed !== "object") return;
246
+ const msg = parsed;
247
+ if (typeof msg.type !== "string") return;
248
+ for (const waiter of this.#waiters) waiter(msg);
249
+ }
250
+ /** Resolves with the first message matching `predicate`, or null after `timeoutMs`. */
251
+ waitFor(predicate, timeoutMs) {
252
+ return new Promise((resolve) => {
253
+ const waiter = (msg) => {
254
+ if (!predicate(msg)) return;
255
+ done(msg);
256
+ };
257
+ const timer = setTimeout(() => done(null), timeoutMs);
258
+ const done = (value) => {
259
+ clearTimeout(timer);
260
+ this.#waiters.delete(waiter);
261
+ resolve(value);
262
+ };
263
+ this.#waiters.add(waiter);
264
+ });
265
+ }
266
+ };
267
+ async function drive(page, request, opts) {
268
+ const errors = [];
269
+ const warnings = [];
270
+ const frames = [];
271
+ const inbox = new GameInbox();
272
+ const token = createBridgeToken();
273
+ page.on("pageerror", (error) => note(errors, error.message));
274
+ page.on("console", (msg) => {
275
+ if (msg.type() === "error") note(errors, msg.text());
276
+ });
277
+ await page.exposeFunction("__onGameMessage", (json) => inbox.push(json));
278
+ void inbox.waitFor((msg) => {
279
+ if (msg.type === "game:error") {
280
+ const detail = msg.payload?.error;
281
+ note(errors, `game:error: ${typeof detail === "string" ? detail : "unknown"}`);
282
+ }
283
+ return false;
284
+ }, opts.renderTimeoutMs);
285
+ const viewport = request.viewport ?? DEFAULT_SCREENSHOT_VIEWPORT;
286
+ await page.setContent(buildHostPage(viewport), { waitUntil: "load" });
287
+ await page.evaluate(
288
+ (doc) => window.__loadGame(doc),
289
+ request.document
290
+ );
291
+ const post = (msg) => page.evaluate((m) => window.__postToGame(m), msg);
292
+ const shoot = async () => {
293
+ const buffer = await page.screenshot({
294
+ type: "jpeg",
295
+ quality: SCREENSHOT_LIMITS.JPEG_QUALITY,
296
+ clip: { x: 0, y: 0, width: viewport.width, height: viewport.height }
297
+ });
298
+ return `data:image/jpeg;base64,${buffer.toString("base64")}`;
299
+ };
300
+ const probes = [];
301
+ const probe = async () => {
302
+ const gameFrame = page.frames().find((frame) => frame.parentFrame() === page.mainFrame());
303
+ const found = gameFrame ? await gameFrame.evaluate(
304
+ probeLayoutScript({ maxOverlaps: SCREENSHOT_LIMITS.MAX_LAYOUT_ISSUES })
305
+ ).catch(() => []) : [];
306
+ probes.push(found);
307
+ };
308
+ const init = {
309
+ type: "dodi:init",
310
+ token,
311
+ payload: { gameId: randomUUID(), ...request.locale ? { locale: request.locale } : {} }
312
+ };
313
+ const readyPromise = inbox.waitFor(
314
+ (msg) => msg.type === "game:ready" && msg.token === token,
315
+ opts.readyTimeoutMs
316
+ );
317
+ const retry = setInterval(() => void post(init).catch(() => {
318
+ }), INIT_RETRY_MS);
319
+ await post(init).catch(() => {
320
+ });
321
+ const ready = await readyPromise !== null;
322
+ clearInterval(retry);
323
+ await page.waitForTimeout(request.settleMs ?? SCREENSHOT_LIMITS.DEFAULT_SETTLE_MS);
324
+ frames.push({ label: "initial", image: await shoot() });
325
+ await probe();
326
+ const steps = request.steps ?? [];
327
+ if (!ready && steps.length > 0) {
328
+ warnings.push(`skipped ${steps.length} step(s): the game never became ready`);
329
+ } else {
330
+ for (const [index, step] of steps.entries()) {
331
+ const waitMs = step.waitMs ?? SCREENSHOT_LIMITS.DEFAULT_STEP_WAIT_MS;
332
+ if (step.command) {
333
+ const resultPromise = inbox.waitFor(
334
+ (msg) => msg.type === "game:result" && msg.token === token,
335
+ waitMs
336
+ );
337
+ await post({ type: "dodi:command", token, payload: { command: step.command } });
338
+ if (await resultPromise === null) {
339
+ warnings.push(`step ${index + 1} (${step.label}): no game:result within ${waitMs}ms`);
340
+ }
341
+ await page.waitForTimeout(POST_COMMAND_PAINT_MS);
342
+ } else {
343
+ await page.waitForTimeout(waitMs);
344
+ }
345
+ frames.push({ label: step.label, image: await shoot() });
346
+ await probe();
347
+ }
348
+ }
349
+ const layoutIssues = collectLayoutIssues(
350
+ probes,
351
+ frames.map((frame) => frame.label)
352
+ );
353
+ return {
354
+ version: SCREENSHOT_CONTRACT_VERSION,
355
+ frames,
356
+ ready,
357
+ warnings,
358
+ errors,
359
+ ...layoutIssues.length > 0 ? { layoutIssues } : {}
360
+ };
361
+ }
362
+ function createRenderer(opts) {
363
+ let browserPromise = null;
364
+ let active = 0;
365
+ const waiting = [];
366
+ const browser = () => {
367
+ if (browserPromise) return browserPromise;
368
+ const launching = chromium.launch({ headless: true, chromiumSandbox: opts.chromiumSandbox }).then((launched) => {
369
+ launched.on("disconnected", () => {
370
+ if (browserPromise === launching) browserPromise = null;
371
+ });
372
+ return launched;
373
+ });
374
+ launching.catch(() => {
375
+ if (browserPromise === launching) browserPromise = null;
376
+ });
377
+ browserPromise = launching;
378
+ return launching;
379
+ };
380
+ async function acquire() {
381
+ if (active < opts.maxConcurrent) {
382
+ active++;
383
+ return;
384
+ }
385
+ if (waiting.length >= opts.maxConcurrent * MAX_QUEUE_FACTOR) throw new RenderBusyError();
386
+ await new Promise((resolve) => waiting.push(resolve));
387
+ active++;
388
+ }
389
+ function release() {
390
+ active--;
391
+ waiting.shift()?.();
392
+ }
393
+ async function renderOnce(request) {
394
+ const viewport = request.viewport ?? DEFAULT_SCREENSHOT_VIEWPORT;
395
+ const context = await (await browser()).newContext({
396
+ viewport,
397
+ deviceScaleFactor: 1,
398
+ // The document's CSP already forbids network; the context is offline and
399
+ // every request is aborted as belt and braces.
400
+ offline: true,
401
+ javaScriptEnabled: true
402
+ });
403
+ try {
404
+ const page = await context.newPage();
405
+ await page.route("**/*", (route) => route.abort());
406
+ const work = drive(page, request, opts);
407
+ work.catch(() => {
408
+ });
409
+ let timer;
410
+ const deadline = new Promise((_, reject) => {
411
+ timer = setTimeout(() => reject(new RenderTimeoutError(opts.renderTimeoutMs)), opts.renderTimeoutMs);
412
+ });
413
+ try {
414
+ return await Promise.race([work, deadline]);
415
+ } finally {
416
+ clearTimeout(timer);
417
+ }
418
+ } finally {
419
+ await context.close().catch(() => {
420
+ });
421
+ }
422
+ }
423
+ return {
424
+ async render(request) {
425
+ await acquire();
426
+ try {
427
+ return await renderOnce(request);
428
+ } finally {
429
+ release();
430
+ }
431
+ },
432
+ async close() {
433
+ const open = browserPromise;
434
+ browserPromise = null;
435
+ if (open) await open.then((b) => b.close()).catch(() => {
436
+ });
437
+ }
438
+ };
439
+ }
440
+ export {
441
+ RenderBusyError,
442
+ RenderTimeoutError,
443
+ createRenderer
444
+ };
package/package.json CHANGED
@@ -1,6 +1,77 @@
1
1
  {
2
2
  "name": "@dodi-education/cli",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.0",
4
+ "description": "The dodi CLI: let your own AI agent build learning games and companion looks for your family on dodi.",
5
+ "license": "AGPL-3.0-only",
6
+ "author": "dodi",
7
+ "homepage": "https://dodi.app",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/dodi-education/dodi-app.git",
11
+ "directory": "clients/cli"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/dodi-education/dodi-app/issues"
15
+ },
16
+ "keywords": [
17
+ "dodi",
18
+ "cli",
19
+ "mcp",
20
+ "ai-agent",
21
+ "learning-games",
22
+ "kids",
23
+ "e2ee"
24
+ ],
25
+ "type": "module",
26
+ "bin": {
27
+ "dodi": "./dist/dodi.js"
28
+ },
29
+ "files": [
30
+ "dist",
31
+ "README.md",
32
+ "LICENSE"
33
+ ],
34
+ "engines": {
35
+ "node": ">=20"
36
+ },
37
+ "dependencies": {
38
+ "yaml": "^2.9.1"
39
+ },
40
+ "peerDependencies": {
41
+ "playwright": ">=1.50.0"
42
+ },
43
+ "peerDependenciesMeta": {
44
+ "playwright": {
45
+ "optional": true
46
+ }
47
+ },
48
+ "devDependencies": {
49
+ "@types/node": "^20",
50
+ "esbuild": "^0.28.2",
51
+ "tsx": "4.23.13",
52
+ "typescript": "^5",
53
+ "vitest": "^4.1.8",
54
+ "zod": "^4.3.6",
55
+ "@dodi/ai": "0.0.0",
56
+ "@dodi/character": "0.0.0",
57
+ "@dodi/crypto": "0.0.0",
58
+ "@dodi/games": "0.0.0",
59
+ "@dodi/intl": "0.0.0",
60
+ "@dodi/protocol": "0.0.0",
61
+ "@dodi/types": "0.0.0",
62
+ "@dodi/screenshot": "0.0.0",
63
+ "@dodi/vault": "0.0.0"
64
+ },
65
+ "publishConfig": {
66
+ "access": "public",
67
+ "provenance": false
68
+ },
69
+ "scripts": {
70
+ "dev": "tsx src/index.ts",
71
+ "build": "node scripts/build.mjs",
72
+ "typecheck": "tsc --noEmit",
73
+ "lint": "tsc --noEmit",
74
+ "test": "vitest run",
75
+ "test:watch": "vitest"
76
+ }
6
77
  }