@myvillage/cli 1.8.4 → 1.9.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@myvillage/cli",
3
- "version": "1.8.4",
3
+ "version": "1.9.0",
4
4
  "description": "MyVillageOS CLI for community developers",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,943 @@
1
+ import chalk from 'chalk';
2
+ import inquirer from 'inquirer';
3
+ import { readFileSync, statSync, createReadStream } from 'fs';
4
+ import { resolve, basename } from 'path';
5
+ import axios from 'axios';
6
+ import pLimit from 'p-limit';
7
+ import { isAuthenticated } from '../utils/auth.js';
8
+ import { brand, villageSpinner } from '../utils/brand.js';
9
+ import {
10
+ createMediaDraft,
11
+ listMediaDrafts,
12
+ getMediaDraft,
13
+ getMediaDraftStatus,
14
+ updateMediaDraft,
15
+ uploadMediaDraftAsset,
16
+ getMediaDraftUploadUrl,
17
+ confirmMediaDraftUpload,
18
+ } from '../utils/api.js';
19
+
20
+ // ── Status badge colors ────────────────────────────────
21
+
22
+ const STATUS_COLORS = {
23
+ DRAFT: chalk.gray,
24
+ SUBMITTED: chalk.cyan,
25
+ IN_REVIEW: chalk.yellow,
26
+ APPROVED: chalk.green,
27
+ REJECTED: chalk.red,
28
+ SCHEDULED: chalk.magenta,
29
+ PUBLISHED: chalk.greenBright,
30
+ CANCELLED: chalk.gray,
31
+ };
32
+
33
+ function statusBadge(status) {
34
+ const colorFn = STATUS_COLORS[status] || chalk.white;
35
+ return colorFn(status);
36
+ }
37
+
38
+ // ── Platform display names ─────────────────────────────
39
+
40
+ const PLATFORM_LABELS = {
41
+ LINKEDIN: 'LinkedIn',
42
+ YOUTUBE: 'YouTube',
43
+ TIKTOK: 'TikTok',
44
+ INSTAGRAM: 'Instagram',
45
+ };
46
+
47
+ // ── Size thresholds ────────────────────────────────────
48
+
49
+ // Files under this size use the simple buffered upload endpoint.
50
+ // Files over this size use presigned URL (direct-to-S3) upload.
51
+ const PRESIGNED_THRESHOLD = 5 * 1024 * 1024; // 5 MB
52
+
53
+ // ── Content type detection ─────────────────────────────
54
+
55
+ const CONTENT_TYPES = {
56
+ png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg',
57
+ gif: 'image/gif', webp: 'image/webp', svg: 'image/svg+xml',
58
+ mp4: 'video/mp4', webm: 'video/webm', mov: 'video/quicktime',
59
+ avi: 'video/x-msvideo', mkv: 'video/x-matroska',
60
+ pdf: 'application/pdf',
61
+ };
62
+
63
+ function getContentType(fileName) {
64
+ const ext = fileName.split('.').pop()?.toLowerCase() || '';
65
+ return CONTENT_TYPES[ext] || 'application/octet-stream';
66
+ }
67
+
68
+ // ── Auto-detect asset type from file extension ─────────
69
+
70
+ function inferAssetType(fileName) {
71
+ const ext = fileName.split('.').pop()?.toLowerCase() || '';
72
+ const imageExts = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'];
73
+ const videoExts = ['mp4', 'webm', 'mov', 'avi', 'mkv'];
74
+ const docExts = ['pdf'];
75
+
76
+ if (imageExts.includes(ext)) return 'IMAGE';
77
+ if (videoExts.includes(ext)) return 'VIDEO';
78
+ if (docExts.includes(ext)) return 'DOCUMENT';
79
+ return null;
80
+ }
81
+
82
+ // ── Platform-specific metadata prompts ─────────────────
83
+
84
+ async function promptPlatformMetadata(platform, existing = {}) {
85
+ const prompts = [];
86
+
87
+ if (platform === 'YOUTUBE') {
88
+ prompts.push(
89
+ {
90
+ type: 'list',
91
+ name: 'privacy',
92
+ message: 'Privacy:',
93
+ choices: [
94
+ { name: 'Public', value: 'public' },
95
+ { name: 'Unlisted', value: 'unlisted' },
96
+ { name: 'Private', value: 'private' },
97
+ ],
98
+ default: existing.privacy || 'public',
99
+ },
100
+ {
101
+ type: 'list',
102
+ name: 'category',
103
+ message: 'Category:',
104
+ choices: [
105
+ { name: 'Education', value: 'Education' },
106
+ { name: 'Science & Technology', value: 'Science & Technology' },
107
+ { name: 'People & Blogs', value: 'People & Blogs' },
108
+ { name: 'Entertainment', value: 'Entertainment' },
109
+ { name: 'Howto & Style', value: 'Howto & Style' },
110
+ { name: 'Gaming', value: 'Gaming' },
111
+ { name: 'Nonprofits & Activism', value: 'Nonprofits & Activism' },
112
+ ],
113
+ default: existing.category || 'Education',
114
+ },
115
+ {
116
+ type: 'input',
117
+ name: 'language',
118
+ message: 'Language (e.g., en, es, fr):',
119
+ default: existing.language || 'en',
120
+ },
121
+ );
122
+ }
123
+
124
+ if (platform === 'LINKEDIN') {
125
+ prompts.push(
126
+ {
127
+ type: 'list',
128
+ name: 'visibility',
129
+ message: 'Visibility:',
130
+ choices: [
131
+ { name: 'Public (anyone)', value: 'PUBLIC' },
132
+ { name: 'Connections only', value: 'CONNECTIONS' },
133
+ ],
134
+ default: existing.visibility || 'PUBLIC',
135
+ },
136
+ {
137
+ type: 'list',
138
+ name: 'contentType',
139
+ message: 'Post type:',
140
+ choices: [
141
+ { name: 'Share / Status update', value: 'share' },
142
+ { name: 'Article', value: 'article' },
143
+ ],
144
+ default: existing.contentType || 'share',
145
+ },
146
+ );
147
+ }
148
+
149
+ if (platform === 'TIKTOK') {
150
+ prompts.push(
151
+ {
152
+ type: 'list',
153
+ name: 'privacy',
154
+ message: 'Privacy:',
155
+ choices: [
156
+ { name: 'Public', value: 'PUBLIC_TO_EVERYONE' },
157
+ { name: 'Friends only', value: 'MUTUAL_FOLLOW_FRIENDS' },
158
+ { name: 'Private (only me)', value: 'SELF_ONLY' },
159
+ ],
160
+ default: existing.privacy || 'PUBLIC_TO_EVERYONE',
161
+ },
162
+ {
163
+ type: 'confirm',
164
+ name: 'allowComments',
165
+ message: 'Allow comments?',
166
+ default: existing.allowComments !== undefined ? existing.allowComments : true,
167
+ },
168
+ {
169
+ type: 'confirm',
170
+ name: 'allowDuet',
171
+ message: 'Allow duets?',
172
+ default: existing.allowDuet !== undefined ? existing.allowDuet : true,
173
+ },
174
+ {
175
+ type: 'confirm',
176
+ name: 'allowStitch',
177
+ message: 'Allow stitches?',
178
+ default: existing.allowStitch !== undefined ? existing.allowStitch : true,
179
+ },
180
+ {
181
+ type: 'confirm',
182
+ name: 'brandedContent',
183
+ message: 'Is this branded content?',
184
+ default: existing.brandedContent || false,
185
+ },
186
+ );
187
+ }
188
+
189
+ if (platform === 'INSTAGRAM') {
190
+ prompts.push(
191
+ {
192
+ type: 'input',
193
+ name: 'altText',
194
+ message: 'Alt text for accessibility (optional):',
195
+ default: existing.altText || '',
196
+ },
197
+ {
198
+ type: 'input',
199
+ name: 'location',
200
+ message: 'Location tag (optional):',
201
+ default: existing.location || '',
202
+ },
203
+ {
204
+ type: 'input',
205
+ name: 'collaborators',
206
+ message: 'Collaborator handles (comma-separated, optional):',
207
+ default: existing.collaborators?.join(', ') || '',
208
+ },
209
+ );
210
+ }
211
+
212
+ if (prompts.length === 0) return null;
213
+
214
+ console.log(brand.teal(`\n ${PLATFORM_LABELS[platform]} settings:\n`));
215
+ const answers = await inquirer.prompt(prompts);
216
+
217
+ // Clean up empty strings and parse collaborators
218
+ const metadata = {};
219
+ for (const [key, value] of Object.entries(answers)) {
220
+ if (value === '' || value === undefined) continue;
221
+ if (key === 'collaborators' && typeof value === 'string') {
222
+ const parsed = value.split(',').map(s => s.trim()).filter(Boolean);
223
+ if (parsed.length > 0) metadata[key] = parsed;
224
+ } else {
225
+ metadata[key] = value;
226
+ }
227
+ }
228
+
229
+ return Object.keys(metadata).length > 0 ? metadata : null;
230
+ }
231
+
232
+ // ── media draft create ─────────────────────────────────
233
+
234
+ export async function mediaDraftCreateCommand(options = {}) {
235
+ if (!isAuthenticated()) {
236
+ console.log(chalk.red(' \u2717 Authentication required. Run \'myvillage login\' first.'));
237
+ return;
238
+ }
239
+
240
+ try {
241
+ console.log(`\n ${brand.gold(chalk.bold('\u2726 Media Draft \u2014 New Submission'))}\n`);
242
+
243
+ // Platform selection
244
+ const platformAnswer = await inquirer.prompt([{
245
+ type: 'list',
246
+ name: 'platform',
247
+ message: 'Target platform:',
248
+ choices: [
249
+ { name: 'LinkedIn', value: 'LINKEDIN' },
250
+ { name: 'YouTube', value: 'YOUTUBE' },
251
+ { name: 'TikTok', value: 'TIKTOK' },
252
+ { name: 'Instagram', value: 'INSTAGRAM' },
253
+ ],
254
+ }]);
255
+
256
+ // Content fields based on platform
257
+ const isVideo = ['YOUTUBE', 'TIKTOK'].includes(platformAnswer.platform);
258
+
259
+ const contentPrompts = [];
260
+
261
+ if (platformAnswer.platform === 'YOUTUBE') {
262
+ contentPrompts.push({
263
+ type: 'input',
264
+ name: 'title',
265
+ message: 'Video title:',
266
+ validate: (v) => v.trim() ? true : 'Title is required for YouTube',
267
+ });
268
+ }
269
+
270
+ contentPrompts.push({
271
+ type: 'input',
272
+ name: 'caption',
273
+ message: isVideo ? 'Description / caption:' : 'Caption:',
274
+ validate: (v, answers) => {
275
+ if (v.trim()) return true;
276
+ if (answers?.title?.trim()) return true;
277
+ return 'Caption is required';
278
+ },
279
+ });
280
+
281
+ if (isVideo) {
282
+ contentPrompts.push({
283
+ type: 'input',
284
+ name: 'script',
285
+ message: 'Script (optional, press Enter to skip):',
286
+ });
287
+ }
288
+
289
+ contentPrompts.push({
290
+ type: 'input',
291
+ name: 'tags',
292
+ message: 'Tags / hashtags (comma-separated, optional):',
293
+ });
294
+
295
+ contentPrompts.push({
296
+ type: 'input',
297
+ name: 'suggestedPublishAt',
298
+ message: 'Suggested publish date (YYYY-MM-DD or YYYY-MM-DDTHH:mm, optional):',
299
+ });
300
+
301
+ const answers = await inquirer.prompt(contentPrompts);
302
+
303
+ // Platform-specific settings
304
+ const platformMetadata = await promptPlatformMetadata(platformAnswer.platform);
305
+
306
+ // Build draft payload
307
+ const data = {
308
+ platform: platformAnswer.platform,
309
+ intakeMethod: 'CLI',
310
+ };
311
+
312
+ if (answers.caption?.trim()) data.caption = answers.caption.trim();
313
+ if (answers.title?.trim()) data.title = answers.title.trim();
314
+ if (answers.script?.trim()) data.script = answers.script.trim();
315
+ if (answers.tags?.trim()) {
316
+ data.tags = answers.tags.split(',').map(t => t.trim()).filter(Boolean);
317
+ }
318
+ if (answers.suggestedPublishAt?.trim()) {
319
+ data.suggestedPublishAt = new Date(answers.suggestedPublishAt.trim()).toISOString();
320
+ }
321
+ if (platformMetadata) {
322
+ data.platformMetadata = platformMetadata;
323
+ }
324
+
325
+ // Auto-submit flag
326
+ if (options.submit) {
327
+ data.submit = true;
328
+ }
329
+
330
+ // Create draft
331
+ const spinner = villageSpinner('Creating draft...').start();
332
+ const result = await createMediaDraft(data);
333
+ spinner.succeed('Draft created!');
334
+
335
+ const draft = result.draft;
336
+ console.log(brand.green(`\n \u2713 Draft ID: ${chalk.bold(draft.draftId)}`));
337
+ console.log(brand.teal(` Platform: ${PLATFORM_LABELS[draft.platform]}`));
338
+ console.log(brand.teal(` Status: ${statusBadge(draft.status)}`));
339
+
340
+ // ── File uploads ───────────────────────────────────
341
+ // Collect files from --file flags or interactive prompts
342
+ const filesToUpload = [];
343
+
344
+ if (options.file) {
345
+ // --file can be passed multiple times (commander collects into array)
346
+ const files = Array.isArray(options.file) ? options.file : [options.file];
347
+ for (const f of files) {
348
+ const assetType = options.assetType?.toUpperCase() || inferAssetType(f) || 'IMAGE';
349
+ filesToUpload.push({ path: f, assetType });
350
+ }
351
+ } else {
352
+ // Interactive multi-file loop
353
+ let addMore = true;
354
+ while (addMore) {
355
+ const { wantsUpload } = await inquirer.prompt([{
356
+ type: 'confirm',
357
+ name: 'wantsUpload',
358
+ message: filesToUpload.length === 0
359
+ ? 'Attach media files?'
360
+ : 'Attach another file?',
361
+ default: false,
362
+ }]);
363
+
364
+ if (!wantsUpload) break;
365
+
366
+ const fileAnswer = await inquirer.prompt([
367
+ {
368
+ type: 'input',
369
+ name: 'filePath',
370
+ message: 'File path:',
371
+ validate: (v) => {
372
+ if (!v.trim()) return 'File path is required';
373
+ try {
374
+ statSync(resolve(v.trim()));
375
+ return true;
376
+ } catch {
377
+ return 'File not found';
378
+ }
379
+ },
380
+ },
381
+ {
382
+ type: 'list',
383
+ name: 'assetType',
384
+ message: 'Asset type:',
385
+ choices: [
386
+ { name: 'Image', value: 'IMAGE' },
387
+ { name: 'Video', value: 'VIDEO' },
388
+ { name: 'Thumbnail', value: 'THUMBNAIL' },
389
+ { name: 'Document', value: 'DOCUMENT' },
390
+ ],
391
+ default: () => {
392
+ // Try to auto-detect from the last input
393
+ return undefined;
394
+ },
395
+ },
396
+ ]);
397
+
398
+ filesToUpload.push({ path: fileAnswer.filePath.trim(), assetType: fileAnswer.assetType });
399
+ }
400
+ }
401
+
402
+ // Upload all collected files
403
+ if (filesToUpload.length > 0) {
404
+ console.log(brand.teal(`\n Uploading ${filesToUpload.length} file${filesToUpload.length > 1 ? 's' : ''}...\n`));
405
+ await uploadFiles(draft.id, filesToUpload);
406
+ }
407
+
408
+ // Submit prompt if not already submitted
409
+ if (!options.submit && draft.status === 'DRAFT') {
410
+ const { wantsSubmit } = await inquirer.prompt([{
411
+ type: 'confirm',
412
+ name: 'wantsSubmit',
413
+ message: 'Submit for review now?',
414
+ default: true,
415
+ }]);
416
+
417
+ if (wantsSubmit) {
418
+ const submitSpinner = villageSpinner('Submitting for review...').start();
419
+ try {
420
+ await updateMediaDraft(draft.id, { submit: true });
421
+ submitSpinner.succeed('Submitted for review!');
422
+ } catch (err) {
423
+ const message = err.response?.data?.error || err.message;
424
+ submitSpinner.fail(`Failed to submit: ${message}`);
425
+ }
426
+ }
427
+ }
428
+
429
+ console.log(brand.teal(`\n Track status: myvillage media draft status ${draft.draftId}\n`));
430
+ } catch (err) {
431
+ const message = err.response?.data?.error || err.response?.data?.message || err.message;
432
+ console.log(chalk.red(` \u2717 Failed to create draft: ${message}\n`));
433
+ }
434
+ }
435
+
436
+ // ── Multi-file upload orchestrator ─────────────────────
437
+
438
+ async function uploadFiles(draftId, files) {
439
+ const limit = pLimit(3); // Max 3 concurrent uploads
440
+
441
+ const tasks = files.map((file) =>
442
+ limit(() => uploadSingleFile(draftId, file.path, file.assetType))
443
+ );
444
+
445
+ await Promise.all(tasks);
446
+ }
447
+
448
+ // ── Single file upload (auto-selects strategy) ─────────
449
+
450
+ async function uploadSingleFile(draftId, filePath, assetType) {
451
+ const resolvedPath = resolve(filePath);
452
+ const fileName = basename(resolvedPath);
453
+
454
+ let fileSize;
455
+ try {
456
+ const stat = statSync(resolvedPath);
457
+ fileSize = stat.size;
458
+ } catch (err) {
459
+ console.log(chalk.red(` \u2717 Could not read file: ${err.message}`));
460
+ return;
461
+ }
462
+
463
+ // Use presigned upload for large files, direct upload for small ones
464
+ if (fileSize > PRESIGNED_THRESHOLD) {
465
+ await uploadViaPresignedUrl(draftId, resolvedPath, fileName, fileSize, assetType);
466
+ } else {
467
+ await uploadViaDirect(draftId, resolvedPath, fileName, assetType);
468
+ }
469
+ }
470
+
471
+ // ── Presigned URL upload (large files — direct to S3) ──
472
+
473
+ async function uploadViaPresignedUrl(draftId, resolvedPath, fileName, fileSize, assetType) {
474
+ const contentType = getContentType(fileName);
475
+ const spinner = villageSpinner(`${fileName} (${formatBytes(fileSize)}) — requesting upload URL...`).start();
476
+
477
+ try {
478
+ // 1. Get presigned URL from API
479
+ const urlResult = await getMediaDraftUploadUrl(draftId, {
480
+ fileName,
481
+ assetType,
482
+ contentType,
483
+ fileSize,
484
+ });
485
+
486
+ // 2. Upload directly to S3 with progress
487
+ spinner.text = chalk.yellow(`${fileName} — uploading to storage...`);
488
+
489
+ const fileBuffer = readFileSync(resolvedPath);
490
+
491
+ await axios.put(urlResult.uploadUrl, fileBuffer, {
492
+ headers: {
493
+ 'Content-Type': contentType,
494
+ },
495
+ maxBodyLength: Infinity,
496
+ maxContentLength: Infinity,
497
+ onUploadProgress: (progressEvent) => {
498
+ if (progressEvent.total) {
499
+ const pct = Math.round((progressEvent.loaded / progressEvent.total) * 100);
500
+ spinner.text = chalk.yellow(`${fileName} — ${pct}% (${formatBytes(progressEvent.loaded)} / ${formatBytes(progressEvent.total)})`);
501
+ }
502
+ },
503
+ });
504
+
505
+ // 3. Confirm upload with API
506
+ spinner.text = chalk.yellow(`${fileName} — confirming...`);
507
+
508
+ const confirmResult = await confirmMediaDraftUpload(draftId, {
509
+ s3Key: urlResult.s3Key,
510
+ fileName,
511
+ assetType,
512
+ contentType,
513
+ });
514
+
515
+ spinner.succeed(`${fileName} (${formatBytes(fileSize)})`);
516
+ console.log(brand.teal(` Type: ${confirmResult.asset.assetType} Uploaded via presigned URL`));
517
+ } catch (err) {
518
+ const message = err.response?.data?.error || err.message;
519
+ spinner.fail(`${fileName} — upload failed: ${message}`);
520
+ }
521
+ }
522
+
523
+ // ── Direct upload (small files — via API server) ───────
524
+
525
+ async function uploadViaDirect(draftId, resolvedPath, fileName, assetType) {
526
+ const spinner = villageSpinner(`${fileName} — uploading...`).start();
527
+
528
+ try {
529
+ const fileBuffer = readFileSync(resolvedPath);
530
+ const formData = new FormData();
531
+ const blob = new Blob([fileBuffer]);
532
+ formData.append('file', blob, fileName);
533
+ formData.append('assetType', assetType || 'IMAGE');
534
+
535
+ const result = await uploadMediaDraftAsset(draftId, formData);
536
+ spinner.succeed(`${fileName} (${formatBytes(result.asset.fileSize)})`);
537
+ console.log(brand.teal(` Type: ${result.asset.assetType}`));
538
+ } catch (err) {
539
+ const message = err.response?.data?.error || err.message;
540
+ spinner.fail(`${fileName} — upload failed: ${message}`);
541
+ }
542
+ }
543
+
544
+ // ── Bytes formatter ────────────────────────────────────
545
+
546
+ function formatBytes(bytes) {
547
+ if (!bytes) return 'unknown';
548
+ if (bytes < 1024) return `${bytes} B`;
549
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
550
+ if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
551
+ return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
552
+ }
553
+
554
+ // ── media draft edit ───────────────────────────────────
555
+
556
+ export async function mediaDraftEditCommand(id) {
557
+ if (!isAuthenticated()) {
558
+ console.log(chalk.red(' \u2717 Authentication required. Run \'myvillage login\' first.'));
559
+ return;
560
+ }
561
+
562
+ if (!id) {
563
+ console.log(chalk.red(' \u2717 Draft ID is required (e.g., MED-2026-0001)'));
564
+ return;
565
+ }
566
+
567
+ const spinner = villageSpinner(`Loading draft ${id}...`).start();
568
+
569
+ let draft;
570
+ try {
571
+ const result = await getMediaDraft(id);
572
+ draft = result.draft;
573
+ spinner.stop();
574
+ } catch (err) {
575
+ if (err.response?.status === 404) {
576
+ spinner.fail(`Draft not found: ${id}`);
577
+ } else {
578
+ spinner.fail(`Failed to load draft: ${err.response?.data?.error || err.message}`);
579
+ }
580
+ return;
581
+ }
582
+
583
+ if (!['DRAFT', 'REJECTED'].includes(draft.status)) {
584
+ console.log(chalk.red(`\n \u2717 Cannot edit draft in ${draft.status} status.`));
585
+ console.log(brand.teal(' Only DRAFT or REJECTED drafts can be edited.\n'));
586
+ return;
587
+ }
588
+
589
+ const platform = draft.platform;
590
+ const isVideo = ['YOUTUBE', 'TIKTOK'].includes(platform);
591
+
592
+ console.log(`\n ${brand.gold(chalk.bold(`\u2726 Edit Draft \u2014 ${draft.draftId}`))}`);
593
+ console.log(brand.teal(` Platform: ${PLATFORM_LABELS[platform]} Status: ${statusBadge(draft.status)}`));
594
+
595
+ if (draft.rejectionReason) {
596
+ console.log(chalk.red(` Rejection reason: ${draft.rejectionReason}`));
597
+ }
598
+
599
+ if (draft.reviews?.length > 0) {
600
+ const lastReview = draft.reviews[0]; // already sorted desc
601
+ if (lastReview.notes) {
602
+ console.log(brand.cream(` Last review notes: ${lastReview.notes}`));
603
+ }
604
+ }
605
+
606
+ console.log(brand.cream('\n Press Enter to keep current value, or type a new one.\n'));
607
+
608
+ try {
609
+ // Content fields
610
+ const contentPrompts = [];
611
+
612
+ if (platform === 'YOUTUBE') {
613
+ contentPrompts.push({
614
+ type: 'input',
615
+ name: 'title',
616
+ message: `Video title:`,
617
+ default: draft.title || '',
618
+ });
619
+ }
620
+
621
+ contentPrompts.push({
622
+ type: 'input',
623
+ name: 'caption',
624
+ message: isVideo ? 'Description / caption:' : 'Caption:',
625
+ default: draft.caption || '',
626
+ });
627
+
628
+ if (isVideo) {
629
+ contentPrompts.push({
630
+ type: 'input',
631
+ name: 'script',
632
+ message: 'Script:',
633
+ default: draft.script || '',
634
+ });
635
+ }
636
+
637
+ contentPrompts.push({
638
+ type: 'input',
639
+ name: 'tags',
640
+ message: 'Tags / hashtags (comma-separated):',
641
+ default: draft.tags?.join(', ') || '',
642
+ });
643
+
644
+ contentPrompts.push({
645
+ type: 'input',
646
+ name: 'suggestedPublishAt',
647
+ message: 'Suggested publish date (YYYY-MM-DD or YYYY-MM-DDTHH:mm):',
648
+ default: draft.suggestedPublishAt
649
+ ? new Date(draft.suggestedPublishAt).toISOString().slice(0, 16)
650
+ : '',
651
+ });
652
+
653
+ const answers = await inquirer.prompt(contentPrompts);
654
+
655
+ // Platform-specific settings
656
+ const existingMetadata = draft.platformMetadata || {};
657
+ const platformMetadata = await promptPlatformMetadata(platform, existingMetadata);
658
+
659
+ // Build update payload — only send changed fields
660
+ const updates = {};
661
+
662
+ if (platform === 'YOUTUBE' && answers.title !== (draft.title || '')) {
663
+ updates.title = answers.title.trim() || undefined;
664
+ }
665
+ if (answers.caption !== (draft.caption || '')) {
666
+ updates.caption = answers.caption.trim() || undefined;
667
+ }
668
+ if (isVideo && answers.script !== (draft.script || '')) {
669
+ updates.script = answers.script.trim() || undefined;
670
+ }
671
+
672
+ const newTags = answers.tags
673
+ ? answers.tags.split(',').map(t => t.trim()).filter(Boolean)
674
+ : [];
675
+ const oldTags = draft.tags || [];
676
+ if (JSON.stringify(newTags) !== JSON.stringify(oldTags)) {
677
+ updates.tags = newTags;
678
+ }
679
+
680
+ const oldPublish = draft.suggestedPublishAt
681
+ ? new Date(draft.suggestedPublishAt).toISOString().slice(0, 16)
682
+ : '';
683
+ if (answers.suggestedPublishAt !== oldPublish) {
684
+ updates.suggestedPublishAt = answers.suggestedPublishAt.trim()
685
+ ? new Date(answers.suggestedPublishAt.trim()).toISOString()
686
+ : null;
687
+ }
688
+
689
+ if (platformMetadata) {
690
+ updates.platformMetadata = platformMetadata;
691
+ }
692
+
693
+ // File uploads
694
+ const filesToUpload = [];
695
+ let addMore = true;
696
+ const existingAssets = draft.assets || [];
697
+
698
+ if (existingAssets.length > 0) {
699
+ console.log(brand.teal(`\n Current files (${existingAssets.length}):`));
700
+ for (const asset of existingAssets) {
701
+ console.log(` ${chalk.dim(asset.assetType)} ${asset.fileName} (${formatBytes(asset.fileSize)})`);
702
+ }
703
+ }
704
+
705
+ while (addMore) {
706
+ const { wantsUpload } = await inquirer.prompt([{
707
+ type: 'confirm',
708
+ name: 'wantsUpload',
709
+ message: filesToUpload.length === 0
710
+ ? 'Attach additional media files?'
711
+ : 'Attach another file?',
712
+ default: false,
713
+ }]);
714
+
715
+ if (!wantsUpload) break;
716
+
717
+ const fileAnswer = await inquirer.prompt([
718
+ {
719
+ type: 'input',
720
+ name: 'filePath',
721
+ message: 'File path:',
722
+ validate: (v) => {
723
+ if (!v.trim()) return 'File path is required';
724
+ try {
725
+ statSync(resolve(v.trim()));
726
+ return true;
727
+ } catch {
728
+ return 'File not found';
729
+ }
730
+ },
731
+ },
732
+ {
733
+ type: 'list',
734
+ name: 'assetType',
735
+ message: 'Asset type:',
736
+ choices: [
737
+ { name: 'Image', value: 'IMAGE' },
738
+ { name: 'Video', value: 'VIDEO' },
739
+ { name: 'Thumbnail', value: 'THUMBNAIL' },
740
+ { name: 'Document', value: 'DOCUMENT' },
741
+ ],
742
+ },
743
+ ]);
744
+
745
+ filesToUpload.push({ path: fileAnswer.filePath.trim(), assetType: fileAnswer.assetType });
746
+ }
747
+
748
+ // Apply updates
749
+ const hasFieldChanges = Object.keys(updates).length > 0;
750
+ const hasFileChanges = filesToUpload.length > 0;
751
+
752
+ if (!hasFieldChanges && !hasFileChanges) {
753
+ console.log(brand.teal('\n No changes made.\n'));
754
+ return;
755
+ }
756
+
757
+ if (hasFieldChanges) {
758
+ const updateSpinner = villageSpinner('Updating draft...').start();
759
+ try {
760
+ await updateMediaDraft(draft.id, updates);
761
+ updateSpinner.succeed('Draft updated!');
762
+ } catch (err) {
763
+ const message = err.response?.data?.error || err.message;
764
+ updateSpinner.fail(`Failed to update: ${message}`);
765
+ return;
766
+ }
767
+ }
768
+
769
+ if (hasFileChanges) {
770
+ console.log(brand.teal(`\n Uploading ${filesToUpload.length} file${filesToUpload.length > 1 ? 's' : ''}...\n`));
771
+ await uploadFiles(draft.id, filesToUpload);
772
+ }
773
+
774
+ // Offer to resubmit if it was rejected
775
+ if (draft.status === 'REJECTED' || draft.status === 'DRAFT') {
776
+ const { wantsSubmit } = await inquirer.prompt([{
777
+ type: 'confirm',
778
+ name: 'wantsSubmit',
779
+ message: draft.status === 'REJECTED' ? 'Resubmit for review?' : 'Submit for review?',
780
+ default: true,
781
+ }]);
782
+
783
+ if (wantsSubmit) {
784
+ const submitSpinner = villageSpinner('Submitting for review...').start();
785
+ try {
786
+ await updateMediaDraft(draft.id, { submit: true });
787
+ submitSpinner.succeed(draft.status === 'REJECTED' ? 'Resubmitted for review!' : 'Submitted for review!');
788
+ } catch (err) {
789
+ const message = err.response?.data?.error || err.message;
790
+ submitSpinner.fail(`Failed to submit: ${message}`);
791
+ }
792
+ }
793
+ }
794
+
795
+ console.log(brand.teal(`\n Track status: myvillage media draft status ${draft.draftId}\n`));
796
+ } catch (err) {
797
+ const message = err.response?.data?.error || err.response?.data?.message || err.message;
798
+ console.log(chalk.red(` \u2717 Failed to edit draft: ${message}\n`));
799
+ }
800
+ }
801
+
802
+ // ── media draft list ───────────────────────────────────
803
+
804
+ export async function mediaDraftListCommand(options = {}) {
805
+ if (!isAuthenticated()) {
806
+ console.log(chalk.red(' \u2717 Authentication required. Run \'myvillage login\' first.'));
807
+ return;
808
+ }
809
+
810
+ const spinner = villageSpinner('Loading drafts...').start();
811
+
812
+ try {
813
+ const params = {
814
+ limit: parseInt(options.limit) || 20,
815
+ offset: parseInt(options.offset) || 0,
816
+ sort: options.sort || 'newest',
817
+ };
818
+ if (options.status) params.status = options.status.toUpperCase();
819
+ if (options.platform) params.platform = options.platform.toUpperCase();
820
+ if (options.search) params.search = options.search;
821
+
822
+ const result = await listMediaDrafts(params);
823
+ spinner.stop();
824
+
825
+ if (options.json) {
826
+ console.log(JSON.stringify(result, null, 2));
827
+ return;
828
+ }
829
+
830
+ const drafts = result.drafts || [];
831
+
832
+ if (drafts.length === 0) {
833
+ console.log(brand.teal('\n No media drafts found.\n'));
834
+ console.log(brand.cream(' Create one with: myvillage media draft create\n'));
835
+ return;
836
+ }
837
+
838
+ const statusLabel = options.status
839
+ ? ` \u2014 ${options.status.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())}`
840
+ : '';
841
+
842
+ console.log(`\n ${chalk.bold(`Media Drafts${statusLabel}`)}\n`);
843
+
844
+ for (const draft of drafts) {
845
+ const platform = PLATFORM_LABELS[draft.platform] || draft.platform;
846
+ const status = statusBadge(draft.status);
847
+ const preview = draft.caption
848
+ ? draft.caption.substring(0, 60) + (draft.caption.length > 60 ? '...' : '')
849
+ : draft.title || draft.script?.substring(0, 60) || '(no content)';
850
+ const date = new Date(draft.createdAt).toLocaleDateString();
851
+ const assetCount = draft.assets?.length || draft._count?.assets || 0;
852
+ const assetLabel = assetCount > 0 ? chalk.dim(` [${assetCount} file${assetCount > 1 ? 's' : ''}]`) : '';
853
+
854
+ console.log(` ${brand.gold(draft.draftId)} ${status} ${chalk.dim(platform)} ${chalk.dim(date)}${assetLabel}`);
855
+ console.log(` ${brand.cream(preview)}`);
856
+ console.log('');
857
+ }
858
+
859
+ const { pagination } = result;
860
+ if (pagination) {
861
+ console.log(brand.teal(` Showing ${drafts.length} of ${pagination.total} drafts`));
862
+ if (pagination.hasMore) {
863
+ console.log(brand.teal(` More results: --offset=${pagination.offset + pagination.limit}`));
864
+ }
865
+ console.log('');
866
+ }
867
+ } catch (err) {
868
+ const message = err.response?.data?.error || err.message;
869
+ spinner.fail(`Failed to load drafts: ${message}`);
870
+ }
871
+ }
872
+
873
+ // ── media draft status ─────────────────────────────────
874
+
875
+ export async function mediaDraftStatusCommand(id) {
876
+ if (!isAuthenticated()) {
877
+ console.log(chalk.red(' \u2717 Authentication required. Run \'myvillage login\' first.'));
878
+ return;
879
+ }
880
+
881
+ if (!id) {
882
+ console.log(chalk.red(' \u2717 Draft ID is required (e.g., MED-2026-0001)'));
883
+ return;
884
+ }
885
+
886
+ const spinner = villageSpinner(`Loading status for ${id}...`).start();
887
+
888
+ try {
889
+ const result = await getMediaDraftStatus(id);
890
+ spinner.stop();
891
+
892
+ const platform = PLATFORM_LABELS[result.platform] || result.platform;
893
+ const status = statusBadge(result.status);
894
+
895
+ console.log(`\n ${chalk.bold('Media Draft Status')}\n`);
896
+ console.log(` ${brand.gold('Draft ID:')} ${result.draftId}`);
897
+ console.log(` ${brand.gold('Status:')} ${status}`);
898
+ console.log(` ${brand.gold('Platform:')} ${platform}`);
899
+
900
+ if (result.title) {
901
+ console.log(` ${brand.gold('Title:')} ${result.title}`);
902
+ }
903
+ if (result.caption) {
904
+ console.log(` ${brand.gold('Caption:')} ${result.caption}`);
905
+ }
906
+ if (result.suggestedPublishAt) {
907
+ console.log(` ${brand.gold('Publish at:')} ${new Date(result.suggestedPublishAt).toLocaleString()}`);
908
+ }
909
+ if (result.publishedAt) {
910
+ console.log(` ${brand.gold('Published:')} ${new Date(result.publishedAt).toLocaleString()}`);
911
+ }
912
+ if (result.platformPostUrl) {
913
+ console.log(` ${brand.gold('Post URL:')} ${result.platformPostUrl}`);
914
+ }
915
+ if (result.rejectionReason) {
916
+ console.log(` ${chalk.red('Rejection:')} ${result.rejectionReason}`);
917
+ }
918
+
919
+ console.log(` ${brand.gold('Created:')} ${new Date(result.createdAt).toLocaleString()}`);
920
+ console.log(` ${brand.gold('Updated:')} ${new Date(result.updatedAt).toLocaleString()}`);
921
+
922
+ if (result.reviewCount > 0) {
923
+ console.log(` ${brand.gold('Reviews:')} ${result.reviewCount}`);
924
+ }
925
+
926
+ if (result.lastReview) {
927
+ const review = result.lastReview;
928
+ console.log(`\n ${chalk.bold('Latest Review')}`);
929
+ console.log(` Action: ${review.action}`);
930
+ if (review.notes) console.log(` Notes: ${review.notes}`);
931
+ console.log(` Date: ${new Date(review.createdAt).toLocaleString()}`);
932
+ }
933
+
934
+ console.log('');
935
+ } catch (err) {
936
+ if (err.response?.status === 404) {
937
+ spinner.fail(`Draft not found: ${id}`);
938
+ } else {
939
+ const message = err.response?.data?.error || err.message;
940
+ spinner.fail(`Failed to load status: ${message}`);
941
+ }
942
+ }
943
+ }
package/src/index.js CHANGED
@@ -57,6 +57,12 @@ import { checkinCommand } from './commands/checkin.js';
57
57
  import { discoverCommand } from './commands/discover.js';
