@magicpixelart/cli 0.5.36 → 0.6.1

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.
@@ -1,11 +1,12 @@
1
1
  import kleur from 'kleur';
2
+ import { ui } from '../util/ui.js';
2
3
  import { deleteCredentials, credentialsPath } from '../util/credentials.js';
3
4
  import { relative } from 'node:path';
4
5
  export async function logoutCommand() {
5
6
  const path = credentialsPath();
6
7
  const removed = await deleteCredentials();
7
8
  if (removed) {
8
- console.log(kleur.green(`✓ logged out`));
9
+ console.log(ui.ok(`logged out`));
9
10
  console.log(kleur.dim(` Removed ${relative(process.cwd(), path)}.`));
10
11
  }
11
12
  else {
@@ -7,7 +7,7 @@
7
7
  * `POST /api/public/integration/ingest`.
8
8
  */
9
9
  import kleur from 'kleur';
10
- import ora from 'ora';
10
+ import { icon, progressBar, spinner as makeSpinner, ui } from '../util/ui.js';
11
11
  import { mkdir, readFile, rename } from 'node:fs/promises';
12
12
  import { dirname, relative, resolve } from 'node:path';
13
13
  import { loadConfig, loadState, saveState, resetStateIfProjectChanged, PROJECT_CHANGED_MESSAGE } from '../config.js';
@@ -26,6 +26,19 @@ import { assertSafeIoPath } from '../util/security.js';
26
26
  const PUSH_SCAN_CONCURRENCY = 8;
27
27
  /** Paths already verified this process; hashFile still refuses a later symlink swap. */
28
28
  const verifiedIoPaths = new Set();
29
+ /** Plain-language skip reasons for the quiet (nested-in-sync) push path. */
30
+ export function skipIssues(legacy, flattenBlocked) {
31
+ return [
32
+ ...legacy.map((a) => ({
33
+ key: a.key,
34
+ text: 'MagicPixel file is a legacy single image — open it in MagicPixel and save once, then sync again.',
35
+ })),
36
+ ...flattenBlocked.map((a) => ({
37
+ key: a.key,
38
+ text: `MagicPixel artboard has ${a.layers ?? 'several'} layers — run \`${cmd('push')} --flatten\` to replace it with your file.`,
39
+ })),
40
+ ];
41
+ }
29
42
  export async function pushCommand(opts = {}) {
30
43
  await runPush(opts);
31
44
  }
@@ -45,7 +58,7 @@ export async function runPush(opts = {}) {
45
58
  if (!opts.dryRun)
46
59
  await saveState(state);
47
60
  if (!opts.quiet)
48
- console.log(kleur.yellow(`! ${PROJECT_CHANGED_MESSAGE}`));
61
+ console.log(ui.warn(`${PROJECT_CHANGED_MESSAGE}`));
49
62
  }
50
63
  }
51
64
  return runPushWith(config, state, opts);
@@ -55,9 +68,9 @@ export async function runPushWith(config, state, opts = {}) {
55
68
  if (opts.replace)
56
69
  opts = { ...opts, flatten: true };
57
70
  const empty = {
58
- created: 0, updated: 0, unchanged: 0, conflict: 0, error: 0, imported: 0, keptAssetIds: [], scanned: 0,
71
+ created: 0, updated: 0, unchanged: 0, conflict: 0, error: 0, imported: 0, keptAssetIds: [], scanned: 0, issues: [],
59
72
  };
60
- const spinner = quiet ? null : ora(countingSpritesText(0)).start();
73
+ const spinner = quiet ? null : makeSpinner(countingSpritesText(0)).start();
61
74
  const kind = await detectProjectKind();
62
75
  const index = opts.gameIndex
63
76
  ?? (isEngineKind(kind)
@@ -90,12 +103,11 @@ export async function runPushWith(config, state, opts = {}) {
90
103
  let hashed = 0;
91
104
  const reportHash = () => {
92
105
  hashed++;
93
- const n = hashed.toLocaleString('en-US');
94
- const t = toHash.length.toLocaleString('en-US');
106
+ const line = `Checking your game files ${progressBar(hashed, toHash.length)}`;
95
107
  if (spinner)
96
- spinner.text = `Checking your game files… ${n} / ${t}`;
108
+ spinner.text = line;
97
109
  else if (opts.onStatus && (hashed === 1 || hashed === toHash.length || hashed % 25 === 0)) {
98
- opts.onStatus(`Checking your game files… ${n} / ${t}`);
110
+ opts.onStatus(`${icon('work')} ${line}`);
99
111
  }
100
112
  };
101
113
  await Promise.all(toHash.map(([key, abs]) => scan(async () => {
@@ -149,31 +161,33 @@ export async function runPushWith(config, state, opts = {}) {
149
161
  const legacy = actions.filter((a) => a.kind === 'skip' && a.reason === 'legacy');
150
162
  const flattenBlocked = actions.filter((a) => a.kind === 'needs-flatten');
151
163
  const sendable = actions.filter((a) => a.kind === 'update' || a.kind === 'adopt');
164
+ const preIssues = skipIssues(legacy, flattenBlocked.map((a) => ({ key: a.key, ...('layers' in a && typeof a.layers === 'number' ? { layers: a.layers } : {}) })));
152
165
  spinner?.succeed(`Local sprites: ${candidates.length} · to push ${sendable.length} · unchanged ${skipped}` +
153
166
  (matched.entries.length ? ` · connected ${matched.entries.length}` : ''));
154
167
  if (skippedUnsafe > 0 && !quiet) {
155
168
  console.log(kleur.yellow(`! skipped ${skippedUnsafe} path${skippedUnsafe === 1 ? '' : 's'} that were not regular files inside the project.`));
156
169
  }
157
- if (legacy.length > 0 && !quiet && !opts.nestedSync) {
170
+ if (legacy.length > 0 && !quiet) {
158
171
  console.log();
159
172
  console.log(kleur.yellow(`! ${legacy.length} sprite${legacy.length === 1 ? '' : 's'} came from a legacy single-image file — skipped.`));
160
- console.log(kleur.dim(` Fix: open the file in MagicPixel and save it once, then re-run \`${cmd('sync')}\`.`));
173
+ console.log(ui.fixText(`open the file in MagicPixel and save it once, then re-run \`${cmd('sync')}\`.`));
161
174
  }
162
- if (flattenBlocked.length > 0 && !quiet && !opts.nestedSync) {
175
+ if (flattenBlocked.length > 0 && !quiet) {
163
176
  console.log();
164
177
  console.log(kleur.yellow(`! ${flattenBlocked.length} sprite${flattenBlocked.length === 1 ? '' : 's'} would flatten a multi-layer artboard — skipped.`));
165
178
  for (const a of flattenBlocked.slice(0, 10)) {
166
179
  console.log(kleur.dim(` ${a.key} (${'layers' in a ? a.layers : '?'} layers)`));
167
180
  }
168
- console.log(kleur.dim(' Fix: re-run with --flatten to replace them with the flat disk image.'));
181
+ console.log(ui.fixText('re-run with --flatten to replace them with the flat disk image.'));
169
182
  }
170
183
  if (sendable.length === 0) {
171
184
  await persistDiskFingerprints(state, candidates);
172
185
  if (!quiet)
173
- console.log(kleur.green('✓ nothing to push.'));
186
+ console.log(ui.ok('nothing to push.'));
174
187
  return {
175
188
  ...empty, imported: matched.entries.length, unchanged: skipped,
176
189
  keptAssetIds: collectKeptAssetIds(candidates, state.synced ?? {}, []), scanned: candidates.length,
190
+ issues: preIssues, needsFlatten: flattenBlocked.map((a) => a.key), gameIndex: index,
177
191
  };
178
192
  }
179
193
  if (opts.dryRun) {
@@ -188,6 +202,7 @@ export async function runPushWith(config, state, opts = {}) {
188
202
  return {
189
203
  ...empty, imported: matched.entries.length,
190
204
  keptAssetIds: collectKeptAssetIds(candidates, state.synced ?? {}, []), scanned: candidates.length,
205
+ issues: preIssues, needsFlatten: flattenBlocked.map((a) => a.key), gameIndex: index,
191
206
  };
192
207
  }
193
208
  const outRoot = resolve(process.cwd(), config.outDir);
@@ -221,7 +236,8 @@ export async function runPushWith(config, state, opts = {}) {
221
236
  });
222
237
  }
223
238
  }
224
- const progress = quiet ? null : ora(`Pushing 0/${sprites.length}…`).start();
239
+ const progress = quiet ? null : makeSpinner(`Uploading ${progressBar(0, sprites.length)}`).start();
240
+ opts.onStatus?.(`${icon('up')} Uploading ${sprites.length.toLocaleString('en-US')} sprite${sprites.length === 1 ? '' : 's'}…`);
225
241
  const results = [];
226
242
  let synced = { ...(state.synced ?? {}) };
227
243
  try {
@@ -239,8 +255,11 @@ export async function runPushWith(config, state, opts = {}) {
239
255
  outDir: config.outDir,
240
256
  });
241
257
  await saveState({ ...state, synced });
258
+ const doneN = Math.min(i + batch.length, sprites.length);
242
259
  if (progress)
243
- progress.text = `Pushing ${Math.min(i + batch.length, sprites.length)}/${sprites.length}…`;
260
+ progress.text = `Uploading ${progressBar(doneN, sprites.length)} ${kleur.dim(batch[batch.length - 1]?.name ?? '')}`;
261
+ else
262
+ opts.onStatus?.(`${icon('up')} Uploading ${doneN}/${sprites.length} ${batch[batch.length - 1]?.name ?? ''}…`);
244
263
  }
245
264
  }
246
265
  catch (e) {
@@ -300,11 +319,11 @@ export async function runPushWith(config, state, opts = {}) {
300
319
  for (const r of results) {
301
320
  if (r.status === 'conflict') {
302
321
  if (printedIssues++ < 10)
303
- console.log(` ${kleur.yellow('!')} ${r.key}: ${opts.force && r.lastWriteSource === 'editor' && !opts.overwriteEditorChanges ? `edited in MagicPixel — skipped. Add --overwrite-editor-changes to replace it.` : conflictHint(r.reason)}`);
322
+ console.log(` ${icon('warn')} ${r.key}: ${opts.force && r.lastWriteSource === 'editor' && !opts.overwriteEditorChanges ? `edited in MagicPixel — skipped. Add --overwrite-editor-changes to replace it.` : conflictHint(r.reason)}`);
304
323
  }
305
324
  else if (r.status === 'error') {
306
325
  if (printedIssues++ < 10)
307
- console.log(` ${kleur.red('!')} ${r.key}: ${r.message ?? 'push failed'}`);
326
+ console.log(` ${icon('fail')} ${r.key}: ${r.message ?? 'push failed'}`);
308
327
  }
309
328
  }
310
329
  if (printedIssues > 10)
@@ -316,9 +335,24 @@ export async function runPushWith(config, state, opts = {}) {
316
335
  }
317
336
  if (failed)
318
337
  process.exitCode = 1;
338
+ const issues = [...preIssues];
339
+ for (const r of results) {
340
+ if (r.status === 'conflict') {
341
+ issues.push({
342
+ key: r.key,
343
+ text: opts.force && r.lastWriteSource === 'editor' && !opts.overwriteEditorChanges
344
+ ? 'edited in MagicPixel — skipped. Add --overwrite-editor-changes to replace it.'
345
+ : conflictHint(r.reason),
346
+ });
347
+ }
348
+ else if (r.status === 'error') {
349
+ issues.push({ key: r.key, text: r.message ?? 'push failed' });
350
+ }
351
+ }
319
352
  return {
320
353
  ...counts, imported: matched.entries.length,
321
354
  keptAssetIds: collectKeptAssetIds(candidates, synced, results), scanned: candidates.length,
355
+ issues, needsFlatten: flattenBlocked.map((a) => a.key),
322
356
  };
323
357
  }
324
358
  /**
@@ -1,4 +1,5 @@
1
1
  import kleur from 'kleur';
2
+ import { ui } from '../util/ui.js';
2
3
  import { loadConfig, saveConfig } from '../config.js';
3
4
  import { assertSafeGlob } from '../util/security.js';
4
5
  export async function removeCommand(glob) {
@@ -11,5 +12,5 @@ export async function removeCommand(glob) {
11
12
  return;
12
13
  }
13
14
  await saveConfig(config);
14
- console.log(kleur.green(`✓ removed include pattern: ${pattern}`));
15
+ console.log(ui.ok(`removed include pattern: ${pattern}`));
15
16
  }
@@ -1,9 +1,10 @@
1
1
  import kleur from 'kleur';
2
+ import { icon, ui } from '../util/ui.js';
2
3
  import { existsSync } from 'node:fs';
3
4
  import { rename } from 'node:fs/promises';
4
5
  import { resolve, relative } from 'node:path';
5
- import { createInterface } from 'node:readline/promises';
6
- import { stdin, stdout } from 'node:process';
6
+ import { confirm as askYesNo } from '../util/prompt.js';
7
+ import { stdin } from 'node:process';
7
8
  import { loadConfig, statePath, getApiKey } from '../config.js';
8
9
  import { assertKeyValid } from '../util/auth.js';
9
10
  import { listEmptyDirs, pruneEmptyDirs } from '../util/paths.js';
@@ -32,7 +33,7 @@ export async function repairCommand(opts = {}) {
32
33
  try {
33
34
  const key = getApiKey();
34
35
  await assertKeyValid(key, config);
35
- console.log(` ${kleur.green('✓')} key accepted`);
36
+ console.log(` ${icon('ok')} key accepted`);
36
37
  }
37
38
  catch (e) {
38
39
  // Preserve multi-line "Fix:" guidance from getApiKey()/assertKeyValid —
@@ -40,7 +41,7 @@ export async function repairCommand(opts = {}) {
40
41
  // first-time `repair` user needs.
41
42
  const msg = e.message ?? String(e);
42
43
  const [head, ...rest] = msg.split('\n');
43
- console.log(` ${kleur.red('✗')} ${head}`);
44
+ console.log(` ${icon('fail')} ${head}`);
44
45
  for (const line of rest)
45
46
  console.log(` ${line}`);
46
47
  process.exitCode = 1;
@@ -78,10 +79,10 @@ export async function repairCommand(opts = {}) {
78
79
  const dest = `${sPath}.repair-${Date.now()}`;
79
80
  try {
80
81
  await rename(sPath, dest);
81
- console.log(` ${kleur.green('✓')} moved to ${relative(process.cwd(), dest)}`);
82
+ console.log(` ${icon('ok')} moved to ${relative(process.cwd(), dest)}`);
82
83
  }
83
84
  catch (e) {
84
- console.log(` ${kleur.red('✗')} ${e.message}`);
85
+ console.log(` ${icon('fail')} ${e.message}`);
85
86
  process.exitCode = 1;
86
87
  return;
87
88
  }
@@ -110,7 +111,7 @@ export async function repairCommand(opts = {}) {
110
111
  }
111
112
  else {
112
113
  await pruneEmptyDirs(outRoot);
113
- console.log(` ${kleur.green('✓')} done`);
114
+ console.log(` ${icon('ok')} done`);
114
115
  }
115
116
  // --- Step 4: full sync ---------------------------------------------------
116
117
  console.log(kleur.bold('4/4 ') + 'Running full sync…');
@@ -121,10 +122,10 @@ export async function repairCommand(opts = {}) {
121
122
  console.log(` ${kleur.dim(`would run \`${cmd('sync')} --full\` (skipped in --dry-run)`)}`);
122
123
  console.log();
123
124
  if (skippedStep2) {
124
- console.log(kleur.yellow('! repair plan would skip the state reset. Re-run with --yes to apply it.'));
125
+ console.log(ui.warn('repair plan would skip the state reset. Re-run with --yes to apply it.'));
125
126
  }
126
127
  else {
127
- console.log(kleur.green('✓ repair plan looks good. Re-run without --dry-run to apply.'));
128
+ console.log(ui.ok('repair plan looks good. Re-run without --dry-run to apply.'));
128
129
  }
129
130
  return;
130
131
  }
@@ -138,21 +139,10 @@ export async function repairCommand(opts = {}) {
138
139
  await syncCommand({ full: true, prune: true });
139
140
  console.log();
140
141
  if ((process.exitCode ?? 0) > exitBefore) {
141
- console.log(kleur.yellow(`! repair completed with errors — re-run \`${cmd('sync')}\` to retry the failed downloads.`));
142
+ console.log(ui.warn(`repair completed with errors — re-run \`${cmd('sync')}\` to retry the failed downloads.`));
142
143
  }
143
144
  else {
144
- console.log(kleur.green('✓ repair complete.'));
145
- }
146
- }
147
- async function confirm(prompt) {
148
- if (!stdin.isTTY)
149
- return false; // CI/non-interactive: default to NO; require --yes.
150
- const rl = createInterface({ input: stdin, output: stdout });
151
- try {
152
- const answer = (await rl.question(` ${prompt}`)).trim().toLowerCase();
153
- return answer === 'y' || answer === 'yes';
154
- }
155
- finally {
156
- rl.close();
145
+ console.log(ui.ok('repair complete.'));
157
146
  }
158
147
  }
148
+ const confirm = (prompt) => askYesNo(` ${prompt}`);
@@ -7,8 +7,11 @@
7
7
  * - MagicPixel files in that folder with no local file go to Trash.
8
8
  */
9
9
  import kleur from 'kleur';
10
- import { createInterface } from 'node:readline/promises';
11
- import { stdin, stdout } from 'node:process';
10
+ import { ui } from '../util/ui.js';
11
+ import { confirm } from '../util/prompt.js';
12
+ import { canRedraw } from '../util/ui.js';
13
+ import { stripMemberSegment } from '../util/workspace.js';
14
+ import { stdout } from 'node:process';
12
15
  import { loadConfig, loadState } from '../config.js';
13
16
  import { ensureEngineConnect } from '../util/engineConnect.js';
14
17
  import { detectProjectKind, isEngineKind } from '../util/framework.js';
@@ -20,11 +23,11 @@ export function slugifySegment(input) {
20
23
  return input.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
21
24
  }
22
25
  export function parseFolderArg(folder) {
23
- return folder
26
+ return stripMemberSegment(folder
24
27
  .replace(/\\/g, '/')
25
28
  .split('/')
26
29
  .map((s) => slugifySegment(s.trim()))
27
- .filter(Boolean);
30
+ .filter(Boolean));
28
31
  }
29
32
  /**
30
33
  * True when `key` (`<folders…>/<doc>/<artboard>`) lives inside `segments`.
@@ -38,18 +41,6 @@ export function keyInFolder(key, segments) {
38
41
  return false;
39
42
  return segments.every((s, i) => folderParts[i] === s);
40
43
  }
41
- async function confirm(prompt) {
42
- if (!stdin.isTTY)
43
- return false;
44
- const rl = createInterface({ input: stdin, output: stdout });
45
- try {
46
- const a = (await rl.question(prompt)).trim().toLowerCase();
47
- return a === 'y' || a === 'yes';
48
- }
49
- finally {
50
- rl.close();
51
- }
52
- }
53
44
  export async function resyncCommand(folder, opts = {}) {
54
45
  await runResync(parseFolderArg(folder), opts);
55
46
  }
@@ -64,9 +55,9 @@ export async function runResync(segments, opts = {}) {
64
55
  if (!isEngineKind(kind))
65
56
  throw new Error('This folder is not a Unity/Godot/GameMaker project.');
66
57
  const gameIndex = await indexGamePngs(kind, process.cwd(), config.outDir, {
67
- onProgress: opts.quiet ? undefined : (p) => { stdout.write(`\r${countingSpritesText(p)}`); },
58
+ onProgress: opts.quiet || !canRedraw() ? undefined : (p) => { stdout.write(`\r${countingSpritesText(p)}`); },
68
59
  });
69
- if (!opts.quiet)
60
+ if (!opts.quiet && canRedraw())
70
61
  stdout.write('\n');
71
62
  const keyFilter = (key) => keyInFolder(key, segments);
72
63
  const label = segments.join('/');
@@ -111,8 +102,8 @@ export async function runResync(segments, opts = {}) {
111
102
  trashed = res.trashed;
112
103
  }
113
104
  if (pruneSkipped)
114
- log(kleur.yellow(`! Nothing moved to Trash (${pruneSkipped}). Re-run once fixed.`));
105
+ log(ui.warn(`Nothing moved to Trash (${pruneSkipped}). Re-run once fixed.`));
115
106
  else
116
- log(kleur.green(`✓ ${label} resynced. ${trashed} file${trashed === 1 ? '' : 's'} moved to Trash.`));
107
+ log(ui.ok(`${label} resynced. ${trashed} file${trashed === 1 ? '' : 's'} moved to Trash.`));
117
108
  return { push, trashed, pruneSkipped };
118
109
  }
@@ -1,5 +1,5 @@
1
1
  import kleur from 'kleur';
2
- import ora from 'ora';
2
+ import { spinner as makeSpinner } from '../util/ui.js';
3
3
  import { loadConfig } from '../config.js';
4
4
  import { detectProjectKind, isEngineKind } from '../util/framework.js';
5
5
  import { indexGamePngs, searchGameIndex, countingSpritesText } from '../util/gameScan.js';
@@ -26,7 +26,7 @@ export async function searchCommand(query) {
26
26
  catch {
27
27
  // Config is optional for search — skip MagicPixel outDir when missing.
28
28
  }
29
- const spinner = ora({ text: countingSpritesText(0), spinner: 'dots' }).start();
29
+ const spinner = makeSpinner({ text: countingSpritesText(0), spinner: 'dots' }).start();
30
30
  const index = await indexGamePngs(kind, process.cwd(), outDir, {
31
31
  onProgress: (p) => { spinner.text = countingSpritesText(p); },
32
32
  });
@@ -1,13 +1,23 @@
1
1
  import kleur from 'kleur';
2
+ import { ui } from '../util/ui.js';
3
+ import { confirm } from '../util/prompt.js';
4
+ let stepNo = 0;
5
+ let stepTotal = 0;
6
+ const step = (title) => console.log(ui.step(++stepNo, title, stepTotal));
2
7
  import { existsSync } from 'node:fs';
3
8
  import { readFile } from 'node:fs/promises';
4
9
  import { createInterface } from 'node:readline/promises';
5
10
  import { stdin, stdout } from 'node:process';
6
- import { resolve } from 'node:path';
11
+ import { basename, dirname, resolve } from 'node:path';
7
12
  import { configPath, loadConfig } from '../config.js';
8
13
  import { detectProjectKind, hasPackageJson, isEngineKind } from '../util/framework.js';
9
14
  import { findKeyInDotenv, readCredentialsSync, writeCredentials } from '../util/credentials.js';
10
15
  import { initCommand } from './init.js';
16
+ import { consolidateCommand, needsConsolidation } from './consolidate.js';
17
+ import { cleanStraysCommand } from './cleanStrays.js';
18
+ import { runPush } from './push.js';
19
+ import { readWorkspaceMembers } from '../util/framework.js';
20
+ import { detectWorkspaceMembers } from '../util/workspace.js';
11
21
  import { loginCommand } from './login.js';
12
22
  import { syncCommand } from './sync.js';
13
23
  import { cmd } from '../util/invoke.js';
@@ -46,6 +56,35 @@ export async function startCommand(opts = {}) {
46
56
  console.log(kleur.bold('🪄 MagicPixel — first-run setup'));
47
57
  console.log(kleur.dim(' This links your game project and syncs sprites both ways.'));
48
58
  console.log();
59
+ // Started inside one game folder of a monorepo: set up once at the root.
60
+ if (!existsSync(configPath()) && stdin.isTTY) {
61
+ const parent = dirname(process.cwd());
62
+ const siblings = detectWorkspaceMembers(parent);
63
+ if (siblings.length >= 2 && siblings.includes(basename(process.cwd()))) {
64
+ const rl = createInterface({ input: stdin, output: stdout });
65
+ let ans = '';
66
+ try {
67
+ ans = (await rl.question(`${kleur.cyan('?')} ${basename(process.cwd())} is one of ${siblings.length} game folders in ${parent}. Set up one MagicPixel for all of them there? ${kleur.dim('(Y/n)')} `)).trim().toLowerCase();
68
+ }
69
+ finally {
70
+ rl.close();
71
+ }
72
+ if (ans !== 'n' && ans !== 'no')
73
+ process.chdir(parent);
74
+ }
75
+ }
76
+ // Monorepo with several game folders: merge their old setups into one here.
77
+ let consolidated = false;
78
+ const merging = needsConsolidation();
79
+ stepNo = 0;
80
+ stepTotal = merging ? 3 : 2;
81
+ if (merging) {
82
+ step('One setup for all your game folders');
83
+ consolidated = await consolidateCommand({});
84
+ if (!consolidated && !readWorkspaceMembers(process.cwd()))
85
+ return;
86
+ console.log();
87
+ }
49
88
  const kind = await detectProjectKind();
50
89
  // Engine projects (including Unity UPM packages) have no package.json
51
90
  // requirement. JS projects still need one.
@@ -85,36 +124,35 @@ export async function startCommand(opts = {}) {
85
124
  const config = await loadConfig();
86
125
  const envKey = process.env.MAGICPIXEL_API_KEY?.trim();
87
126
  const stored = readCredentialsSync();
127
+ console.log();
128
+ step('Your MagicPixel account');
88
129
  if (!envKey && !stored) {
89
- console.log();
90
- console.log(kleur.bold('Step: connect your account'));
91
130
  await loginCommand();
92
131
  }
93
132
  else if (envKey) {
94
133
  if ((await checkKey(envKey, config)) === 'rejected') {
95
- console.log(kleur.red('✖ The MAGICPIXEL_API_KEY in your environment was rejected.'));
96
- console.log(kleur.dim(' Fix: set it to a fresh key from https://magicpixel.art/settings (or unset it and re-run).'));
134
+ console.log(ui.fail('The MAGICPIXEL_API_KEY in your environment was rejected.'));
135
+ console.log(ui.fix('set MAGICPIXEL_API_KEY to a fresh key from https://magicpixel.art/settings'));
97
136
  process.exitCode = 1;
98
137
  return;
99
138
  }
100
- console.log(kleur.dim(' Using MAGICPIXEL_API_KEY from your environment.'));
139
+ console.log(ui.ok('Using MAGICPIXEL_API_KEY from your environment.'));
101
140
  }
102
141
  else if (stored && (await checkKey(stored.apiKey, config)) === 'rejected') {
103
- console.log(kleur.yellow('! Your saved key was rejected (revoked, or from another account).'));
142
+ console.log(ui.warn('Your saved key was rejected (revoked, or from another account).'));
104
143
  if (!stdin.isTTY) {
105
- console.log(kleur.dim(` Fix: run \`${cmd('login')} --key mp_live_…\` with a fresh key from https://magicpixel.art/settings.`));
144
+ console.log(ui.fix(`${cmd('login')} --key mp_live_…`));
106
145
  process.exitCode = 1;
107
146
  return;
108
147
  }
109
- console.log(kleur.bold('Step: connect your account'));
110
148
  await loginCommand();
111
149
  }
112
150
  else {
113
- console.log(kleur.dim(' Using stored credentials from .magicpixel/credentials.'));
151
+ console.log(ui.ok('Signed in (saved key in .magicpixel/credentials).'));
114
152
  }
115
153
  // 4b. Don't push a whole game by surprise — offer one folder first.
116
154
  let folder = opts.folder?.trim();
117
- if (!folder && shouldAskFolder(config.connect, !!stdin.isTTY, isEngineKind(kind))) {
155
+ if (!folder && !config.workspace && shouldAskFolder(config.connect, !!stdin.isTTY, isEngineKind(kind))) {
118
156
  const rl = createInterface({ input: stdin, output: stdout });
119
157
  try {
120
158
  folder = (await rl.question(`${kleur.cyan('?')} Sync all sprites, or one folder? ${kleur.dim('(type a folder like Sprites/Enemies, or press Enter for all)')} `)).trim();
@@ -125,7 +163,7 @@ export async function startCommand(opts = {}) {
125
163
  }
126
164
  if (folder) {
127
165
  console.log();
128
- console.log(kleur.bold('Step: sync one folder'));
166
+ step('Sync one folder');
129
167
  await connectCommand(folderToGlob(folder));
130
168
  console.log();
131
169
  console.log(kleur.dim(` Add another folder later with \`${cmd('connect')} "<folder>/**"\`.`));
@@ -137,11 +175,14 @@ export async function startCommand(opts = {}) {
137
175
  // print a misleading green "you're set up" over a half-failed run.
138
176
  // Manifest 546s throw after still importing local sprites; catch so
139
177
  // the watch instructions below aren't swallowed.
178
+ if (consolidated)
179
+ await removeLeftoverStrays();
180
+ const gameIndex = await maybeFlattenLayered();
140
181
  console.log();
141
- console.log(kleur.bold('Step: sync'));
182
+ step('First sync');
142
183
  const exitBefore = process.exitCode ?? 0;
143
184
  try {
144
- await syncCommand({ full: true });
185
+ await syncCommand({ full: true }, { gameIndex });
145
186
  }
146
187
  catch (e) {
147
188
  const err = e;
@@ -157,7 +198,7 @@ export async function startCommand(opts = {}) {
157
198
  // `npx` form so we never instruct users to run a script they don't have.
158
199
  console.log();
159
200
  if (firstSyncFailed) {
160
- console.log(kleur.yellow('! first sync completed with errors.'));
201
+ console.log(ui.warn('first sync completed with errors.'));
161
202
  console.log(kleur.dim(` Re-run \`${cmd('sync')}\` to retry the failed downloads, or \`${cmd('doctor')}\` to diagnose.`));
162
203
  }
163
204
  else {
@@ -181,6 +222,46 @@ export async function startCommand(opts = {}) {
181
222
  console.log();
182
223
  }
183
224
  }
225
+ /** After a merge: drop copies an older sync wrote for another folder's sprites. */
226
+ async function removeLeftoverStrays() {
227
+ try {
228
+ await cleanStraysCommand({ yes: true });
229
+ }
230
+ catch (e) {
231
+ console.log(ui.warn(`couldn't check for stray copies: ${e.message.split('\n')[0]}`));
232
+ }
233
+ }
234
+ /**
235
+ * Local files win: offer to replace multi-layer MagicPixel artboards that a
236
+ * normal upload refuses, instead of silently skipping them.
237
+ */
238
+ async function maybeFlattenLayered() {
239
+ if (!stdin.isTTY)
240
+ return undefined;
241
+ let keys = [];
242
+ let gameIndex;
243
+ try {
244
+ const plan = await runPush({ dryRun: true, quiet: true, nestedSync: true });
245
+ keys = plan.needsFlatten ?? [];
246
+ gameIndex = plan.gameIndex;
247
+ }
248
+ catch {
249
+ return undefined;
250
+ }
251
+ if (keys.length === 0)
252
+ return gameIndex;
253
+ console.log();
254
+ console.log(ui.warn(`${keys.length} of your files match a MagicPixel artboard with several layers:`));
255
+ for (const k of keys.slice(0, 10))
256
+ console.log(kleur.dim(` ${k}`));
257
+ if (keys.length > 10)
258
+ console.log(kleur.dim(` …and ${keys.length - 10} more`));
259
+ if (!(await confirm(`${kleur.cyan('?')} Replace them with your local files (layers are flattened)? ${kleur.dim('(y/N)')} `)))
260
+ return gameIndex;
261
+ const only = new Set(keys);
262
+ await runPush({ flatten: true, keyFilter: (k) => only.has(k), gameIndex });
263
+ return gameIndex;
264
+ }
184
265
  async function readPkgJson() {
185
266
  try {
186
267
  const path = resolve(process.cwd(), 'package.json');
@@ -220,7 +301,7 @@ async function maybeMigrateDotenvKey() {
220
301
  if (ans === 'n' || ans === 'no')
221
302
  return;
222
303
  await writeCredentials(found.value);
223
- console.log(kleur.green(`✓ migrated key from ${found.file} → .magicpixel/credentials`));
304
+ console.log(ui.ok(`migrated key from ${found.file} → .magicpixel/credentials`));
224
305
  console.log(kleur.dim(` You can now remove the MAGICPIXEL_API_KEY line from ${found.file}.`));
225
306
  }
226
307
  finally {