@magicpixelart/cli 0.5.38 → 0.6.2

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,11 +7,11 @@
7
7
  * `POST /api/public/integration/ingest`.
8
8
  */
9
9
  import kleur from 'kleur';
10
- import ora from 'ora';
10
+ import { icon, progressBar, shortenPath, spinner as makeSpinner, ui, visibleWidth } 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';
14
- import { ensureEngineConnect } from '../util/engineConnect.js';
14
+ import { ensureEngineConnect, unwatchedHiddenFolders } from '../util/engineConnect.js';
15
15
  import { PUSH_BATCH_SIZE, pushSpritesAdaptive, fetchManifestPage, getLastProjectInfo } from '../api.js';
16
16
  import { hashFile } from '../util/hash.js';
17
17
  import { createLimit } from '../util/limit.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)
@@ -67,6 +80,13 @@ export async function runPushWith(config, state, opts = {}) {
67
80
  : { files: [] });
68
81
  const matched = matchConnectGlobs(index, config.connect ?? [], config.exclude);
69
82
  const sourceByKey = collectSourceRelMap(matched.entries, state.synced);
83
+ if (!quiet) {
84
+ const watched = new Set(matched.entries.map((e) => e.key));
85
+ for (const [folder, n] of unwatchedHiddenFolders(index.files, watched, config.exclude ?? []).slice(0, 5)) {
86
+ console.log(ui.warn(`${n.toLocaleString('en-US')} PNGs in ${folder} aren't watched.`));
87
+ console.log(ui.fix(`${cmd('connect')} '${folder}/**'`));
88
+ }
89
+ }
70
90
  const disk = await walkOutDirPngs(config.outDir);
71
91
  const absByKey = new Map(disk.map((a) => [a.key, a.abs]));
72
92
  const entryByKey = new Map(matched.entries.map((e) => [e.key, e]));
