@myvillage/cli 1.10.1 → 1.17.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.
@@ -35,15 +35,34 @@ function statusBadge(status) {
35
35
  return colorFn(status);
36
36
  }
37
37
 
38
- // ── Platform display names ─────────────────────────────
38
+ // ── Content-type display names ─────────────────────────
39
+ // Reels in the mvp-mobile-react Soulprint feed are categorized by content
40
+ // type, not by external platform. The legacy LinkedIn/YouTube/TikTok/Instagram
41
+ // labels are kept temporarily as aliases so existing scripts keep working.
42
+
43
+ const CONTENT_TYPE_LABELS = {
44
+ VIDEO: 'Video',
45
+ PHOTO: 'Photo',
46
+ CAROUSEL: 'Carousel',
47
+ AUDIO: 'Audio / Podcast',
48
+ TEXT: 'Quote / Text',
49
+ };
39
50
 
40
- const PLATFORM_LABELS = {
41
- LINKEDIN: 'LinkedIn',
42
- YOUTUBE: 'YouTube',
43
- TIKTOK: 'TikTok',
44
- INSTAGRAM: 'Instagram',
51
+ // Back-compat: legacy platform → content type. Used only for display fallback
52
+ // when reading older drafts from the API.
53
+ const LEGACY_PLATFORM_TO_CONTENT_TYPE = {
54
+ LINKEDIN: 'TEXT',
55
+ YOUTUBE: 'VIDEO',
56
+ TIKTOK: 'VIDEO',
57
+ INSTAGRAM: 'PHOTO',
45
58
  };
46
59
 
60
+ function resolveContentTypeLabel(draft) {
61
+ const ct = draft?.contentType
62
+ || (draft?.platform ? LEGACY_PLATFORM_TO_CONTENT_TYPE[draft.platform] : undefined);
63
+ return ct ? (CONTENT_TYPE_LABELS[ct] || ct) : 'Unknown';
64
+ }
65
+
47
66
  // ── Size thresholds ────────────────────────────────────
48
67
 
49
68
  // Files under this size use the simple buffered upload endpoint.
@@ -79,154 +98,107 @@ function inferAssetType(fileName) {
79
98
  return null;
80
99
  }
81
100
 
82
- // ── Platform-specific metadata prompts ─────────────────
101
+ // ── Content-type-specific extra fields ─────────────────
102
+ //
103
+ // Asks for fields the reel renderer needs but caption/title don't cover —
104
+ // e.g. quote text + attribution for TEXT reels, podcast/episode title for
105
+ // AUDIO reels. Returns a sparse object suitable for spreading into the
106
+ // API payload. Empty strings are dropped.
83
107
 
84
- async function promptPlatformMetadata(platform, existing = {}) {
108
+ async function promptContentTypeFields(contentType, existing = {}) {
85
109
  const prompts = [];
86
110
 
87
- if (platform === 'YOUTUBE') {
111
+ if (contentType === 'TEXT') {
88
112
  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
113
  {
116
114
  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,
115
+ name: 'quoteText',
116
+ message: 'Quote / body text:',
117
+ default: existing.quoteText || '',
118
+ validate: (v) => v.trim() ? true : 'Quote text is required for TEXT reels',
167
119
  },
168
120
  {
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,
121
+ type: 'input',
122
+ name: 'attribution',
123
+ message: 'Attribution (e.g., “— Maya Angelou”, optional):',
124
+ default: existing.attribution || '',
185
125
  },
186
126
  );
187
127
  }
188
128
 
189
- if (platform === 'INSTAGRAM') {
129
+ if (contentType === 'AUDIO') {
190
130
  prompts.push(
191
131
  {
192
132
  type: 'input',
193
- name: 'altText',
194
- message: 'Alt text for accessibility (optional):',
195
- default: existing.altText || '',
133
+ name: 'podcastTitle',
134
+ message: 'Podcast / show title:',
135
+ default: existing.podcastTitle || '',
196
136
  },
197
137
  {
198
138
  type: 'input',
199
- name: 'location',
200
- message: 'Location tag (optional):',
201
- default: existing.location || '',
139
+ name: 'episodeTitle',
140
+ message: 'Episode title:',
141
+ default: existing.episodeTitle || '',
202
142
  },
203
143
  {
204
144
  type: 'input',
205
- name: 'collaborators',
206
- message: 'Collaborator handles (comma-separated, optional):',
207
- default: existing.collaborators?.join(', ') || '',
145
+ name: 'audioDuration',
146
+ message: 'Duration in seconds (optional):',
147
+ default: existing.audioDuration ? String(existing.audioDuration) : '',
148
+ filter: (v) => v ? Number(v) || undefined : undefined,
208
149
  },
209
150
  );
210
151
  }
211
152
 
212
- if (prompts.length === 0) return null;
153
+ if (contentType === 'VIDEO') {
154
+ prompts.push({
155
+ type: 'input',
156
+ name: 'videoDuration',
157
+ message: 'Duration in seconds (optional):',
158
+ default: existing.videoDuration ? String(existing.videoDuration) : '',
159
+ filter: (v) => v ? Number(v) || undefined : undefined,
160
+ });
161
+ }
162
+
163
+ if (prompts.length === 0) return {};
213
164
 
214
- console.log(brand.teal(`\n ${PLATFORM_LABELS[platform]} settings:\n`));
165
+ console.log(brand.teal(`\n ${CONTENT_TYPE_LABELS[contentType]} details:\n`));
215
166
  const answers = await inquirer.prompt(prompts);
216
167
 
217
- // Clean up empty strings and parse collaborators
218
- const metadata = {};
168
+ const out = {};
219
169
  for (const [key, value] of Object.entries(answers)) {
220
170
  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
- }
171
+ out[key] = typeof value === 'string' ? value.trim() : value;
172
+ }
173
+ return out;
174
+ }
175
+
176
+ async function promptVisibilityAndCommunity(existing = {}) {
177
+ const { visibility } = await inquirer.prompt([{
178
+ type: 'list',
179
+ name: 'visibility',
180
+ message: 'Who can see this reel?',
181
+ choices: [
182
+ { name: 'Public — everyone in the app', value: 'PUBLIC' },
183
+ { name: 'Community only — pick one below', value: 'COMMUNITY' },
184
+ { name: 'Private — only me (preview)', value: 'PRIVATE' },
185
+ ],
186
+ default: existing.visibility || 'PUBLIC',
187
+ }]);
188
+
189
+ let communityId = existing.communityId || undefined;
190
+ if (visibility === 'COMMUNITY') {
191
+ const { id } = await inquirer.prompt([{
192
+ type: 'input',
193
+ name: 'id',
194
+ message: 'Community ID (uuid):',
195
+ default: communityId || '',
196
+ validate: (v) => v.trim() ? true : 'Community ID is required for COMMUNITY visibility',
197
+ }]);
198
+ communityId = id.trim();
227
199
  }
228
200
 
229
- return Object.keys(metadata).length > 0 ? metadata : null;
201
+ return { visibility, communityId };
230
202
  }
231
203
 
232
204
  // ── media draft create ─────────────────────────────────
@@ -238,51 +210,53 @@ export async function mediaDraftCreateCommand(options = {}) {
238
210
  }
239
211
 
240
212
  try {
241
- console.log(`\n ${brand.gold(chalk.bold('\u2726 Media Draft \u2014 New Submission'))}\n`);
213
+ console.log(`\n ${brand.gold(chalk.bold('\u2726 Soulprint Reel \u2014 New Submission'))}\n`);
242
214
 
243
- // Platform selection
244
- const platformAnswer = await inquirer.prompt([{
215
+ // Content-type selection (replaces "platform")
216
+ const { contentType } = await inquirer.prompt([{
245
217
  type: 'list',
246
- name: 'platform',
247
- message: 'Target platform:',
218
+ name: 'contentType',
219
+ message: 'Content type:',
248
220
  choices: [
249
- { name: 'LinkedIn', value: 'LINKEDIN' },
250
- { name: 'YouTube', value: 'YOUTUBE' },
251
- { name: 'TikTok', value: 'TIKTOK' },
252
- { name: 'Instagram', value: 'INSTAGRAM' },
221
+ { name: 'Video', value: 'VIDEO' },
222
+ { name: 'Photo', value: 'PHOTO' },
223
+ { name: 'Photo carousel', value: 'CAROUSEL' },
224
+ { name: 'Audio / podcast', value: 'AUDIO' },
225
+ { name: 'Quote / text', value: 'TEXT' },
253
226
  ],
254
227
  }]);
255
228
 
256
- // Content fields based on platform
257
- const isVideo = ['YOUTUBE', 'TIKTOK'].includes(platformAnswer.platform);
229
+ const needsTitle = contentType === 'VIDEO' || contentType === 'AUDIO';
230
+ const isLongForm = contentType === 'VIDEO' || contentType === 'AUDIO';
258
231
 
259
232
  const contentPrompts = [];
260
233
 
261
- if (platformAnswer.platform === 'YOUTUBE') {
234
+ if (needsTitle) {
262
235
  contentPrompts.push({
263
236
  type: 'input',
264
237
  name: 'title',
265
- message: 'Video title:',
266
- validate: (v) => v.trim() ? true : 'Title is required for YouTube',
238
+ message: 'Title:',
267
239
  });
268
240
  }
269
241
 
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
- });
242
+ if (contentType !== 'TEXT') {
243
+ contentPrompts.push({
244
+ type: 'input',
245
+ name: 'caption',
246
+ message: 'Caption:',
247
+ validate: (v, answers) => {
248
+ if (v.trim()) return true;
249
+ if (answers?.title?.trim()) return true;
250
+ return 'Caption is required';
251
+ },
252
+ });
253
+ }
280
254
 
281
- if (isVideo) {
255
+ if (isLongForm) {
282
256
  contentPrompts.push({
283
257
  type: 'input',
284
258
  name: 'script',
285
- message: 'Script (optional, press Enter to skip):',
259
+ message: 'Script / show notes (optional, press Enter to skip):',
286
260
  });
287
261
  }
288
262
 
@@ -300,12 +274,16 @@ export async function mediaDraftCreateCommand(options = {}) {
300
274
 
301
275
  const answers = await inquirer.prompt(contentPrompts);
302
276
 
303
- // Platform-specific settings
304
- const platformMetadata = await promptPlatformMetadata(platformAnswer.platform);
277
+ // Type-specific extras (quote/attribution for TEXT, podcast/episode for AUDIO, etc.)
278
+ const typeExtras = await promptContentTypeFields(contentType);
279
+
280
+ // Visibility + community
281
+ const { visibility, communityId } = await promptVisibilityAndCommunity();
305
282
 
306
283
  // Build draft payload
307
284
  const data = {
308
- platform: platformAnswer.platform,
285
+ contentType,
286
+ visibility,
309
287
  intakeMethod: 'CLI',
310
288
  };
311
289
 
@@ -318,9 +296,8 @@ export async function mediaDraftCreateCommand(options = {}) {
318
296
  if (answers.suggestedPublishAt?.trim()) {
319
297
  data.suggestedPublishAt = new Date(answers.suggestedPublishAt.trim()).toISOString();
320
298
  }
321
- if (platformMetadata) {
322
- data.platformMetadata = platformMetadata;
323
- }
299
+ if (communityId) data.communityId = communityId;
300
+ Object.assign(data, typeExtras);
324
301
 
325
302
  // Auto-submit flag
326
303
  if (options.submit) {
@@ -334,7 +311,7 @@ export async function mediaDraftCreateCommand(options = {}) {
334
311
 
335
312
  const draft = result.draft;
336
313
  console.log(brand.green(`\n \u2713 Draft ID: ${chalk.bold(draft.draftId)}`));
337
- console.log(brand.teal(` Platform: ${PLATFORM_LABELS[draft.platform]}`));
314
+ console.log(brand.teal(` Type: ${resolveContentTypeLabel(draft)}`));
338
315
  console.log(brand.teal(` Status: ${statusBadge(draft.status)}`));
339
316
 
340
317
  // ── File uploads ───────────────────────────────────
@@ -586,11 +563,13 @@ export async function mediaDraftEditCommand(id) {
586
563
  return;
587
564
  }
588
565
 
589
- const platform = draft.platform;
590
- const isVideo = ['YOUTUBE', 'TIKTOK'].includes(platform);
566
+ const contentType = draft.contentType
567
+ || LEGACY_PLATFORM_TO_CONTENT_TYPE[draft.platform]
568
+ || 'VIDEO';
569
+ const isLongForm = contentType === 'VIDEO' || contentType === 'AUDIO';
591
570
 
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)}`));
571
+ console.log(`\n ${brand.gold(chalk.bold(`\u2726 Edit Reel \u2014 ${draft.draftId}`))}`);
572
+ console.log(brand.teal(` Type: ${resolveContentTypeLabel({ ...draft, contentType })} Status: ${statusBadge(draft.status)}`));
594
573
 
595
574
  if (draft.rejectionReason) {
596
575
  console.log(chalk.red(` Rejection reason: ${draft.rejectionReason}`));
@@ -609,27 +588,29 @@ export async function mediaDraftEditCommand(id) {
609
588
  // Content fields
610
589
  const contentPrompts = [];
611
590
 
612
- if (platform === 'YOUTUBE') {
591
+ if (contentType === 'VIDEO' || contentType === 'AUDIO') {
613
592
  contentPrompts.push({
614
593
  type: 'input',
615
594
  name: 'title',
616
- message: `Video title:`,
595
+ message: 'Title:',
617
596
  default: draft.title || '',
618
597
  });
619
598
  }
620
599
 
621
- contentPrompts.push({
622
- type: 'input',
623
- name: 'caption',
624
- message: isVideo ? 'Description / caption:' : 'Caption:',
625
- default: draft.caption || '',
626
- });
600
+ if (contentType !== 'TEXT') {
601
+ contentPrompts.push({
602
+ type: 'input',
603
+ name: 'caption',
604
+ message: isLongForm ? 'Description / caption:' : 'Caption:',
605
+ default: draft.caption || '',
606
+ });
607
+ }
627
608
 
628
- if (isVideo) {
609
+ if (isLongForm) {
629
610
  contentPrompts.push({
630
611
  type: 'input',
631
612
  name: 'script',
632
- message: 'Script:',
613
+ message: 'Script / show notes:',
633
614
  default: draft.script || '',
634
615
  });
635
616
  }
@@ -652,20 +633,22 @@ export async function mediaDraftEditCommand(id) {
652
633
 
653
634
  const answers = await inquirer.prompt(contentPrompts);
654
635
 
655
- // Platform-specific settings
656
- const existingMetadata = draft.platformMetadata || {};
657
- const platformMetadata = await promptPlatformMetadata(platform, existingMetadata);
636
+ // Type-specific extras (quote/attribution, podcast/episode, etc.)
637
+ const typeExtras = await promptContentTypeFields(contentType, draft);
658
638
 
659
639
  // Build update payload — only send changed fields
660
640
  const updates = {};
661
641
 
662
- if (platform === 'YOUTUBE' && answers.title !== (draft.title || '')) {
642
+ if (
643
+ (contentType === 'VIDEO' || contentType === 'AUDIO') &&
644
+ answers.title !== (draft.title || '')
645
+ ) {
663
646
  updates.title = answers.title.trim() || undefined;
664
647
  }
665
- if (answers.caption !== (draft.caption || '')) {
648
+ if (contentType !== 'TEXT' && answers.caption !== (draft.caption || '')) {
666
649
  updates.caption = answers.caption.trim() || undefined;
667
650
  }
668
- if (isVideo && answers.script !== (draft.script || '')) {
651
+ if (isLongForm && answers.script !== (draft.script || '')) {
669
652
  updates.script = answers.script.trim() || undefined;
670
653
  }
671
654
 
@@ -686,9 +669,7 @@ export async function mediaDraftEditCommand(id) {
686
669
  : null;
687
670
  }
688
671
 
689
- if (platformMetadata) {
690
- updates.platformMetadata = platformMetadata;
691
- }
672
+ Object.assign(updates, typeExtras);
692
673
 
693
674
  // File uploads
694
675
  const filesToUpload = [];
@@ -816,7 +797,15 @@ export async function mediaDraftListCommand(options = {}) {
816
797
  sort: options.sort || 'newest',
817
798
  };
818
799
  if (options.status) params.status = options.status.toUpperCase();
819
- if (options.platform) params.platform = options.platform.toUpperCase();
800
+ if (options.contentType) params.contentType = options.contentType.toUpperCase();
801
+ // Back-compat: --platform still accepted on the CLI; map it forward.
802
+ if (options.platform) {
803
+ params.contentType =
804
+ LEGACY_PLATFORM_TO_CONTENT_TYPE[options.platform.toUpperCase()] ||
805
+ options.platform.toUpperCase();
806
+ }
807
+ if (options.visibility) params.visibility = options.visibility.toUpperCase();
808
+ if (options.communityId) params.communityId = options.communityId;
820
809
  if (options.search) params.search = options.search;
821
810
 
822
811
  const result = await listMediaDrafts(params);
@@ -842,16 +831,22 @@ export async function mediaDraftListCommand(options = {}) {
842
831
  console.log(`\n ${chalk.bold(`Media Drafts${statusLabel}`)}\n`);
843
832
 
844
833
  for (const draft of drafts) {
845
- const platform = PLATFORM_LABELS[draft.platform] || draft.platform;
834
+ const typeLabel = resolveContentTypeLabel(draft);
846
835
  const status = statusBadge(draft.status);
847
836
  const preview = draft.caption
848
837
  ? draft.caption.substring(0, 60) + (draft.caption.length > 60 ? '...' : '')
849
- : draft.title || draft.script?.substring(0, 60) || '(no content)';
838
+ : draft.title
839
+ || draft.quoteText?.substring(0, 60)
840
+ || draft.script?.substring(0, 60)
841
+ || '(no content)';
850
842
  const date = new Date(draft.createdAt).toLocaleDateString();
851
843
  const assetCount = draft.assets?.length || draft._count?.assets || 0;
852
844
  const assetLabel = assetCount > 0 ? chalk.dim(` [${assetCount} file${assetCount > 1 ? 's' : ''}]`) : '';
845
+ const engagement = (draft.likeCount || draft.commentCount)
846
+ ? chalk.dim(` ♥${draft.likeCount || 0} 💬${draft.commentCount || 0}`)
847
+ : '';
853
848
 
854
- console.log(` ${brand.gold(draft.draftId)} ${status} ${chalk.dim(platform)} ${chalk.dim(date)}${assetLabel}`);
849
+ console.log(` ${brand.gold(draft.draftId)} ${status} ${chalk.dim(typeLabel)} ${chalk.dim(date)}${assetLabel}${engagement}`);
855
850
  console.log(` ${brand.cream(preview)}`);
856
851
  console.log('');
857
852
  }
@@ -889,13 +884,16 @@ export async function mediaDraftStatusCommand(id) {
889
884
  const result = await getMediaDraftStatus(id);
890
885
  spinner.stop();
891
886
 
892
- const platform = PLATFORM_LABELS[result.platform] || result.platform;
887
+ const typeLabel = resolveContentTypeLabel(result);
893
888
  const status = statusBadge(result.status);
894
889
 
895
- console.log(`\n ${chalk.bold('Media Draft Status')}\n`);
890
+ console.log(`\n ${chalk.bold('Reel Status')}\n`);
896
891
  console.log(` ${brand.gold('Draft ID:')} ${result.draftId}`);
897
892
  console.log(` ${brand.gold('Status:')} ${status}`);
898
- console.log(` ${brand.gold('Platform:')} ${platform}`);
893
+ console.log(` ${brand.gold('Type:')} ${typeLabel}`);
894
+ if (result.visibility) {
895
+ console.log(` ${brand.gold('Visibility:')} ${result.visibility}`);
896
+ }
899
897
 
900
898
  if (result.title) {
901
899
  console.log(` ${brand.gold('Title:')} ${result.title}`);
@@ -909,8 +907,8 @@ export async function mediaDraftStatusCommand(id) {
909
907
  if (result.publishedAt) {
910
908
  console.log(` ${brand.gold('Published:')} ${new Date(result.publishedAt).toLocaleString()}`);
911
909
  }
912
- if (result.platformPostUrl) {
913
- console.log(` ${brand.gold('Post URL:')} ${result.platformPostUrl}`);
910
+ if (result.likeCount !== undefined) {
911
+ console.log(` ${brand.gold('Engagement:')}${result.likeCount} 💬 ${result.commentCount || 0} ↗ ${result.shareCount || 0}`);
914
912
  }
915
913
  if (result.rejectionReason) {
916
914
  console.log(` ${chalk.red('Rejection:')} ${result.rejectionReason}`);