@myvillage/cli 1.90.0 → 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 +4 -0
- package/package.json +1 -1
- package/src/commands/publish-book.js +308 -0
- package/src/index.js +13 -0
- package/src/utils/api.js +38 -0
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
|
@@ -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 —
|