@@ -90,12 +110,11 @@ export async function runPushWith(config, state, opts = {}) {
90
110
  let hashed = 0;
91
111
  const reportHash = () => {
92
112
  hashed++;
93
- const n = hashed.toLocaleString('en-US');
94
- const t = toHash.length.toLocaleString('en-US');
113
+ const line = `Checking your game files ${progressBar(hashed, toHash.length)}`;
95
114
  if (spinner)
96
- spinner.text = `Checking your game files… ${n} / ${t}`;
115
+ spinner.text = line;
97
116
  else if (opts.onStatus && (hashed === 1 || hashed === toHash.length || hashed % 25 === 0)) {
98
- opts.onStatus(`Checking your game files… ${n} / ${t}`);
117
+ opts.onStatus(`${icon('work')} ${line}`);
99
118
  }
100
119
  };
101
120
  await Promise.all(toHash.map(([key, abs]) => scan(async () => {
@@ -149,31 +168,33 @@ export async function runPushWith(config, state, opts = {}) {
149
168
  const legacy = actions.filter((a) => a.kind === 'skip' && a.reason === 'legacy');
150
169
  const flattenBlocked = actions.filter((a) => a.kind === 'needs-flatten');
151
170
  const sendable = actions.filter((a) => a.kind === 'update' || a.kind === 'adopt');
171
+ const preIssues = skipIssues(legacy, flattenBlocked.map((a) => ({ key: a.key, ...('layers' in a && typeof a.layers === 'number' ? { layers: a.layers } : {}) })));
152
172
  spinner?.succeed(`Local sprites: ${candidates.length} · to push ${sendable.length} · unchanged ${skipped}` +
153
173
  (matched.entries.length ? ` · connected ${matched.entries.length}` : ''));
154
174
  if (skippedUnsafe > 0 && !quiet) {
155
175
  console.log(kleur.yellow(`! skipped ${skippedUnsafe} path${skippedUnsafe === 1 ? '' : 's'} that were not regular files inside the project.`));
156
176
  }
157
- if (legacy.length > 0 && !quiet && !opts.nestedSync) {
177
+ if (legacy.length > 0 && !quiet) {
158
178
  console.log();
159
179
  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')}\`.`));
180
+ console.log(ui.fixText(`open the file in MagicPixel and save it once, then re-run \`${cmd('sync')}\`.`));
161
181
  }
162
- if (flattenBlocked.length > 0 && !quiet && !opts.nestedSync) {
182
+ if (flattenBlocked.length > 0 && !quiet) {
163
183
  console.log();
164
184
  console.log(kleur.yellow(`! ${flattenBlocked.length} sprite${flattenBlocked.length === 1 ? '' : 's'} would flatten a multi-layer artboard — skipped.`));
165
185
  for (const a of flattenBlocked.slice(0, 10)) {
166
186
  console.log(kleur.dim(` ${a.key} (${'layers' in a ? a.layers : '?'} layers)`));
167
187
  }
168
- console.log(kleur.dim(' Fix: re-run with --flatten to replace them with the flat disk image.'));
188
+ console.log(ui.fixText('re-run with --flatten to replace them with the flat disk image.'));
169
189
  }
170
190
  if (sendable.length === 0) {
171
191
  await persistDiskFingerprints(state, candidates);
172
192
  if (!quiet)
173
- console.log(kleur.green('✓ nothing to push.'));
193
+ console.log(ui.ok('nothing to push.'));
174
194
  return {
175
195
  ...empty, imported: matched.entries.length, unchanged: skipped,
176
196
  keptAssetIds: collectKeptAssetIds(candidates, state.synced ?? {}, []), scanned: candidates.length,
197
+ issues: preIssues, needsFlatten: flattenBlocked.map((a) => a.key), gameIndex: index,
177
198
  };
178
199
  }
179
200
  if (opts.dryRun) {
@@ -188,6 +209,7 @@ export async function runPushWith(config, state, opts = {}) {
188
209
  return {
189
210
  ...empty, imported: matched.entries.length,
190
211
  keptAssetIds: collectKeptAssetIds(candidates, state.synced ?? {}, []), scanned: candidates.length,
212
+ issues: preIssues, needsFlatten: flattenBlocked.map((a) => a.key), gameIndex: index,
191
213
  };
192
214
  }
193
215
  const outRoot = resolve(process.cwd(), config.outDir);
@@ -221,7 +243,8 @@ export async function runPushWith(config, state, opts = {}) {
221
243
  });
222
244
  }
223
245
  }
224
- const progress = quiet ? null : ora(`Pushing 0/${sprites.length}…`).start();
246
+ const progress = quiet ? null : makeSpinner(`Uploading ${progressBar(0, sprites.length)}`).start();
247
+ opts.onStatus?.(`${icon('up')} Uploading ${sprites.length.toLocaleString('en-US')} sprite${sprites.length === 1 ? '' : 's'}…`);
225
248
  const results = [];
226
249
  let synced = { ...(state.synced ?? {}) };
227
250
  try {
@@ -239,8 +262,15 @@ export async function runPushWith(config, state, opts = {}) {
239
262
  outDir: config.outDir,
240
263
  });
241
264
  await saveState({ ...state, synced });
242
- if (progress)
243
- progress.text = `Pushing ${Math.min(i + batch.length, sprites.length)}/${sprites.length}…`;
265
+ const doneN = Math.min(i + batch.length, sprites.length);
266
+ const last = batch[batch.length - 1];
267
+ const where = last ? fpByKey.get(last.key)?.sourceRel ?? last.key : '';
268
+ if (progress) {
269
+ const head = `Uploading ${progressBar(doneN, sprites.length)} `;
270
+ progress.text = head + kleur.dim(shortenPath(where, (process.stdout.columns ?? 80) - visibleWidth(head) - 4));
271
+ }
272
+ else
273
+ opts.onStatus?.(`${icon('up')} Uploading ${doneN}/${sprites.length} ${shortenPath(where, 60)}…`);
244
274
  }
245
275
  }
246
276
  catch (e) {
@@ -300,11 +330,11 @@ export async function runPushWith(config, state, opts = {}) {
300
330
  for (const r of results) {
301
331
  if (r.status === 'conflict') {
302
332
  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)}`);
333
+ 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
334
  }
305
335
  else if (r.status === 'error') {
306
336
  if (printedIssues++ < 10)
307
- console.log(` ${kleur.red('!')} ${r.key}: ${r.message ?? 'push failed'}`);
337
+ console.log(` ${icon('fail')} ${r.key}: ${r.message ?? 'push failed'}`);
308
338
  }
309
339
  }
310
340
  if (printedIssues > 10)
@@ -316,9 +346,24 @@ export async function runPushWith(config, state, opts = {}) {
316
346
  }
317
347
  if (failed)
318
348
  process.exitCode = 1;
349
+ const issues = [...preIssues];
350
+ for (const r of results) {
351
+ if (r.status === 'conflict') {
352
+ issues.push({
353
+ key: r.key,
354
+ text: opts.force && r.lastWriteSource === 'editor' && !opts.overwriteEditorChanges
355
+ ? 'edited in MagicPixel — skipped. Add --overwrite-editor-changes to replace it.'
356
+ : conflictHint(r.reason),
357
+ });
358
+ }
359
+ else if (r.status === 'error') {
360
+ issues.push({ key: r.key, text: r.message ?? 'push failed' });
361
+ }
362
+ }
319
363
  return {
320
364
  ...counts, imported: matched.entries.length,
321
365
  keptAssetIds: collectKeptAssetIds(candidates, synced, results), scanned: candidates.length,
366
+ issues, needsFlatten: flattenBlocked.map((a) => a.key),
322
367
  };
323
368
  }
324
369
  /**
@@ -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';
@@ -15,6 +25,7 @@ import { assertKeyValid } from '../util/auth.js';
15
25
  import { ApiError } from '../api.js';
16
26
  import { connectCommand } from './connect.js';
17
27
  import { isAllSpritesGlob } from '../util/engineConnect.js';
28
+ import { COMMAND_TIPS, formatTipsBlock } from '../util/commandTips.js';
18
29
  /** Validate a key; only auth rejections map to 'rejected' — other failures throw. */
19
30
  export async function checkKey(key, config) {
20
31
  try {
@@ -46,6 +57,35 @@ export async function startCommand(opts = {}) {
46
57
  console.log(kleur.bold('🪄 MagicPixel — first-run setup'));
47
58
  console.log(kleur.dim(' This links your game project and syncs sprites both ways.'));
48
59
  console.log();
60
+ // Started inside one game folder of a monorepo: set up once at the root.
61
+ if (!existsSync(configPath()) && stdin.isTTY) {
62
+ const parent = dirname(process.cwd());
63
+ const siblings = detectWorkspaceMembers(parent);
64
+ if (siblings.length >= 2 && siblings.includes(basename(process.cwd()))) {
65
+ const rl = createInterface({ input: stdin, output: stdout });
66
+ let ans = '';
67
+ try {
68
+ 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();
69
+ }
70
+ finally {
71
+ rl.close();
72
+ }
73
+ if (ans !== 'n' && ans !== 'no')
74
+ process.chdir(parent);
75
+ }
76
+ }
77
+ // Monorepo with several game folders: merge their old setups into one here.
78
+ let consolidated = false;
79
+ const merging = needsConsolidation();
80
+ stepNo = 0;
81
+ stepTotal = merging ? 3 : 2;
82
+ if (merging) {
83
+ step('One setup for all your game folders');
84
+ consolidated = await consolidateCommand({});
85
+ if (!consolidated && !readWorkspaceMembers(process.cwd()))
86
+ return;
87
+ console.log();
88
+ }
49
89
  const kind = await detectProjectKind();
50
90
  // Engine projects (including Unity UPM packages) have no package.json
51
91
  // requirement. JS projects still need one.
@@ -85,36 +125,35 @@ export async function startCommand(opts = {}) {
85
125
  const config = await loadConfig();
86
126
  const envKey = process.env.MAGICPIXEL_API_KEY?.trim();
87
127
  const stored = readCredentialsSync();
128
+ console.log();
129
+ step('Your MagicPixel account');
88
130
  if (!envKey && !stored) {
89
- console.log();
90
- console.log(kleur.bold('Step: connect your account'));
91
131
  await loginCommand();
92
132
  }
93
133
  else if (envKey) {
94
134
  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).'));
135
+ console.log(ui.fail('The MAGICPIXEL_API_KEY in your environment was rejected.'));
136
+ console.log(ui.fix('set MAGICPIXEL_API_KEY to a fresh key from https://magicpixel.art/settings'));
97
137
  process.exitCode = 1;
98
138
  return;
99
139
  }
100
- console.log(kleur.dim(' Using MAGICPIXEL_API_KEY from your environment.'));
140
+ console.log(ui.ok('Using MAGICPIXEL_API_KEY from your environment.'));
101
141
  }
102
142
  else if (stored && (await checkKey(stored.apiKey, config)) === 'rejected') {
103
- console.log(kleur.yellow('! Your saved key was rejected (revoked, or from another account).'));
143
+ console.log(ui.warn('Your saved key was rejected (revoked, or from another account).'));
104
144
  if (!stdin.isTTY) {
105
- console.log(kleur.dim(` Fix: run \`${cmd('login')} --key mp_live_…\` with a fresh key from https://magicpixel.art/settings.`));
145
+ console.log(ui.fix(`${cmd('login')} --key mp_live_…`));
106
146
  process.exitCode = 1;
107
147
  return;
108
148
  }
109
- console.log(kleur.bold('Step: connect your account'));
110
149
  await loginCommand();
111
150
  }
112
151
  else {
113
- console.log(kleur.dim(' Using stored credentials from .magicpixel/credentials.'));
152
+ console.log(ui.ok('Signed in (saved key in .magicpixel/credentials).'));
114
153
  }
115
154
  // 4b. Don't push a whole game by surprise — offer one folder first.
116
155
  let folder = opts.folder?.trim();
117
- if (!folder && shouldAskFolder(config.connect, !!stdin.isTTY, isEngineKind(kind))) {
156
+ if (!folder && !config.workspace && shouldAskFolder(config.connect, !!stdin.isTTY, isEngineKind(kind))) {
118
157
  const rl = createInterface({ input: stdin, output: stdout });
119
158
  try {
120
159
  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 +164,7 @@ export async function startCommand(opts = {}) {
125
164
  }
126
165
  if (folder) {
127
166
  console.log();
128
- console.log(kleur.bold('Step: sync one folder'));
167
+ step('Sync one folder');
129
168
  await connectCommand(folderToGlob(folder));
130
169
  console.log();
131
170
  console.log(kleur.dim(` Add another folder later with \`${cmd('connect')} "<folder>/**"\`.`));
@@ -137,11 +176,14 @@ export async function startCommand(opts = {}) {
137
176
  // print a misleading green "you're set up" over a half-failed run.
138
177
  // Manifest 546s throw after still importing local sprites; catch so
139
178
  // the watch instructions below aren't swallowed.
179
+ if (consolidated)
180
+ await removeLeftoverStrays();
181
+ const gameIndex = await maybeFlattenLayered();
140
182
  console.log();
141
- console.log(kleur.bold('Step: sync'));
183
+ step('First sync');
142
184
  const exitBefore = process.exitCode ?? 0;
143
185
  try {
144
- await syncCommand({ full: true });
186
+ await syncCommand({ full: true }, { gameIndex });
145
187
  }
146
188
  catch (e) {
147
189
  const err = e;
@@ -157,7 +199,7 @@ export async function startCommand(opts = {}) {
157
199
  // `npx` form so we never instruct users to run a script they don't have.
158
200
  console.log();
159
201
  if (firstSyncFailed) {
160
- console.log(kleur.yellow('! first sync completed with errors.'));
202
+ console.log(ui.warn('first sync completed with errors.'));
161
203
  console.log(kleur.dim(` Re-run \`${cmd('sync')}\` to retry the failed downloads, or \`${cmd('doctor')}\` to diagnose.`));
162
204
  }
163
205
  else {
@@ -174,6 +216,8 @@ export async function startCommand(opts = {}) {
174
216
  console.log();
175
217
  console.log(kleur.dim(' Game sprites write back to their original path. New MagicPixel art lands in outDir.'));
176
218
  console.log();
219
+ console.log(formatTipsBlock(COMMAND_TIPS.slice(1)));
220
+ console.log();
177
221
  if (await hasDevScript()) {
178
222
  const watchCmd = hasWatch ? 'npm run magicpixel:watch' : `${cmd('sync')} --watch`;
179
223
  console.log(kleur.dim(' Tip: run your dev server and the watcher together with'));
@@ -181,6 +225,46 @@ export async function startCommand(opts = {}) {
181
225
  console.log();
182
226
  }
183
227
  }
228
+ /** After a merge: drop copies an older sync wrote for another folder's sprites. */
229
+ async function removeLeftoverStrays() {
230
+ try {
231
+ await cleanStraysCommand({ yes: true });
232
+ }
233
+ catch (e) {
234
+ console.log(ui.warn(`couldn't check for stray copies: ${e.message.split('\n')[0]}`));
235
+ }
236
+ }
237
+ /**
238
+ * Local files win: offer to replace multi-layer MagicPixel artboards that a
239
+ * normal upload refuses, instead of silently skipping them.
240
+ */
241
+ async function maybeFlattenLayered() {
242
+ if (!stdin.isTTY)
243
+ return undefined;
244
+ let keys = [];
245
+ let gameIndex;
246
+ try {
247
+ const plan = await runPush({ dryRun: true, quiet: true, nestedSync: true });
248
+ keys = plan.needsFlatten ?? [];
249
+ gameIndex = plan.gameIndex;
250
+ }
251
+ catch {
252
+ return undefined;
253
+ }
254
+ if (keys.length === 0)
255
+ return gameIndex;
256
+ console.log();
257
+ console.log(ui.warn(`${keys.length} of your files match a MagicPixel artboard with several layers:`));
258
+ for (const k of keys.slice(0, 10))
259
+ console.log(kleur.dim(` ${k}`));
260
+ if (keys.length > 10)
261
+ console.log(kleur.dim(` …and ${keys.length - 10} more`));
262
+ if (!(await confirm(`${kleur.cyan('?')} Replace them with your local files (layers are flattened)? ${kleur.dim('(y/N)')} `)))
263
+ return gameIndex;
264
+ const only = new Set(keys);
265
+ await runPush({ flatten: true, keyFilter: (k) => only.has(k), gameIndex });
266
+ return gameIndex;
267
+ }
184
268
  async function readPkgJson() {
185
269
  try {
186
270
  const path = resolve(process.cwd(), 'package.json');
@@ -220,7 +304,7 @@ async function maybeMigrateDotenvKey() {
220
304
  if (ans === 'n' || ans === 'no')
221
305
  return;
222
306
  await writeCredentials(found.value);
223
- console.log(kleur.green(`✓ migrated key from ${found.file} → .magicpixel/credentials`));
307
+ console.log(ui.ok(`migrated key from ${found.file} → .magicpixel/credentials`));
224
308
  console.log(kleur.dim(` You can now remove the MAGICPIXEL_API_KEY line from ${found.file}.`));
225
309
  }
226
310
  finally {