@magicpixelart/cli 0.1.0 → 0.1.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.
package/README.md CHANGED
@@ -1,13 +1,13 @@
1
- # @magicpixel/cli
1
+ # @magicpixelart/cli
2
2
 
3
3
  Sync MagicPixel pixel-art assets to your local project as flattened PNGs. Zero runtime — just files on disk that your bundler picks up.
4
4
 
5
5
  ## Install
6
6
 
7
7
  ```bash
8
- npm install --save-dev @magicpixel/cli
9
- # or: pnpm add -D @magicpixel/cli
10
- # or: bun add -d @magicpixel/cli
8
+ npm install --save-dev @magicpixelart/cli
9
+ # or: pnpm add -D @magicpixelart/cli
10
+ # or: bun add -d @magicpixelart/cli
11
11
  ```
12
12
 
13
13
  Requires Node.js ≥ 18.
@@ -23,6 +23,8 @@ npx magicpixel sync --watch # keeps assets fresh while you work
23
23
 
24
24
  Get an API key at [magicpixel.art/settings](https://magicpixel.art/settings) → API Keys. Each key is bound to one project.
25
25
 
26
+ The key is read **only** from the `MAGICPIXEL_API_KEY` environment variable — never from `magicpixel.json` or any other file. This is intentional: it keeps secrets out of repos and CI logs.
27
+
26
28
  ## Typed asset index (default on)
27
29
 
28
30
  If `emitIndex` is true in `magicpixel.json`, every sync writes `<outDir>/index.ts`:
@@ -93,7 +95,7 @@ jobs:
93
95
  - uses: actions/checkout@v4
94
96
  - uses: actions/setup-node@v4
95
97
  with: { node-version: 20 }
96
- - run: npx -y @magicpixel/cli sync --prune --quiet
98
+ - run: npx -y @magicpixelart/cli sync --prune --quiet
97
99
  env:
98
100
  MAGICPIXEL_API_KEY: ${{ secrets.MAGICPIXEL_API_KEY }}
99
101
  - uses: peter-evans/create-pull-request@v6
package/dist/api.js CHANGED
@@ -57,12 +57,18 @@ export async function fetchManifestPage(opts) {
57
57
  export async function fetchAllManifest(config, since) {
58
58
  const out = [];
59
59
  let cursor;
60
- do {
61
- const page = await fetchManifestPage({ config, since, cursor, limit: 500 });
62
- out.push(...page.items);
63
- cursor = page.nextCursor ?? undefined;
64
- } while (cursor);
65
- return out;
60
+ // Cycle guard: caps total pages so a buggy server cursor can't hang the CLI.
61
+ // At 500 entries/page this is 100k assets — well past any realistic project.
62
+ const MAX_PAGES = 200;
63
+ for (let page = 0; page < MAX_PAGES; page++) {
64
+ const res = await fetchManifestPage({ config, since, cursor, limit: 500 });
65
+ out.push(...res.items);
66
+ cursor = res.nextCursor ?? undefined;
67
+ if (!cursor)
68
+ return out;
69
+ }
70
+ throw new Error(`manifest: pagination exceeded ${MAX_PAGES} pages — possible server cursor loop.\n` +
71
+ ` Fix: re-run with --full, or report at https://github.com/magicpixel/cli/issues.`);
66
72
  }
67
73
  /**
68
74
  * Download a single asset by key. Returns null on 304 (not modified).
@@ -20,7 +20,7 @@ export async function initCommand(opts) {
20
20
  if (interactive) {
21
21
  const rl = createInterface({ input: stdin, output: stdout });
22
22
  try {
23
- console.log(kleur.bold('Set up @magicpixel/cli'));
23
+ console.log(kleur.bold('Set up @magicpixelart/cli'));
24
24
  if (framework)
25
25
  console.log(kleur.dim(` Detected: ${framework}`));
26
26
  console.log();
@@ -115,9 +115,11 @@ function isImportableOutDir(outDir) {
115
115
  async function ensureGitignore() {
116
116
  const path = resolve(process.cwd(), '.gitignore');
117
117
  const marker = '.magicpixel/';
118
+ // Accept any of these as "already ignored" so we don't append a duplicate.
119
+ const equivalents = new Set(['.magicpixel', '.magicpixel/', '/.magicpixel', '/.magicpixel/']);
118
120
  if (existsSync(path)) {
119
121
  const current = await readFile(path, 'utf8');
120
- if (current.split('\n').some((l) => l.trim() === marker))
122
+ if (current.split('\n').some((l) => equivalents.has(l.trim())))
121
123
  return false;
122
124
  await appendFile(path, (current.endsWith('\n') ? '' : '\n') + `\n# MagicPixel CLI state\n${marker}\n`);
123
125
  return true;
@@ -1,5 +1,6 @@
1
1
  import kleur from 'kleur';
2
2
  import ora from 'ora';
3
+ import { createHash } from 'node:crypto';
3
4
  import { mkdir, writeFile, unlink, readdir, rm, rename } from 'node:fs/promises';
4
5
  import { existsSync } from 'node:fs';
5
6
  import { dirname, relative, resolve } from 'node:path';
@@ -19,16 +20,26 @@ export async function syncCommand(opts) {
19
20
  async function watchLoop(opts) {
20
21
  const intervalSec = typeof opts.watch === 'string' ? Math.max(2, parseInt(opts.watch, 10) || 10) : 10;
21
22
  console.log(kleur.dim(`[watch] polling every ${intervalSec}s. Ctrl+C to stop.`));
23
+ let stopping = false;
24
+ const onSigint = () => {
25
+ if (stopping)
26
+ return;
27
+ stopping = true;
28
+ // \x1b[2K\r clears any in-flight status line before the goodbye message.
29
+ process.stdout.write('\x1b[2K\r');
30
+ console.log(kleur.dim('[watch] stopped.'));
31
+ process.exit(0);
32
+ };
33
+ process.on('SIGINT', onSigint);
22
34
  // Run once eagerly so first sync isn't delayed by the interval.
23
35
  let inFlight = false;
24
36
  const tick = async () => {
25
- if (inFlight)
37
+ if (inFlight || stopping)
26
38
  return;
27
39
  inFlight = true;
28
40
  try {
29
41
  const r = await runOnce({ ...opts, quiet: true, watch: false });
30
42
  if (r.downloaded > 0 || r.pruned > 0) {
31
- // \x1b[2K\r clears any residual "no changes" line from the previous tick.
32
43
  process.stdout.write('\x1b[2K\r');
33
44
  console.log(`${kleur.dim(timestamp())} ${kleur.green('✓')} ${r.downloaded} downloaded` +
34
45
  (r.pruned ? `, ${r.pruned} pruned` : '') +
@@ -46,8 +57,7 @@ async function watchLoop(opts) {
46
57
  }
47
58
  };
48
59
  await tick();
49
- // eslint-disable-next-line no-constant-condition
50
- while (true) {
60
+ while (!stopping) {
51
61
  await new Promise((r) => setTimeout(r, intervalSec * 1000));
52
62
  await tick();
53
63
  }
@@ -58,6 +68,11 @@ async function runOnce(opts) {
58
68
  const startedAt = new Date().toISOString();
59
69
  const concurrency = Math.max(1, Math.min(opts.concurrency ?? 6, 16));
60
70
  const quiet = !!opts.quiet;
71
+ // Warn loudly when a custom endpoint is configured — easy to forget a test
72
+ // override before committing magicpixel.json to a real repo / CI.
73
+ if (!quiet && config.endpoint) {
74
+ console.log(kleur.yellow(`! using custom endpoint: ${config.endpoint}`));
75
+ }
61
76
  // Incremental: only fetch what changed since last successful sync.
62
77
  // --full forces a from-scratch fetch.
63
78
  const since = opts.full ? undefined : state.lastSync;
@@ -142,6 +157,14 @@ async function runOnce(opts) {
142
157
  result.unchanged++;
143
158
  }
144
159
  else {
160
+ // Verify the payload matches what the manifest advertised. Guards
161
+ // against silently writing a corrupted body to disk.
162
+ if (entry.sha256) {
163
+ const actual = createHash('sha256').update(bytes).digest('hex');
164
+ if (actual !== entry.sha256) {
165
+ throw new Error(`sha256 mismatch (expected ${entry.sha256.slice(0, 12)}…, got ${actual.slice(0, 12)}…)`);
166
+ }
167
+ }
145
168
  await mkdir(dirname(diskPath), { recursive: true });
146
169
  const tmp = `${diskPath}.${process.pid}.tmp`;
147
170
  await writeFile(tmp, bytes);
package/dist/config.js CHANGED
@@ -77,4 +77,4 @@ export function getApiKey() {
77
77
  }
78
78
  return key;
79
79
  }
80
- export const CLI_USER_AGENT = `@magicpixel/cli/0.1.0 (node ${process.versions.node})`;
80
+ export { CLI_USER_AGENT, CLI_VERSION } from './version.js';
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { Command } from 'commander';
2
+ import { Command, InvalidArgumentError } from 'commander';
3
3
  import kleur from 'kleur';
4
4
  import { initCommand } from './commands/init.js';
5
5
  import { syncCommand } from './commands/sync.js';
@@ -8,6 +8,7 @@ import { removeCommand } from './commands/remove.js';
8
8
  import { listCommand } from './commands/list.js';
9
9
  import { statusCommand } from './commands/status.js';
10
10
  import { whoamiCommand } from './commands/whoami.js';
11
+ import { CLI_VERSION } from './version.js';
11
12
  // Node version guard
12
13
  const major = Number(process.versions.node.split('.')[0]);
13
14
  if (major < 18) {
@@ -18,7 +19,7 @@ const program = new Command();
18
19
  program
19
20
  .name('magicpixel')
20
21
  .description('Sync MagicPixel pixel-art assets to your local project')
21
- .version('0.1.0');
22
+ .version(CLI_VERSION);
22
23
  const wrap = (fn) => async (...args) => {
23
24
  try {
24
25
  await fn(...args);
@@ -44,7 +45,13 @@ program
44
45
  .option('--full', 'Ignore lastSync state; re-fetch the full manifest')
45
46
  .option('-w, --watch [seconds]', 'Poll for changes (default 10s)', (v) => v ?? true)
46
47
  .option('-q, --quiet', 'Minimal output (for CI)')
47
- .option('-c, --concurrency <n>', 'Parallel downloads (1–16, default 6)', (v) => parseInt(v, 10))
48
+ .option('-c, --concurrency <n>', 'Parallel downloads (1–16, default 6)', (v) => {
49
+ const n = parseInt(v, 10);
50
+ if (!Number.isFinite(n) || String(n) !== v.trim() || n < 1 || n > 16) {
51
+ throw new InvalidArgumentError(`expected an integer 1–16, got "${v}".`);
52
+ }
53
+ return n;
54
+ })
48
55
  .action(wrap(async (opts) => syncCommand(opts)));
49
56
  program
50
57
  .command('add <glob>')
@@ -10,22 +10,36 @@ import { resolve } from 'node:path';
10
10
  * time. No runtime, no bundler config.
11
11
  */
12
12
  export async function emitTypedIndex(outDir, manifest, cwd = process.cwd()) {
13
- const sorted = [...manifest].sort((a, b) => a.key.localeCompare(b.key));
13
+ // Dedupe by key. A well-behaved manifest never has duplicates, but a server
14
+ // bug here would otherwise emit duplicate object keys and break TS compile.
15
+ const seen = new Set();
16
+ const sorted = [...manifest]
17
+ .sort((a, b) => a.key.localeCompare(b.key))
18
+ .filter((e) => {
19
+ if (seen.has(e.key)) {
20
+ console.warn(`magicpixel: duplicate manifest key "${e.key}" — keeping first.`);
21
+ return false;
22
+ }
23
+ seen.add(e.key);
24
+ return true;
25
+ });
14
26
  const lines = [
15
- '// AUTOGENERATED by @magicpixel/cli. Do not edit.',
27
+ '// AUTOGENERATED by @magicpixelart/cli. Do not edit.',
16
28
  '// Re-runs of `magicpixel sync` overwrite this file.',
17
29
  '',
18
30
  ];
19
- for (const e of sorted) {
31
+ // Use positional identifiers (asset_0, asset_1, …) so keys that differ only
32
+ // by non-alphanumeric chars (e.g. `player/walk` vs `player_walk`) can't
33
+ // collide on the JS identifier and break the consumer's TS build.
34
+ sorted.forEach((e, i) => {
20
35
  const rel = e.folder ? `./${e.folder}/${e.slug}.png` : `./${e.slug}.png`;
21
- const safe = JSON.stringify(rel);
22
- lines.push(`import asset_${slugToIdent(e.key)} from ${safe};`);
23
- }
36
+ lines.push(`import asset_${i} from ${JSON.stringify(rel)};`);
37
+ });
24
38
  lines.push('');
25
39
  lines.push('export const MagicPixelAssets = {');
26
- for (const e of sorted) {
27
- lines.push(` ${JSON.stringify(e.key)}: asset_${slugToIdent(e.key)},`);
28
- }
40
+ sorted.forEach((e, i) => {
41
+ lines.push(` ${JSON.stringify(e.key)}: asset_${i},`);
42
+ });
29
43
  lines.push('} as const;');
30
44
  lines.push('');
31
45
  lines.push('export type MagicPixelAssetKey = keyof typeof MagicPixelAssets;');
@@ -34,6 +48,3 @@ export async function emitTypedIndex(outDir, manifest, cwd = process.cwd()) {
34
48
  await writeFile(path, lines.join('\n'), 'utf8');
35
49
  return path;
36
50
  }
37
- function slugToIdent(key) {
38
- return key.replace(/[^a-zA-Z0-9]/g, '_');
39
- }
@@ -0,0 +1,8 @@
1
+ import { createRequire } from 'node:module';
2
+ // Single source of truth: read version from package.json at runtime so
3
+ // `npm version <bump>` propagates everywhere without code edits.
4
+ // dist/version.js resolves `../package.json` → tools/cli/package.json.
5
+ const require = createRequire(import.meta.url);
6
+ const pkg = require('../package.json');
7
+ export const CLI_VERSION = pkg.version;
8
+ export const CLI_USER_AGENT = `@magicpixelart/cli/${CLI_VERSION} (node ${process.versions.node})`;
package/package.json CHANGED
@@ -1,16 +1,12 @@
1
1
  {
2
2
  "name": "@magicpixelart/cli",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Sync MagicPixel pixel-art assets to your local project.",
5
5
  "type": "module",
6
6
  "bin": {
7
- "magicpixel": "dist/index.js"
7
+ "magicpixel": "./dist/index.js"
8
8
  },
9
- "files": [
10
- "dist",
11
- "README.md",
12
- "LICENSE"
13
- ],
9
+ "files": ["dist", "README.md", "LICENSE"],
14
10
  "scripts": {
15
11
  "build": "tsc && node -e \"require('fs').chmodSync('dist/index.js', 0o755)\"",
16
12
  "dev": "tsx src/index.ts",
@@ -30,7 +26,7 @@
30
26
  "url": "git+https://github.com/handpoke/magicpixel-cli.git"
31
27
  },
32
28
  "bugs": {
33
- "url": "https://github.com/magicpixel/cli/issues"
29
+ "url": "https://github.com/handpoke/magicpixel-cli/issues"
34
30
  },
35
31
  "license": "MIT",
36
32
  "author": "MagicPixel",