58
58
  import { logCommand } from './commands/log.js';
59
59
  import { storyCommand } from './commands/story.js';
60
+ import {
61
+ mediaDraftCreateCommand,
62
+ mediaDraftEditCommand,
63
+ mediaDraftListCommand,
64
+ mediaDraftStatusCommand,
65
+ } from './commands/media.js';
60
66
  import {
61
67
  soulprintInitCommand,
62
68
  soulprintIngestCommand,
@@ -398,6 +404,46 @@ export function run() {
398
404
  .option('--contact <name>', 'Contact name')
399
405
  .action(bizreqsImportCommand);
400
406
 
407
+ // ── Media: Draft Submission Pipeline ──────────────────────
408
+
409
+ const mediaCmd = program
410
+ .command('media')
411
+ .description('Media draft submission and approval pipeline');
412
+
413
+ const mediaDraftCmd = mediaCmd
414
+ .command('draft')
415
+ .description('Create and manage social media drafts');
416
+
417
+ mediaDraftCmd
418
+ .command('create')
419
+ .description('Create a new media draft (interactive)')
420
+ .option('--submit', 'Auto-submit for review after creation')
421
+ .option('--file <path...>', 'Attach media files (repeatable)')
422
+ .option('--asset-type <type>', 'Asset type for --file: IMAGE, VIDEO, THUMBNAIL, DOCUMENT (auto-detected if omitted)')
423
+ .action(mediaDraftCreateCommand);
424
+
425
+ mediaDraftCmd
426
+ .command('edit <id>')
427
+ .description('Edit a draft (DRAFT or REJECTED status only)')
428
+ .action(mediaDraftEditCommand);
429
+
430
+ mediaDraftCmd
431
+ .command('list')
432
+ .description('List your media drafts')
433
+ .option('--status <status>', 'Filter: DRAFT, SUBMITTED, IN_REVIEW, APPROVED, REJECTED, SCHEDULED, PUBLISHED')
434
+ .option('--platform <platform>', 'Filter: LINKEDIN, YOUTUBE, TIKTOK, INSTAGRAM')
435
+ .option('--search <query>', 'Search by caption, title, or draft ID')
436
+ .option('--sort <sort>', 'Sort: newest, oldest, publish_date', 'newest')
437
+ .option('-n, --limit <number>', 'Number of results', '20')
438
+ .option('--offset <number>', 'Pagination offset', '0')
439
+ .option('--json', 'Output raw JSON')
440
+ .action(mediaDraftListCommand);
441
+
442
+ mediaDraftCmd
443
+ .command('status <id>')
444
+ .description('Check draft status (e.g., MED-2026-0001)')
445
+ .action(mediaDraftStatusCommand);
446
+
401
447
  // ── SoulPrint Studio: Model Training Pipeline ───────────
402
448
 
403
449
  const soulprintCmd = program
@@ -1398,7 +1398,7 @@ export default function AgentChat() {
1398
1398
  <div className="agent-chat-messages">
1399
1399
  {messages.length === 0 && <p style={{ color: '#999' }}>Ask the agent anything about your village...</p>}
1400
1400
  {messages.map((msg, i) => (
1401
- <div key={i} className={`message ${msg.role}`}>
1401
+ <div key={i} className={\`message \${msg.role}\`}>
1402
1402
  <strong>{msg.role === 'user' ? 'You' : 'Agent'}:</strong> {msg.text}
1403
1403
  </div>
1404
1404
  ))}
package/src/utils/api.js CHANGED
@@ -466,3 +466,57 @@ export async function getBizReqsSpec(id) {
466
466
  const response = await client.get(`/${encodeURIComponent(id)}/spec`);
467
467
  return response.data;
468
468
  }
469
+
470
+ // ── Media Drafts API (/api/media/drafts) ────────────────
471
+
472
+ export async function createMediaDraft(data) {
473
+ const client = getPlatformClient();
474
+ const response = await client.post('/media/drafts', data);
475
+ return response.data;
476
+ }
477
+
478
+ export async function listMediaDrafts(params = {}) {
479
+ const client = getPlatformClient();
480
+ const response = await client.get('/media/drafts', { params });
481
+ return response.data;
482
+ }
483
+
484
+ export async function getMediaDraft(id) {
485
+ const client = getPlatformClient();
486
+ const response = await client.get(`/media/drafts/${encodeURIComponent(id)}`);
487
+ return response.data;
488
+ }
489
+
490
+ export async function updateMediaDraft(id, data) {
491
+ const client = getPlatformClient();
492
+ const response = await client.patch(`/media/drafts/${encodeURIComponent(id)}`, data);
493
+ return response.data;
494
+ }
495
+
496
+ export async function getMediaDraftStatus(id) {
497
+ const client = getPlatformClient();
498
+ const response = await client.get(`/media/drafts/${encodeURIComponent(id)}/status`);
499
+ return response.data;
500
+ }
501
+
502
+ export async function uploadMediaDraftAsset(id, formData) {
503
+ const client = getPlatformClient();
504
+ const response = await client.post(`/media/drafts/${encodeURIComponent(id)}/upload`, formData, {
505
+ headers: { 'Content-Type': 'multipart/form-data' },
506
+ maxBodyLength: Infinity,
507
+ maxContentLength: Infinity,
508
+ });
509
+ return response.data;
510
+ }
511
+
512
+ export async function getMediaDraftUploadUrl(id, data) {
513
+ const client = getPlatformClient();
514
+ const response = await client.post(`/media/drafts/${encodeURIComponent(id)}/upload-url`, data);
515
+ return response.data;
516
+ }
517
+
518
+ export async function confirmMediaDraftUpload(id, data) {
519
+ const client = getPlatformClient();
520
+ const response = await client.post(`/media/drafts/${encodeURIComponent(id)}/confirm-upload`, data);
521
+ return response.data;
522
+ }