@myvillage/cli 1.64.3 → 1.64.6

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.64.3",
3
+ "version": "1.64.6",
4
4
  "description": "MyVillageOS CLI for community developers",
5
5
  "type": "module",
6
6
  "bin": {
@@ -7,7 +7,7 @@ import axios from 'axios';
7
7
  import inquirer from 'inquirer';
8
8
  import { villageSpinner, brand } from '../utils/brand.js';
9
9
  import { isAuthenticated } from '../utils/auth.js';
10
- import { deployGame, getGameUploadUrl, confirmGameUpload, submitGameForReview } from '../utils/api.js';
10
+ import { deployGame, getGameUploadUrl, confirmGameUpload, submitGameForReview, getGameDetail, updateGameMetadata } from '../utils/api.js';
11
11
  import { preflightBundles } from '../utils/preflight.js';
12
12
  import { selectGameVillages } from '../utils/village-resolver.js';
13
13
 
@@ -77,6 +77,52 @@ async function gatherVillages(existingIds = []) {
77
77
  return villageIds;
78
78
  }
79
79
 
80
+ // If the target game is currently PUBLISHED, a redeploy takes it offline and
81
+ // sends the new build back through admin review. Confirm with the developer and
82
+ // move it to DRAFT first. Returns true to proceed with the deploy, false to abort.
83
+ async function confirmRedeployIfPublished(gameId) {
84
+ if (!gameId) return true; // brand-new game — nothing live to disturb
85
+
86
+ let game;
87
+ try {
88
+ const result = await getGameDetail(gameId);
89
+ game = result.game;
90
+ } catch {
91
+ return true; // can't determine status — let the deploy proceed and surface real errors
92
+ }
93
+
94
+ if (!game || game.status !== 'PUBLISHED') return true;
95
+
96
+ console.log();
97
+ console.log(chalk.yellow(` ! "${game.title}" is currently PUBLISHED (live).`));
98
+ console.log(brand.teal(' Redeploying takes it offline and sends the new build back through'));
99
+ console.log(brand.teal(' admin review before it can go live again.'));
100
+ console.log();
101
+
102
+ const { confirm } = await inquirer.prompt([{
103
+ type: 'confirm',
104
+ name: 'confirm',
105
+ message: 'Take the live game offline and redeploy?',
106
+ default: false,
107
+ }]);
108
+
109
+ if (!confirm) {
110
+ console.log(brand.teal('\n Deploy cancelled — game is still live.\n'));
111
+ return false;
112
+ }
113
+
114
+ const spinner = villageSpinner('Taking game offline (returning to draft)...').start();
115
+ try {
116
+ await updateGameMetadata(gameId, { status: 'DRAFT' });
117
+ spinner.succeed('Game returned to draft.');
118
+ return true;
119
+ } catch (err) {
120
+ const message = err.response?.data?.error || err.message;
121
+ spinner.fail(`Could not unpublish: ${message}`);
122
+ return false;
123
+ }
124
+ }
125
+
80
126
  // Recursively collect file paths from a directory
81
127
  function collectFilePaths(dir) {
82
128
  const paths = [];
@@ -151,6 +197,9 @@ async function autoSubmitForReview(gameId, deployResult) {
151
197
  // ── Three.js deploy (existing flow) ──
152
198
 
153
199
  async function deployThreeJsGame(projectDir, pkg) {
200
+ // If this game is already live, confirm + unpublish before we rebuild/upload.
201
+ if (!(await confirmRedeployIfPublished(pkg.myvillage.gameId))) return;
202
+
154
203
  // Village visibility is part of the submission — gather it up front (before
155
204
  // the build) so it travels in the deploy metadata.
156
205
  const villageIds = await gatherVillages(pkg.myvillage.villages || []);
@@ -196,7 +245,10 @@ async function deployThreeJsGame(projectDir, pkg) {
196
245
  formData.append('metadata', JSON.stringify(metadata));
197
246
 
198
247
  for (const fullPath of filePaths) {
199
- const relPath = relative(distDir, fullPath);
248
+ // Force POSIX separators: path.relative() returns backslashes on Windows,
249
+ // which would land in the S3 key (e.g. assets\foo.js) and 404 when the
250
+ // browser requests assets/foo.js — a blank game. (Server normalizes too.)
251
+ const relPath = relative(distDir, fullPath).replace(/\\/g, '/');
200
252
  const fileBuffer = readFileSync(fullPath);
201
253
  const blob = new Blob([fileBuffer], { type: getContentType(relPath) });
202
254
  formData.append('files', blob, relPath);
@@ -213,10 +265,22 @@ async function deployThreeJsGame(projectDir, pkg) {
213
265
 
214
266
  const finalGameId = result.gameId || pkg.myvillage.gameId;
215
267
 
216
- spinner.text = 'Submitting for review...';
268
+ spinner.succeed('Upload complete!');
269
+
270
+ saveVillages(projectDir, false, villageIds);
271
+
272
+ // Attach cover art before the final submit step. The deploy already moved the
273
+ // game to SUBMITTED; the platform lets the owner upload assets while a game is
274
+ // DRAFT/REJECTED/SUBMITTED (only APPROVED/PUBLISHED lock it), so this works
275
+ // for non-admin developers too — not just admins.
276
+ if (finalGameId) {
277
+ await submitCoverArt(finalGameId, projectDir, pkg.myvillage);
278
+ }
279
+
280
+ // Safety net: promote to SUBMITTED if the deploy left the game in DRAFT.
281
+ // (A normal deploy already submits, so this is usually a no-op.)
217
282
  const finalStatus = await autoSubmitForReview(finalGameId, result);
218
283
 
219
- spinner.succeed('Deployment complete!');
220
284
  console.log();
221
285
  if (finalStatus === 'SUBMITTED') {
222
286
  console.log(brand.green(` \u2713 Game "${metadata.title}" submitted for review`));
@@ -227,13 +291,6 @@ async function deployThreeJsGame(projectDir, pkg) {
227
291
  if (result.slug) console.log(brand.teal(` Slug: ${result.slug}`));
228
292
  if (result.gameId) console.log(brand.teal(` Game ID: ${result.gameId}`));
229
293
 
230
- saveVillages(projectDir, false, villageIds);
231
-
232
- // Cover art is part of the submission: ship declared assets, require a thumbnail.
233
- if (finalGameId) {
234
- await submitCoverArt(finalGameId, projectDir, pkg.myvillage);
235
- }
236
-
237
294
  console.log();
238
295
  console.log(brand.teal(' Run "myvillage status" to check the review status.'));
239
296
  console.log();
@@ -299,6 +356,9 @@ async function deployUnityGame(projectDir, mvConfig, options) {
299
356
 
300
357
  console.log(brand.teal(` Deploying ${platformsToDeploy.length} platform(s): ${platformsToDeploy.join(', ')}`));
301
358
 
359
+ // If this game is already live, confirm + unpublish before we upload new builds.
360
+ if (!(await confirmRedeployIfPublished(mvConfig.gameId))) return;
361
+
302
362
  // Village visibility is part of the submission — gather once for the whole
303
363
  // multi-platform deploy and thread it into every platform's metadata.
304
364
  const villageIds = await gatherVillages(mvConfig.villages || []);
@@ -308,23 +368,39 @@ async function deployUnityGame(projectDir, mvConfig, options) {
308
368
  // Track final outcome across all platforms.
309
369
  let finalGameId = mvConfig.gameId || null;
310
370
  let finalSlug = null;
311
- let finalStatus = null;
312
371
  let finalTitle = null;
372
+ let deployResultForSubmit = null;
313
373
 
314
374
  for (const platform of platformsToDeploy) {
315
375
  const outcome = await deployUnityPlatform(projectDir, mvConfig, platform, { gameIdOverride: finalGameId, villageIds });
316
376
  if (!outcome.ok) return; // bail on first platform failure; nothing else makes sense to ship
317
377
  finalGameId = outcome.gameId || finalGameId;
318
378
  finalSlug = outcome.slug || finalSlug;
319
- finalStatus = outcome.status || finalStatus;
320
379
  finalTitle = outcome.title || finalTitle;
380
+ deployResultForSubmit = outcome.deployResult || deployResultForSubmit;
321
381
  // Reload mvConfig in case the first deploy stamped a new gameId into myvillage.json
322
382
  if (outcome.gameId && !mvConfig.gameId) {
323
383
  mvConfig.gameId = outcome.gameId;
324
384
  }
325
385
  }
326
386
 
327
- // Single combined summary + one image prompt for the whole multi-platform deploy.
387
+ saveVillages(projectDir, true, villageIds);
388
+
389
+ // Attach cover art before the final submit step. The deploy already moved the
390
+ // game to SUBMITTED; the platform lets the owner upload assets while a game is
391
+ // DRAFT/REJECTED/SUBMITTED (only APPROVED/PUBLISHED lock it). Submit is kept
392
+ // out of the per-platform loop so it runs once, after every platform is up.
393
+ if (finalGameId) {
394
+ await submitCoverArt(finalGameId, projectDir, mvConfig);
395
+ }
396
+
397
+ // Safety net: promote to SUBMITTED if a platform deploy left it in DRAFT.
398
+ const finalStatus = await autoSubmitForReview(
399
+ finalGameId,
400
+ deployResultForSubmit || { status: 'DRAFT' },
401
+ );
402
+
403
+ // Single combined summary for the whole multi-platform deploy.
328
404
  console.log();
329
405
  console.log(brand.green(` \u2713 Game "${finalTitle}" deployed across ${platformsToDeploy.length} platform(s)`));
330
406
  console.log(` Status: ${formatStatus(finalStatus)}`);
@@ -332,21 +408,15 @@ async function deployUnityGame(projectDir, mvConfig, options) {
332
408
  if (finalSlug) console.log(brand.teal(` Slug: ${finalSlug}`));
333
409
  if (finalGameId) console.log(brand.teal(` Game ID: ${finalGameId}`));
334
410
 
335
- saveVillages(projectDir, true, villageIds);
336
-
337
- // Cover art is part of the submission: ship declared assets, require a thumbnail.
338
- if (finalGameId) {
339
- await submitCoverArt(finalGameId, projectDir, mvConfig);
340
- }
341
-
342
411
  console.log();
343
412
  console.log(brand.teal(' Run "myvillage status" to check the review status.'));
344
413
  console.log();
345
414
  }
346
415
 
347
416
  /**
348
- * Deploy one specific platform. Returns { ok, gameId, slug, status, title } or
349
- * { ok: false } and writes a user-readable error to the console.
417
+ * Deploy one specific platform. Returns { ok, gameId, slug, deployResult, title }
418
+ * or { ok: false } and writes a user-readable error to the console. Submit-for-
419
+ * review is intentionally NOT done here — see deployUnityGame.
350
420
  */
351
421
  async function deployUnityPlatform(projectDir, mvConfig, platform, { gameIdOverride, villageIds }) {
352
422
  const buildDir = join(projectDir, 'builds', platform);
@@ -408,7 +478,10 @@ async function deployUnityPlatform(projectDir, mvConfig, platform, { gameIdOverr
408
478
  formData.append('metadata', JSON.stringify(metadata));
409
479
 
410
480
  for (const fullPath of filePaths) {
411
- const relPath = relative(buildDir, fullPath);
481
+ // Force POSIX separators: path.relative() returns backslashes on Windows,
482
+ // which would land in the S3 key (e.g. assets\foo.js) and 404 when the
483
+ // browser requests assets/foo.js — a blank game. (Server normalizes too.)
484
+ const relPath = relative(buildDir, fullPath).replace(/\\/g, '/');
412
485
  const fileBuffer = readFileSync(fullPath);
413
486
  const blob = new Blob([fileBuffer], { type: getContentType(relPath) });
414
487
  formData.append('files', blob, relPath);
@@ -425,16 +498,16 @@ async function deployUnityPlatform(projectDir, mvConfig, platform, { gameIdOverr
425
498
 
426
499
  const finalGameId = result.gameId || gameIdOverride || mvConfig.gameId;
427
500
 
428
- spinner.text = `[${platform}] Submitting for review...`;
429
- const finalStatus = await autoSubmitForReview(finalGameId, result);
430
-
431
501
  spinner.succeed(`[${platform}] deployed (${gameType})`);
432
502
 
503
+ // Submit-for-review is deferred to deployUnityGame, AFTER cover art is
504
+ // uploaded — non-admin developers can't attach assets once a game is
505
+ // SUBMITTED, and re-submitting per platform would also be redundant.
433
506
  return {
434
507
  ok: true,
435
508
  gameId: finalGameId,
436
509
  slug: result.slug,
437
- status: finalStatus,
510
+ deployResult: result,
438
511
  title: metadata.title,
439
512
  };
440
513
  } catch (err) {
@@ -693,22 +693,24 @@ export async function gameDraftCommand() {
693
693
  return;
694
694
  }
695
695
 
696
- if (game.status === 'PUBLISHED') {
697
- console.log(chalk.red(`\n ✗ "${game.title}" is PUBLISHED and cannot be returned to draft.\n`));
698
- return;
699
- }
696
+ const isPublished = game.status === 'PUBLISHED';
700
697
 
701
698
  console.log();
702
699
  console.log(brand.gold(` Return "${game.title}" to DRAFT?`));
703
700
  console.log(brand.teal(` Current status: ${game.status}`));
704
- console.log(brand.teal(' This will withdraw the game from review so you can make changes.'));
701
+ if (isPublished) {
702
+ console.log(chalk.yellow(' ! This game is LIVE. Unpublishing takes it offline immediately, and'));
703
+ console.log(chalk.yellow(' it must be re-reviewed by an admin before it can be published again.'));
704
+ } else {
705
+ console.log(brand.teal(' This will withdraw the game from review so you can make changes.'));
706
+ }
705
707
  console.log();
706
708
 
707
709
  const { confirm } = await inquirer.prompt([{
708
710
  type: 'confirm',
709
711
  name: 'confirm',
710
- message: 'Return to draft?',
711
- default: true,
712
+ message: isPublished ? 'Take the live game offline?' : 'Return to draft?',
713
+ default: !isPublished,
712
714
  }]);
713
715
 
714
716
  if (!confirm) {
package/src/index.js CHANGED
@@ -225,7 +225,7 @@ export function run() {
225
225
 
226
226
  gameCmd
227
227
  .command('draft')
228
- .description('Return a game to DRAFT status (withdraw from review)')
228
+ .description('Return a game to DRAFT (withdraw from review, or unpublish a live game)')
229
229
  .action(gameDraftCommand);
230
230
 
231
231
  // Game missions subcommands