@myvillage/cli 1.89.2 → 1.91.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
@@ -21,10 +21,14 @@ myvillage create-game
21
21
 
22
22
  # Deploy to the platform
23
23
  myvillage deploy
24
+
25
+ # Publish a PDF story book to the Read tab
26
+ myvillage publish-book ./my-story.pdf
24
27
  ```
25
28
 
26
29
  ## What You Can Do
27
30
 
31
+ - **Publish Story Books** — Upload a comic-style PDF; the platform turns it into pages for the muni Read tab, with MVT rewards for readers who finish.
28
32
  - **Build & Deploy Games** — Scaffold projects from built-in templates (quiz, exploration, narrative, or custom Three.js) and deploy to the M-UNI game platform.
29
33
  - **Community Network** — Browse feeds, create posts, join communities, comment, vote, and search — right from the CLI.
30
34
  - **AI Agents** — Create and manage local or external AI agents that participate in the MyVillage Agent Network.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@myvillage/cli",
3
- "version": "1.89.2",
3
+ "version": "1.91.0",
4
4
  "description": "MyVillageOS CLI for community developers",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,350 @@
1
+ // ── Agent log shipper ────────────────────────────────────────────────────────
2
+ // Ships a rolling tail of each leased agent's logs to the control plane, so an
3
+ // agent running on this machine can be read from Studio on another one without
4
+ // SSH.
5
+ //
6
+ // Design notes worth knowing before editing:
7
+ //
8
+ // * Recent, not complete. Only the newest lines are shipped and the server
9
+ // keeps a bounded window per agent. Full history stays here on disk and is
10
+ // read with `myvillage agent logs`. This is what makes the whole thing scale
11
+ // with fleet size instead of uptime.
12
+ //
13
+ // * Batched per machine. One request carries every leased agent's delta, so
14
+ // request rate tracks machine count, not agent count.
15
+ //
16
+ // * Never on the heartbeat. Lease renewal is correctness-critical; log volume
17
+ // must not be able to slow it down or fail it.
18
+ //
19
+ // * Secrets are masked HERE, before anything leaves the box. Tool arguments
20
+ // and results do ship (they are the point), but .env values and the OAuth
21
+ // token never do.
22
+ import fs from 'fs';
23
+ import path from 'path';
24
+ import os from 'os';
25
+ import { getAgentDir } from '../utils/local-agent.js';
26
+ import { getAccessToken } from '../utils/auth.js';
27
+
28
+ const CURSOR_FILE = path.join(os.homedir(), '.myvillage', 'log-cursors.json');
29
+
30
+ /** Per-stream ship caps. Must not exceed the server's retention, or we would be
31
+ * uploading lines it is about to delete. */
32
+ const SHIP_CAP = { ACTIVITY: 300, STDERR: 100 };
33
+
34
+ /** Hard ceiling on one request across all agents; mirrors the server's cap. */
35
+ const MAX_LINES_PER_PUSH = 2000;
36
+
37
+ /** Shortest .env value we will mask. Below this you get false positives that
38
+ * black out ordinary words — a var set to "true" or a family name. */
39
+ const MIN_SECRET_LEN = 8;
40
+
41
+ const DATE_LOG_RE = /^\d{4}-\d{2}-\d{2}\.jsonl$/;
42
+
43
+ // ── Cursor persistence ───────────────────────────────────────────────────────
44
+ // Keyed `${agentProfileId}:${stream}` → { file, offset }. Persisted so a runner
45
+ // restart resumes mid-file instead of re-shipping a tail we already sent.
46
+
47
+ function readCursors() {
48
+ try {
49
+ return JSON.parse(fs.readFileSync(CURSOR_FILE, 'utf-8'));
50
+ } catch {
51
+ return {};
52
+ }
53
+ }
54
+
55
+ function writeCursors(cursors) {
56
+ try {
57
+ fs.mkdirSync(path.dirname(CURSOR_FILE), { recursive: true });
58
+ fs.writeFileSync(CURSOR_FILE, JSON.stringify(cursors, null, 2), { mode: 0o600 });
59
+ } catch {
60
+ /* best-effort: a lost cursor costs one duplicated tail, not correctness */
61
+ }
62
+ }
63
+
64
+ const key = (agentProfileId, stream) => `${agentProfileId}:${stream}`;
65
+
66
+ // ── Redaction ────────────────────────────────────────────────────────────────
67
+
68
+ /**
69
+ * Literal secrets for one agent: its own .env values plus this machine's OAuth
70
+ * token. Values only — keys stay readable, so `GITHUB_TOKEN=••••` still tells
71
+ * you which credential is in play.
72
+ */
73
+ function secretsForAgent(agentName) {
74
+ const out = [];
75
+ try {
76
+ const envPath = path.join(getAgentDir(agentName), '.env');
77
+ const text = fs.readFileSync(envPath, 'utf-8');
78
+ for (const line of text.split('\n')) {
79
+ const trimmed = line.trim();
80
+ if (!trimmed || trimmed.startsWith('#')) continue;
81
+ const eq = trimmed.indexOf('=');
82
+ if (eq <= 0) continue;
83
+ let value = trimmed.slice(eq + 1).trim();
84
+ // Strip matching surrounding quotes so we mask the value, not the quoting.
85
+ if (value.length >= 2 && /^(".*"|'.*')$/s.test(value)) value = value.slice(1, -1);
86
+ if (value.length >= MIN_SECRET_LEN) out.push(value);
87
+ }
88
+ } catch {
89
+ /* no .env, or unreadable — nothing agent-specific to mask */
90
+ }
91
+ try {
92
+ const token = getAccessToken();
93
+ if (token && token.length >= MIN_SECRET_LEN) out.push(token);
94
+ } catch {
95
+ /* not signed in */
96
+ }
97
+ // Longest first, so a value containing another is masked whole.
98
+ return out.sort((a, b) => b.length - a.length);
99
+ }
100
+
101
+ /** Shapes that look like credentials even when we have never seen the value —
102
+ * a token echoed back by a third-party API, for instance. */
103
+ const SECRET_PATTERNS = [
104
+ /\b(sk|pk|rk)-[A-Za-z0-9_-]{16,}\b/g,
105
+ /\bgh[pousr]_[A-Za-z0-9]{16,}\b/g,
106
+ /\bAKIA[0-9A-Z]{12,}\b/g,
107
+ /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g,
108
+ /\bey[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g, // JWT
109
+ /\b[Bb]earer\s+[A-Za-z0-9._~+/-]{20,}=*/g,
110
+ ];
111
+
112
+ function escapeRegExp(s) {
113
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
114
+ }
115
+
116
+ /**
117
+ * Mask every known secret and credential-shaped token in a line.
118
+ *
119
+ * Runs on every shipped line, so it must be cheap and must never throw — a
120
+ * redaction bug that crashes the shipper would take log visibility down, and one
121
+ * that silently returns the input would leak. On failure we drop the line rather
122
+ * than ship it unmasked.
123
+ */
124
+ export function redactSecrets(line, secrets) {
125
+ let out = line;
126
+ for (const secret of secrets) {
127
+ if (!secret) continue;
128
+ out = out.split(secret).join('••••');
129
+ }
130
+ for (const re of SECRET_PATTERNS) out = out.replace(re, '••••');
131
+ return out;
132
+ }
133
+
134
+ // ── Tailing ──────────────────────────────────────────────────────────────────
135
+
136
+ /** The daemon writes `logs/<YYYY-MM-DD>.jsonl` and rolls over at UTC midnight.
137
+ * ISO names sort chronologically, so lexical max is the current file. */
138
+ function latestActivityFile(logsDir) {
139
+ try {
140
+ const names = fs.readdirSync(logsDir).filter((n) => DATE_LOG_RE.test(n));
141
+ if (names.length === 0) return null;
142
+ names.sort();
143
+ return names[names.length - 1];
144
+ } catch {
145
+ return null;
146
+ }
147
+ }
148
+
149
+ function sourceFileFor(agentName, stream) {
150
+ const logsDir = path.join(getAgentDir(agentName), 'logs');
151
+ if (stream === 'STDERR') {
152
+ const f = path.join(logsDir, 'daemon-stderr.log');
153
+ return fs.existsSync(f) ? { dir: logsDir, name: 'daemon-stderr.log', full: f } : null;
154
+ }
155
+ const name = latestActivityFile(logsDir);
156
+ return name ? { dir: logsDir, name, full: path.join(logsDir, name) } : null;
157
+ }
158
+
159
+ /**
160
+ * Read the lines this agent has produced since our cursor.
161
+ *
162
+ * Resets to the tail (not the top) whenever continuity is broken: a new day's
163
+ * file, a truncated file, or no cursor at all. That is the backlog cap — a
164
+ * machine that was offline for a day ships its recent tail, never a day of
165
+ * backlog, so a fleet reconnecting after an outage cannot stampede the endpoint.
166
+ */
167
+ function readNewLines(agentName, stream, cursor) {
168
+ const src = sourceFileFor(agentName, stream);
169
+ if (!src) return null;
170
+
171
+ let size;
172
+ try {
173
+ size = fs.statSync(src.full).size;
174
+ } catch {
175
+ return null;
176
+ }
177
+
178
+ const continuous =
179
+ cursor && cursor.file === src.name && typeof cursor.offset === 'number' && cursor.offset <= size;
180
+ const start = continuous ? cursor.offset : 0;
181
+ if (continuous && start === size) return null; // nothing new
182
+
183
+ let text = '';
184
+ const len = size - start;
185
+ if (len > 0) {
186
+ let fd;
187
+ try {
188
+ fd = fs.openSync(src.full, 'r');
189
+ const buf = Buffer.alloc(len);
190
+ fs.readSync(fd, buf, 0, len, start);
191
+ text = buf.toString('utf8');
192
+ } catch {
193
+ return null;
194
+ } finally {
195
+ if (fd !== undefined) {
196
+ try { fs.closeSync(fd); } catch { /* ignore */ }
197
+ }
198
+ }
199
+ }
200
+
201
+ let lines = text.split('\n').filter((l) => l.trim().length > 0);
202
+ const cap = SHIP_CAP[stream];
203
+ // On a discontinuity we may be looking at a whole file; keep only the tail.
204
+ if (lines.length > cap) lines = lines.slice(-cap);
205
+
206
+ return { lines, file: src.name, offset: size };
207
+ }
208
+
209
+ /** Pull the line's own timestamp out of the JSONL, so a backlog shipped after a
210
+ * reconnect keeps its real times instead of collapsing onto arrival. */
211
+ function timestampOf(payload) {
212
+ if (payload.charCodeAt(0) !== 123 /* { */) return null;
213
+ try {
214
+ const obj = JSON.parse(payload);
215
+ const ts = obj.ts ?? obj.timestamp ?? obj.time;
216
+ return typeof ts === 'string' ? ts : null;
217
+ } catch {
218
+ return null;
219
+ }
220
+ }
221
+
222
+ // ── Shipper ──────────────────────────────────────────────────────────────────
223
+
224
+ /**
225
+ * Ship one round for every agent this runner holds a lease on.
226
+ *
227
+ * `leases` is the runner's Map of agentProfileId → { name }. Only lease holders
228
+ * may ship, which the server enforces too — the same lease that guarantees
229
+ * single-active execution guarantees a single writer per agent's log.
230
+ *
231
+ * Bootstrap: on the first round after start we send a zero-line batch to learn
232
+ * the server's cursor. If it says this machine wrote it, we adopt the offset and
233
+ * carry on mid-file; otherwise we start from the tail. That is what keeps a
234
+ * runner restart from re-shipping (and thus duplicating) the last few hundred
235
+ * lines it had already sent.
236
+ */
237
+ export async function shipLogsOnce(ctx) {
238
+ if (!ctx.leases || ctx.leases.size === 0) return { pushed: 0 };
239
+
240
+ const cursors = readCursors();
241
+ const bootstrap = !ctx.logShipperBootstrapped;
242
+ const batches = [];
243
+ let budget = MAX_LINES_PER_PUSH;
244
+
245
+ for (const [agentProfileId, lease] of ctx.leases) {
246
+ let secrets = null;
247
+ for (const stream of ['ACTIVITY', 'STDERR']) {
248
+ if (budget <= 0) break;
249
+ const k = key(agentProfileId, stream);
250
+
251
+ // First round: ask for the cursor without shipping anything.
252
+ if (bootstrap && !cursors[k]) {
253
+ batches.push({ agentProfileId, stream, lines: [] });
254
+ continue;
255
+ }
256
+
257
+ const found = readNewLines(lease.name, stream, cursors[k]);
258
+ if (!found || found.lines.length === 0) continue;
259
+
260
+ if (secrets === null) secrets = secretsForAgent(lease.name);
261
+
262
+ const lines = [];
263
+ for (const raw of found.lines) {
264
+ if (budget <= 0) break;
265
+ let payload;
266
+ try {
267
+ payload = redactSecrets(raw, secrets);
268
+ } catch {
269
+ continue; // never ship a line we failed to mask
270
+ }
271
+ lines.push({ payload, ts: timestampOf(raw) });
272
+ budget--;
273
+ }
274
+ if (lines.length === 0) continue;
275
+
276
+ batches.push({
277
+ agentProfileId,
278
+ stream,
279
+ lines,
280
+ sourceFile: found.file,
281
+ sourceOffset: found.offset,
282
+ });
283
+ }
284
+ }
285
+
286
+ if (batches.length === 0) {
287
+ ctx.logShipperBootstrapped = true;
288
+ return { pushed: 0 };
289
+ }
290
+
291
+ const res = await ctx.cp(`/machines/${ctx.machineId}/logs`, {
292
+ method: 'POST',
293
+ body: { agents: batches },
294
+ });
295
+
296
+ // Leave cursors untouched on failure so the next round retries the same
297
+ // range. Re-sending is safe; losing lines is not.
298
+ if (!res.ok) return { pushed: 0, error: res.json?.error || res.status };
299
+
300
+ // Advance our cursors only for batches the server accepted.
301
+ const rejected = new Set(
302
+ (res.json?.data?.cursors || [])
303
+ .filter((c) => c && c.rejected)
304
+ .map((c) => key(c.agentProfileId, c.stream)),
305
+ );
306
+
307
+ let pushed = 0;
308
+ for (const b of batches) {
309
+ const k = key(b.agentProfileId, b.stream);
310
+ if (rejected.has(k)) continue;
311
+ if (b.sourceFile) {
312
+ cursors[k] = { file: b.sourceFile, offset: b.sourceOffset };
313
+ pushed += b.lines.length;
314
+ }
315
+ }
316
+
317
+ // Adopt the server's position for bootstrap batches, but only where this
318
+ // machine is the one that wrote it — another machine's byte offset means
319
+ // nothing against our filesystem.
320
+ if (bootstrap) {
321
+ for (const c of res.json?.data?.cursors || []) {
322
+ if (!c || c.rejected) continue;
323
+ const k = key(c.agentProfileId, c.stream);
324
+ if (cursors[k]) continue;
325
+ if (c.lastMachineId === ctx.machineId && c.lastFile) {
326
+ cursors[k] = { file: c.lastFile, offset: c.lastOffset ?? 0 };
327
+ }
328
+ }
329
+ ctx.logShipperBootstrapped = true;
330
+ }
331
+
332
+ writeCursors(cursors);
333
+ return { pushed };
334
+ }
335
+
336
+ /** Forget an agent's cursors — used when a lease is released, so if the agent
337
+ * comes back here later we re-establish position rather than trusting an offset
338
+ * into a file another machine has since been writing. */
339
+ export function forgetLogCursor(agentProfileId) {
340
+ const cursors = readCursors();
341
+ let changed = false;
342
+ for (const stream of ['ACTIVITY', 'STDERR']) {
343
+ const k = key(agentProfileId, stream);
344
+ if (cursors[k]) {
345
+ delete cursors[k];
346
+ changed = true;
347
+ }
348
+ }
349
+ if (changed) writeCursors(cursors);
350
+ }
@@ -26,11 +26,18 @@ import { getAccessToken, isTokenExpired } from '../utils/auth.js';
26
26
  import { refreshAccessToken } from '../utils/api.js';
27
27
  import { listLocalAgents, readAgentConfig, getAgentDir, writeAgentConfig } from '../utils/local-agent.js';
28
28
  import { startDaemon, stopDaemon, isDaemonRunning, getDaemonInfo } from './daemon.js';
29
+ import { shipLogsOnce, forgetLogCursor } from './log-shipper.js';
29
30
 
30
31
  const RUNNER_STATE_FILE = path.join(os.homedir(), '.myvillage', 'runner.json');
31
32
  const DEFAULT_HEARTBEAT_MS = 20_000;
32
33
  const LEASE_TTL_SECONDS = 90;
33
34
 
35
+ // Log shipping runs on its own clock, faster than reconcile so a remote tail
36
+ // feels live, and in its own loop so a slow or failing log push can never delay
37
+ // a heartbeat. Lease renewal rides the heartbeat, and single-active correctness
38
+ // depends on it — logs must never be able to hold it up.
39
+ const LOG_SHIP_MS = 5_000;
40
+
34
41
  function log(msg, extra) {
35
42
  const line = `[runner] ${new Date().toISOString()} ${msg}`;
36
43
  if (extra !== undefined) console.log(line, extra);
@@ -402,6 +409,10 @@ async function reconcileOnce(ctx) {
402
409
  try { stopDaemon(lease.name); } catch { /* best-effort */ }
403
410
  await ctx.cp(`/agents/${profileId}/lease`, { method: 'POST', body: { action: 'release' } });
404
411
  ctx.leases.delete(profileId);
412
+ // Drop our log position too: if this agent is placed here again later,
413
+ // another machine will have been writing its tail in the meantime, so our
414
+ // old byte offset would be meaningless.
415
+ forgetLogCursor(profileId);
405
416
  log(`stopped + released ${lease.name} (no longer assigned here)`);
406
417
  }
407
418
  }
@@ -449,6 +460,23 @@ export async function runRunner(opts = {}) {
449
460
 
450
461
  const intervalMs = opts.intervalMs || DEFAULT_HEARTBEAT_MS;
451
462
  log(`reconcile loop every ${Math.round(intervalMs / 1000)}s (lease TTL ${LEASE_TTL_SECONDS}s)`);
463
+
464
+ // Independent loop, deliberately not awaited by the reconcile loop below.
465
+ // A log push that hangs or errors must not delay lease renewal.
466
+ const shipLoop = (async () => {
467
+ while (!controller.signal.aborted) {
468
+ await sleep(LOG_SHIP_MS, controller.signal);
469
+ if (controller.signal.aborted) break;
470
+ try {
471
+ await shipLogsOnce(ctx);
472
+ } catch (err) {
473
+ // Logs are best-effort. Cursors only advance on a confirmed write, so a
474
+ // failed round is retried next tick rather than losing lines.
475
+ log('log ship error', err?.message || err);
476
+ }
477
+ }
478
+ })();
479
+
452
480
  while (!controller.signal.aborted) {
453
481
  try {
454
482
  await reconcileOnce(ctx);
@@ -457,5 +485,6 @@ export async function runRunner(opts = {}) {
457
485
  }
458
486
  if (!controller.signal.aborted) await sleep(intervalMs, controller.signal);
459
487
  }
488
+ await shipLoop;
460
489
  log('runner stopped');
461
490
  }
@@ -46,8 +46,23 @@ export function isWithinActiveHours(schedule) {
46
46
  return currentTime >= startTime || currentTime <= endTime;
47
47
  }
48
48
 
49
+ /**
50
+ * Floor on how often an agent may loop, in minutes.
51
+ *
52
+ * These agents act on human-paced events — a knowledge submission, a activity
53
+ * completion, an email — so anything under a minute is burning tokens and log
54
+ * volume against a world that has not changed. It also protects the fleet: log
55
+ * shipping and MVT spend both scale with agents × loops-per-hour, so a single
56
+ * misconfigured 15-second agent multiplied across a large fleet is what turns a
57
+ * comfortable load into an expensive one.
58
+ *
59
+ * Not an error, deliberately. An existing agent set faster than this keeps
60
+ * running, just at the floor, rather than failing to start.
61
+ */
62
+ export const MIN_CHECK_IN_MINUTES = 1;
63
+
49
64
  export function getNextCheckInMs(schedule) {
50
65
  const interval = schedule?.check_in_interval;
51
66
  if (!interval || interval <= 0) return 0; // manual only
52
- return interval * 60 * 1000;
67
+ return Math.max(interval, MIN_CHECK_IN_MINUTES) * 60 * 1000;
53
68
  }
@@ -0,0 +1,308 @@
1
+ import { existsSync, readFileSync, statSync } from 'fs';
2
+ import { resolve, basename } from 'path';
3
+ import chalk from 'chalk';
4
+ import axios from 'axios';
5
+ import inquirer from 'inquirer';
6
+ import { villageSpinner } from '../utils/brand.js';
7
+ import { isAuthenticated } from '../utils/auth.js';
8
+ import { getConfig } from '../utils/config.js';
9
+ import {
10
+ createStoryBook,
11
+ getStoryBookUploadUrl,
12
+ confirmStoryBookUpload,
13
+ getStoryBookStatus,
14
+ } from '../utils/api.js';
15
+ import { getMyVillages } from '../utils/village-resolver.js';
16
+
17
+ /**
18
+ * publish-book — upload a PDF story book to the Read pillar.
19
+ *
20
+ * The book counterpart to `deploy`: create the book, PUT the PDF straight to
21
+ * storage via a presigned URL, confirm, then watch the platform rasterize it
22
+ * into pages. On success the book sits in the review queue; an admin publishes
23
+ * it from the portal, after which it appears in the muni Read tab.
24
+ *
25
+ * See the platform's docs/features/story-books.md.
26
+ */
27
+
28
+ const GENRES = [
29
+ 'adventure', 'folktale', 'history', 'biography',
30
+ 'science', 'faith', 'humor', 'education', 'other',
31
+ ];
32
+
33
+ const AGE_GROUPS = ['under-5', '6-12', '13-and-over'];
34
+
35
+ // Server-enforced too — quoted here so the failure is local and instant.
36
+ const MAX_PDF_MB = 200;
37
+
38
+ // Rasterization is ~0.5s/page server-side; a 300-page book plus queueing
39
+ // comfortably fits inside this. Past it, assume something is wrong.
40
+ const POLL_INTERVAL_MS = 2500;
41
+ const POLL_TIMEOUT_MS = 12 * 60 * 1000;
42
+
43
+ function fail(json, message) {
44
+ if (json) {
45
+ console.log(JSON.stringify({ success: false, error: message }));
46
+ } else {
47
+ console.log(chalk.red(` ✗ ${message}`));
48
+ }
49
+ process.exitCode = 1;
50
+ }
51
+
52
+ function normGenre(v) {
53
+ if (!v) return null;
54
+ const g = String(v).toLowerCase();
55
+ return GENRES.includes(g) ? g.toUpperCase() : null;
56
+ }
57
+
58
+ function normAge(v) {
59
+ if (!v) return null;
60
+ const a = String(v).toLowerCase();
61
+ return AGE_GROUPS.includes(a) ? a : null;
62
+ }
63
+
64
+ function titleFromFilename(pdfPath) {
65
+ return basename(pdfPath)
66
+ .replace(/\.pdf$/i, '')
67
+ .replace(/[-_]+/g, ' ')
68
+ .replace(/\s+/g, ' ')
69
+ .trim();
70
+ }
71
+
72
+ /**
73
+ * Village visibility, same flow as `deploy`: pick which villages can see the
74
+ * book. Wording is book-specific, behavior identical — when the author isn't
75
+ * in any villages the book ships unscoped.
76
+ */
77
+ async function selectBookVillages() {
78
+ const spinner = villageSpinner('Loading your villages...').start();
79
+ let villages;
80
+ try {
81
+ villages = await getMyVillages();
82
+ } catch (err) {
83
+ spinner.fail(`Could not load your villages: ${err.message}`);
84
+ return null;
85
+ }
86
+ spinner.stop();
87
+
88
+ if (!villages.length) {
89
+ console.log(chalk.yellow(' You are not a member of any villages — the book will be submitted without village scoping.'));
90
+ return [];
91
+ }
92
+
93
+ const { selected } = await inquirer.prompt([{
94
+ type: 'checkbox',
95
+ name: 'selected',
96
+ message: 'Which villages can see & read this book? (space to toggle, enter to confirm)',
97
+ choices: villages.map((v) => ({
98
+ name: `${v.name}${v.city ? ` (${v.city}${v.state ? ', ' + v.state : ''})` : ''}`,
99
+ value: v.id,
100
+ checked: false,
101
+ })),
102
+ validate: (ans) => (ans.length > 0 ? true : 'Select at least one village (space to toggle).'),
103
+ }]);
104
+
105
+ return selected;
106
+ }
107
+
108
+ async function pollUntilProcessed(bookId, spinner) {
109
+ const deadline = Date.now() + POLL_TIMEOUT_MS;
110
+
111
+ for (;;) {
112
+ if (Date.now() > deadline) {
113
+ return { ok: false, error: 'Timed out waiting for processing. Check the book in the portal — it may still finish.' };
114
+ }
115
+ await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
116
+
117
+ let status;
118
+ try {
119
+ status = await getStoryBookStatus(bookId);
120
+ } catch {
121
+ continue; // transient poll failure — the render is server-side, keep waiting
122
+ }
123
+
124
+ if (status.processing) {
125
+ if (spinner && status.pagesRendered > 0) {
126
+ spinner.text = `Rendering pages... ${status.pagesRendered} done`;
127
+ }
128
+ continue;
129
+ }
130
+ if (status.error) {
131
+ return { ok: false, error: status.error, status };
132
+ }
133
+ return { ok: true, status };
134
+ }
135
+ }
136
+
137
+ export async function publishBookCommand(pdfPath, options = {}) {
138
+ const json = !!options.json;
139
+ const nonInteractive = json || !!options.yes;
140
+
141
+ if (!isAuthenticated()) {
142
+ return fail(json, "Authentication required. Run 'myvillage login' first.");
143
+ }
144
+
145
+ // ── Validate the PDF locally, before any API call ──
146
+ const resolved = resolve(pdfPath);
147
+ if (!existsSync(resolved)) {
148
+ return fail(json, `File not found: ${resolved}`);
149
+ }
150
+ if (!/\.pdf$/i.test(resolved)) {
151
+ return fail(json, 'Story books are published from PDF files (.pdf).');
152
+ }
153
+ const fileSize = statSync(resolved).size;
154
+ if (fileSize === 0) {
155
+ return fail(json, 'That PDF is empty.');
156
+ }
157
+ if (fileSize > MAX_PDF_MB * 1024 * 1024) {
158
+ return fail(json, `PDF is ${Math.round(fileSize / 1024 / 1024)}MB — the limit is ${MAX_PDF_MB}MB. Export it smaller or split it into volumes.`);
159
+ }
160
+
161
+ // ── Metadata: flags first, prompts for the gaps ──
162
+ let title = options.title?.trim() || null;
163
+ let genre = normGenre(options.genre);
164
+ let targetAge = normAge(options.age);
165
+ let description = options.description?.trim() || null;
166
+
167
+ if (options.genre && !genre) {
168
+ return fail(json, `Unknown genre "${options.genre}". Use one of: ${GENRES.join(', ')}`);
169
+ }
170
+ if (options.age && !targetAge) {
171
+ return fail(json, `Unknown age group "${options.age}". Use one of: ${AGE_GROUPS.join(', ')}`);
172
+ }
173
+
174
+ if (nonInteractive) {
175
+ if (!title) title = titleFromFilename(resolved);
176
+ if (!genre) genre = 'OTHER';
177
+ } else {
178
+ const answers = await inquirer.prompt([
179
+ {
180
+ type: 'input',
181
+ name: 'title',
182
+ message: 'Book title:',
183
+ default: title || titleFromFilename(resolved),
184
+ when: !title,
185
+ validate: (v) => (v.trim() ? true : 'A title is required.'),
186
+ },
187
+ {
188
+ type: 'list',
189
+ name: 'genre',
190
+ message: 'Genre:',
191
+ choices: GENRES,
192
+ default: 'other',
193
+ when: !genre,
194
+ },
195
+ {
196
+ type: 'list',
197
+ name: 'age',
198
+ message: 'Target age:',
199
+ choices: [...AGE_GROUPS, new inquirer.Separator(), 'skip'],
200
+ when: !targetAge,
201
+ },
202
+ {
203
+ type: 'input',
204
+ name: 'description',
205
+ message: 'Short description (optional):',
206
+ when: !description,
207
+ },
208
+ ]);
209
+ title = title || answers.title.trim();
210
+ genre = genre || answers.genre.toUpperCase();
211
+ if (!targetAge && answers.age && answers.age !== 'skip') targetAge = answers.age;
212
+ if (!description && answers.description?.trim()) description = answers.description.trim();
213
+ }
214
+
215
+ // ── Village visibility (same flow as deploy) ──
216
+ let villageIds;
217
+ if (typeof options.villages === 'string') {
218
+ villageIds = options.villages.split(',').map((v) => v.trim()).filter(Boolean);
219
+ } else if (nonInteractive) {
220
+ villageIds = []; // unscoped; admins can narrow it in the portal
221
+ } else {
222
+ villageIds = await selectBookVillages();
223
+ if (villageIds === null) return fail(json, 'Could not load villages. Try again, or pass --villages <ids>.');
224
+ }
225
+
226
+ // ── Create → upload → confirm ──
227
+ let spinner = json ? null : villageSpinner('Creating the book...').start();
228
+ let book;
229
+ try {
230
+ const created = await createStoryBook({
231
+ title,
232
+ description: description || undefined,
233
+ genre,
234
+ targetAge: targetAge || undefined,
235
+ villageIds,
236
+ });
237
+ book = created.storyBook;
238
+ if (spinner) spinner.succeed(`Book created: ${chalk.bold(book.slug)}`);
239
+ } catch (err) {
240
+ if (spinner) spinner.stop();
241
+ return fail(json, err.response?.data?.error || err.message);
242
+ }
243
+
244
+ const fileName = basename(resolved);
245
+ spinner = json ? null : villageSpinner(`Uploading ${fileName} (${(fileSize / 1024 / 1024).toFixed(1)}MB)...`).start();
246
+ try {
247
+ const urlResult = await getStoryBookUploadUrl(book.id, {
248
+ fileName,
249
+ assetType: 'SOURCE_PDF',
250
+ fileSize,
251
+ });
252
+
253
+ await axios.put(urlResult.uploadUrl, readFileSync(resolved), {
254
+ headers: { 'Content-Type': urlResult.contentType },
255
+ maxBodyLength: Infinity,
256
+ maxContentLength: Infinity,
257
+ });
258
+
259
+ await confirmStoryBookUpload(book.id, {
260
+ s3Key: urlResult.s3Key,
261
+ fileName,
262
+ assetType: 'SOURCE_PDF',
263
+ });
264
+ if (spinner) spinner.succeed('PDF uploaded');
265
+ } catch (err) {
266
+ if (spinner) spinner.stop();
267
+ return fail(json, err.response?.data?.error || err.message);
268
+ }
269
+
270
+ // ── Watch the rasterizer ──
271
+ spinner = json ? null : villageSpinner('Rendering pages...').start();
272
+ const result = await pollUntilProcessed(book.id, spinner);
273
+
274
+ if (!result.ok) {
275
+ if (spinner) spinner.fail(result.error);
276
+ if (json) console.log(JSON.stringify({ success: false, error: result.error, bookId: book.id, slug: book.slug }));
277
+ process.exitCode = 1;
278
+ return;
279
+ }
280
+
281
+ const pageCount = result.status.pageCount ?? 0;
282
+ if (spinner) spinner.succeed(`${pageCount} pages rendered`);
283
+
284
+ const config = getConfig();
285
+ const portalUrl = `${config.platformBaseUrl.replace(/\/api$/, '')}/story-books`;
286
+
287
+ if (json) {
288
+ console.log(JSON.stringify({
289
+ success: true,
290
+ bookId: book.id,
291
+ slug: book.slug,
292
+ title,
293
+ pageCount,
294
+ status: result.status.status,
295
+ portalUrl,
296
+ }));
297
+ return;
298
+ }
299
+
300
+ console.log('');
301
+ console.log(chalk.green(' ✓ Your book is in the review queue.'));
302
+ console.log(` ${chalk.dim('Title:')} ${title}`);
303
+ console.log(` ${chalk.dim('Pages:')} ${pageCount} (page one is the cover)`);
304
+ console.log(` ${chalk.dim('Genre:')} ${genre.toLowerCase()}`);
305
+ console.log('');
306
+ console.log(` An admin reviews it at ${chalk.cyan(portalUrl)} — once published,`);
307
+ console.log(' it appears in the muni Read tab and readers earn MVT for finishing it.');
308
+ }
package/src/index.js CHANGED
@@ -18,6 +18,7 @@ import {
18
18
  import { createGameCommand } from './commands/create-game.js';
19
19
  import { createCommand } from './commands/create-app.js';
20
20
  import { deployCommand } from './commands/deploy.js';
21
+ import { publishBookCommand } from './commands/publish-book.js';
21
22
  import { statusCommand } from './commands/status.js';
22
23
  import { feedCommand } from './commands/feed.js';
23
24
  import {
@@ -230,6 +231,18 @@ export function run() {
230
231
  .option('--json', 'Output the result as JSON (implies non-interactive)')
231
232
  .action((options) => deployCommand(options));
232
233
 
234
+ program
235
+ .command('publish-book <pdf>')
236
+ .description('Publish a PDF story book to the muni Read tab')
237
+ .option('-t, --title <title>', 'Book title (defaults to the PDF filename)')
238
+ .option('-g, --genre <genre>', 'adventure | folktale | history | biography | science | faith | humor | education | other')
239
+ .option('-a, --age <age>', 'Target age: under-5 | 6-12 | 13-and-over')
240
+ .option('-d, --description <text>', 'Short description')
241
+ .option('--villages <ids>', 'Comma-separated village ids for visibility (skips the prompt)')
242
+ .option('-y, --yes', 'Non-interactive: defaults instead of prompts')
243
+ .option('--json', 'Output the result as JSON (implies non-interactive)')
244
+ .action((pdf, options) => publishBookCommand(pdf, options));
245
+
233
246
  program
234
247
  .command('status')
235
248
  .description('Check deployment status of your games')
package/src/utils/api.js CHANGED
@@ -594,6 +594,44 @@ export async function submitGameForReview(gameId) {
594
594
  return response.data;
595
595
  }
596
596
 
597
+ // ── Story Books API (/api/v1/storybooks) ───────────────
598
+
599
+ export async function createStoryBook(data) {
600
+ const client = getApiClient();
601
+ const response = await client.post('/storybooks/submit', data);
602
+ return response.data;
603
+ }
604
+
605
+ export async function getStoryBookUploadUrl(bookId, data) {
606
+ const client = getApiClient();
607
+ const response = await client.post(`/storybooks/${encodeURIComponent(bookId)}/upload-url`, data);
608
+ return response.data;
609
+ }
610
+
611
+ export async function confirmStoryBookUpload(bookId, data) {
612
+ const client = getApiClient();
613
+ const response = await client.post(`/storybooks/${encodeURIComponent(bookId)}/confirm-upload`, data);
614
+ return response.data;
615
+ }
616
+
617
+ export async function getStoryBookStatus(bookId) {
618
+ const client = getApiClient();
619
+ const response = await client.get(`/storybooks/${encodeURIComponent(bookId)}/status`);
620
+ return response.data;
621
+ }
622
+
623
+ export async function getStoryBookDetail(bookId) {
624
+ const client = getApiClient();
625
+ const response = await client.get(`/storybooks/${encodeURIComponent(bookId)}`);
626
+ return response.data;
627
+ }
628
+
629
+ export async function getMyStoryBooks() {
630
+ const client = getApiClient();
631
+ const response = await client.get('/storybooks/my-books');
632
+ return response.data;
633
+ }
634
+
597
635
  // ── GameKit SDK preflight ──────────────────────────────
598
636
 
599
637
  // Fetch the GUID allowlist for a specific SDK version. Public endpoint —