@magicpixelart/cli 0.1.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.
- package/LICENSE +21 -0
- package/README.md +134 -0
- package/dist/api.js +103 -0
- package/dist/commands/add.js +12 -0
- package/dist/commands/init.js +127 -0
- package/dist/commands/list.js +23 -0
- package/dist/commands/remove.js +13 -0
- package/dist/commands/status.js +54 -0
- package/dist/commands/sync.js +302 -0
- package/dist/commands/whoami.js +25 -0
- package/dist/config.js +80 -0
- package/dist/index.js +69 -0
- package/dist/util/emitIndex.js +39 -0
- package/dist/util/hash.js +9 -0
- package/dist/util/limit.js +36 -0
- package/dist/util/paths.js +11 -0
- package/package.json +54 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 MagicPixel
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# @magicpixel/cli
|
|
2
|
+
|
|
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
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install --save-dev @magicpixel/cli
|
|
9
|
+
# or: pnpm add -D @magicpixel/cli
|
|
10
|
+
# or: bun add -d @magicpixel/cli
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Requires Node.js ≥ 18.
|
|
14
|
+
|
|
15
|
+
## Quickstart
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npx magicpixel init # interactive setup, detects your framework
|
|
19
|
+
export MAGICPIXEL_API_KEY=mp_live_...
|
|
20
|
+
npx magicpixel sync # downloads changed assets
|
|
21
|
+
npx magicpixel sync --watch # keeps assets fresh while you work
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Get an API key at [magicpixel.art/settings](https://magicpixel.art/settings) → API Keys. Each key is bound to one project.
|
|
25
|
+
|
|
26
|
+
## Typed asset index (default on)
|
|
27
|
+
|
|
28
|
+
If `emitIndex` is true in `magicpixel.json`, every sync writes `<outDir>/index.ts`:
|
|
29
|
+
|
|
30
|
+
```ts
|
|
31
|
+
import { MagicPixelAssets, type MagicPixelAssetKey } from '@/assets/magicpixel';
|
|
32
|
+
|
|
33
|
+
<img src={MagicPixelAssets['player/walk']} />
|
|
34
|
+
// Typo? TypeScript error. Vite/webpack hash the URL at build time.
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
No bundler config, no runtime, no extra package.
|
|
38
|
+
|
|
39
|
+
## Commands
|
|
40
|
+
|
|
41
|
+
| Command | What it does |
|
|
42
|
+
| --- | --- |
|
|
43
|
+
| `init [-y] [--force]` | Interactive config wizard. `-y` for CI. |
|
|
44
|
+
| `sync [...flags]` | Fetch manifest, diff against disk, download changed assets. |
|
|
45
|
+
| `add <glob>` / `remove <glob>` | Manage `include` patterns. |
|
|
46
|
+
| `list` | Print matching manifest as a table. |
|
|
47
|
+
| `status` | Config, last sync, diff vs remote. |
|
|
48
|
+
| `whoami` | Verify API key, report visible assets. |
|
|
49
|
+
|
|
50
|
+
### `sync` flags
|
|
51
|
+
|
|
52
|
+
| Flag | Meaning |
|
|
53
|
+
| --- | --- |
|
|
54
|
+
| `-w, --watch [seconds]` | Poll for changes (default 10s). Ideal during development. |
|
|
55
|
+
| `--prune` | Delete local files no longer in the manifest. |
|
|
56
|
+
| `--dry-run` | Print plan, write nothing. |
|
|
57
|
+
| `--full` | Ignore `lastSync`; re-fetch the full manifest. |
|
|
58
|
+
| `-c, --concurrency <n>` | Parallel downloads (1–16, default 6). |
|
|
59
|
+
| `-q, --quiet` | Minimal output (for CI). |
|
|
60
|
+
|
|
61
|
+
Sync is built to be cheap: a no-op run is one small manifest request, zero PNG bytes.
|
|
62
|
+
|
|
63
|
+
- sha256 diff against disk — matching files are never re-downloaded.
|
|
64
|
+
- `If-None-Match` ETag on per-asset GETs — server returns 304 if your local copy matches.
|
|
65
|
+
- `lastSync` in `.magicpixel/state.json` → incremental manifest fetch (`?since=…`).
|
|
66
|
+
- Atomic writes (`*.tmp` → `rename`) survive crashes mid-write.
|
|
67
|
+
- Retry with backoff on 429/5xx; `lastSync` only advances on a clean run.
|
|
68
|
+
|
|
69
|
+
## Config (`magicpixel.json`)
|
|
70
|
+
|
|
71
|
+
| Field | Type | Default | Meaning |
|
|
72
|
+
| ---------- | ---------- | ----------------------- | ------------------------------------------------ |
|
|
73
|
+
| `outDir` | `string` | framework-dependent | Where PNGs (and `index.ts`) are written. |
|
|
74
|
+
| `include` | `string[]` | `["**/*"]` | Globs (picomatch) matched against `folder/slug`. |
|
|
75
|
+
| `exclude` | `string[]` | `[]` | Globs to exclude. |
|
|
76
|
+
| `emitIndex`| `boolean` | `true` | Emit `<outDir>/index.ts` with typed asset map. |
|
|
77
|
+
| `endpoint` | `string?` | production URL | Override the API base (testing only). |
|
|
78
|
+
|
|
79
|
+
State (`.magicpixel/state.json`) tracks `lastSync`. Add `.magicpixel/` to `.gitignore` (init offers to do this).
|
|
80
|
+
|
|
81
|
+
## CI usage
|
|
82
|
+
|
|
83
|
+
```yaml
|
|
84
|
+
name: Sync MagicPixel
|
|
85
|
+
on:
|
|
86
|
+
workflow_dispatch:
|
|
87
|
+
schedule:
|
|
88
|
+
- cron: '0 * * * *'
|
|
89
|
+
jobs:
|
|
90
|
+
sync:
|
|
91
|
+
runs-on: ubuntu-latest
|
|
92
|
+
steps:
|
|
93
|
+
- uses: actions/checkout@v4
|
|
94
|
+
- uses: actions/setup-node@v4
|
|
95
|
+
with: { node-version: 20 }
|
|
96
|
+
- run: npx -y @magicpixel/cli sync --prune --quiet
|
|
97
|
+
env:
|
|
98
|
+
MAGICPIXEL_API_KEY: ${{ secrets.MAGICPIXEL_API_KEY }}
|
|
99
|
+
- uses: peter-evans/create-pull-request@v6
|
|
100
|
+
with:
|
|
101
|
+
commit-message: 'chore: sync MagicPixel assets'
|
|
102
|
+
branch: chore/magicpixel-sync
|
|
103
|
+
title: 'Sync MagicPixel assets'
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## `.gitattributes` (optional)
|
|
107
|
+
|
|
108
|
+
Keep PR diffs clean by marking generated PNGs:
|
|
109
|
+
|
|
110
|
+
```
|
|
111
|
+
src/assets/magicpixel/** binary linguist-generated=true
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## Troubleshooting
|
|
115
|
+
|
|
116
|
+
Every error message includes a `Fix:` block. Common ones:
|
|
117
|
+
|
|
118
|
+
| Symptom | Fix |
|
|
119
|
+
| --- | --- |
|
|
120
|
+
| `MAGICPIXEL_API_KEY is not set` | Export it from your shell. |
|
|
121
|
+
| `401` / `403` | Regenerate the key at magicpixel.art/settings. |
|
|
122
|
+
| `whoami` shows 0 assets | The key is bound to an empty project. Mint a key for the project that has art. |
|
|
123
|
+
| `index.ts` doesn't update | Run `sync --full` once; renames may take a full pass to propagate. |
|
|
124
|
+
| Files keep re-downloading | Your build system is rewriting PNGs. Sync into a dir your bundler reads but doesn't mutate. |
|
|
125
|
+
|
|
126
|
+
## What v1 does NOT do
|
|
127
|
+
|
|
128
|
+
- Layer JSON sync (PNG only)
|
|
129
|
+
- A runtime JS SDK (your bundler already handles PNG imports)
|
|
130
|
+
- Cross-user asset sharing
|
|
131
|
+
|
|
132
|
+
## License
|
|
133
|
+
|
|
134
|
+
MIT
|
package/dist/api.js
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { resolveEndpoint, getApiKey, CLI_USER_AGENT } from './config.js';
|
|
2
|
+
export class ApiError extends Error {
|
|
3
|
+
status;
|
|
4
|
+
constructor(status, message) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.status = status;
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
function friendly(status, body, context) {
|
|
10
|
+
if (status === 401 || status === 403) {
|
|
11
|
+
return (`${context}: ${status} — API key rejected.\n` +
|
|
12
|
+
` Fix: regenerate at https://magicpixel.art/settings and re-export MAGICPIXEL_API_KEY.`);
|
|
13
|
+
}
|
|
14
|
+
if (status === 404) {
|
|
15
|
+
return (`${context}: 404 — endpoint or asset not found.\n` +
|
|
16
|
+
` Fix: check the "endpoint" field in magicpixel.json, or remove it to use the default.`);
|
|
17
|
+
}
|
|
18
|
+
if (status === 429) {
|
|
19
|
+
return `${context}: 429 — rate limited. Retry shortly.`;
|
|
20
|
+
}
|
|
21
|
+
if (status >= 500) {
|
|
22
|
+
return `${context}: ${status} — MagicPixel server error. Retry shortly; status at https://magicpixel.art.`;
|
|
23
|
+
}
|
|
24
|
+
return `${context}: ${status} — ${body.slice(0, 200)}`;
|
|
25
|
+
}
|
|
26
|
+
function authHeaders() {
|
|
27
|
+
return {
|
|
28
|
+
Authorization: `Bearer ${getApiKey()}`,
|
|
29
|
+
'User-Agent': CLI_USER_AGENT,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
export async function fetchManifestPage(opts) {
|
|
33
|
+
const url = new URL(`${resolveEndpoint(opts.config)}/manifest`);
|
|
34
|
+
for (const inc of opts.config.include)
|
|
35
|
+
url.searchParams.append('include', inc);
|
|
36
|
+
for (const exc of opts.config.exclude)
|
|
37
|
+
url.searchParams.append('exclude', exc);
|
|
38
|
+
if (opts.since)
|
|
39
|
+
url.searchParams.set('since', opts.since);
|
|
40
|
+
if (opts.cursor)
|
|
41
|
+
url.searchParams.set('cursor', opts.cursor);
|
|
42
|
+
if (opts.limit)
|
|
43
|
+
url.searchParams.set('limit', String(opts.limit));
|
|
44
|
+
let res;
|
|
45
|
+
try {
|
|
46
|
+
res = await fetch(url, { headers: authHeaders() });
|
|
47
|
+
}
|
|
48
|
+
catch (e) {
|
|
49
|
+
throw new Error(`manifest: network error (${e.message}).\n` +
|
|
50
|
+
` Fix: check your internet connection and that ${url.host} is reachable.`);
|
|
51
|
+
}
|
|
52
|
+
if (!res.ok) {
|
|
53
|
+
throw new ApiError(res.status, friendly(res.status, await res.text(), 'manifest'));
|
|
54
|
+
}
|
|
55
|
+
return (await res.json());
|
|
56
|
+
}
|
|
57
|
+
export async function fetchAllManifest(config, since) {
|
|
58
|
+
const out = [];
|
|
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;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Download a single asset by key. Returns null on 304 (not modified).
|
|
69
|
+
* Retries transient failures (network errors, 429, 5xx) with backoff.
|
|
70
|
+
*/
|
|
71
|
+
export async function fetchAssetBytes(config, key, knownSha) {
|
|
72
|
+
const url = new URL(resolveEndpoint(config));
|
|
73
|
+
url.searchParams.set('key', key);
|
|
74
|
+
const headers = authHeaders();
|
|
75
|
+
if (knownSha)
|
|
76
|
+
headers['If-None-Match'] = `"${knownSha}"`;
|
|
77
|
+
const maxAttempts = 3;
|
|
78
|
+
let lastErr = null;
|
|
79
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
80
|
+
try {
|
|
81
|
+
const res = await fetch(url, { headers });
|
|
82
|
+
if (res.status === 304)
|
|
83
|
+
return null;
|
|
84
|
+
if (res.ok)
|
|
85
|
+
return new Uint8Array(await res.arrayBuffer());
|
|
86
|
+
if (res.status === 429 || res.status >= 500) {
|
|
87
|
+
lastErr = new ApiError(res.status, friendly(res.status, await res.text(), `download ${key}`));
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
throw new ApiError(res.status, friendly(res.status, await res.text(), `download ${key}`));
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
catch (e) {
|
|
94
|
+
lastErr = e;
|
|
95
|
+
if (e instanceof ApiError && e.status < 500 && e.status !== 429)
|
|
96
|
+
throw e;
|
|
97
|
+
}
|
|
98
|
+
if (attempt < maxAttempts) {
|
|
99
|
+
await new Promise((r) => setTimeout(r, 250 * 2 ** (attempt - 1)));
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
throw lastErr ?? new Error(`download ${key}: unknown error`);
|
|
103
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import kleur from 'kleur';
|
|
2
|
+
import { loadConfig, saveConfig } from '../config.js';
|
|
3
|
+
export async function addCommand(glob) {
|
|
4
|
+
const config = await loadConfig();
|
|
5
|
+
if (config.include.includes(glob)) {
|
|
6
|
+
console.log(kleur.yellow(`already included: ${glob}`));
|
|
7
|
+
return;
|
|
8
|
+
}
|
|
9
|
+
config.include.push(glob);
|
|
10
|
+
await saveConfig(config);
|
|
11
|
+
console.log(kleur.green(`✓ added include pattern: ${glob}`));
|
|
12
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import kleur from 'kleur';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { readFile, writeFile, appendFile } from 'node:fs/promises';
|
|
4
|
+
import { resolve } from 'node:path';
|
|
5
|
+
import { createInterface } from 'node:readline/promises';
|
|
6
|
+
import { stdin, stdout } from 'node:process';
|
|
7
|
+
import { configPath, defaultConfig, saveConfig } from '../config.js';
|
|
8
|
+
export async function initCommand(opts) {
|
|
9
|
+
const path = configPath();
|
|
10
|
+
if (existsSync(path) && !opts.force) {
|
|
11
|
+
console.log(kleur.yellow(`magicpixel.json already exists. Use --force to overwrite.`));
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
const interactive = !opts.yes && stdin.isTTY;
|
|
15
|
+
const config = { ...defaultConfig };
|
|
16
|
+
let addGitignore = true;
|
|
17
|
+
const framework = await detectFramework();
|
|
18
|
+
const suggestedOutDir = suggestOutDir(framework);
|
|
19
|
+
config.outDir = suggestedOutDir;
|
|
20
|
+
if (interactive) {
|
|
21
|
+
const rl = createInterface({ input: stdin, output: stdout });
|
|
22
|
+
try {
|
|
23
|
+
console.log(kleur.bold('Set up @magicpixel/cli'));
|
|
24
|
+
if (framework)
|
|
25
|
+
console.log(kleur.dim(` Detected: ${framework}`));
|
|
26
|
+
console.log();
|
|
27
|
+
config.outDir = (await rl.question(`${kleur.cyan('?')} Where should assets be written? ${kleur.dim(`(${suggestedOutDir})`)} `)).trim() || suggestedOutDir;
|
|
28
|
+
// Typed index only makes sense when the outDir is importable by a bundler.
|
|
29
|
+
// public/ and static/ are served as-is and cannot be imported.
|
|
30
|
+
if (isImportableOutDir(config.outDir)) {
|
|
31
|
+
const emitAns = (await rl.question(`${kleur.cyan('?')} Emit a typed index.ts for autocomplete? ${kleur.dim('(Y/n)')} `)).trim().toLowerCase();
|
|
32
|
+
config.emitIndex = emitAns !== 'n' && emitAns !== 'no';
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
config.emitIndex = false;
|
|
36
|
+
console.log(kleur.dim(` Skipping typed index (outDir "${config.outDir}" is served statically, not importable).`));
|
|
37
|
+
}
|
|
38
|
+
const giAns = (await rl.question(`${kleur.cyan('?')} Add .magicpixel/ to .gitignore? ${kleur.dim('(Y/n)')} `)).trim().toLowerCase();
|
|
39
|
+
addGitignore = giAns !== 'n' && giAns !== 'no';
|
|
40
|
+
}
|
|
41
|
+
finally {
|
|
42
|
+
rl.close();
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
config.emitIndex = isImportableOutDir(config.outDir);
|
|
47
|
+
}
|
|
48
|
+
await saveConfig(config);
|
|
49
|
+
console.log();
|
|
50
|
+
console.log(kleur.green('✓ wrote magicpixel.json'));
|
|
51
|
+
if (addGitignore) {
|
|
52
|
+
const added = await ensureGitignore();
|
|
53
|
+
if (added)
|
|
54
|
+
console.log(kleur.green('✓ added .magicpixel/ to .gitignore'));
|
|
55
|
+
}
|
|
56
|
+
console.log();
|
|
57
|
+
console.log(kleur.bold('Next:'));
|
|
58
|
+
console.log(` 1. Get an API key → ${kleur.cyan('https://magicpixel.art/settings')}`);
|
|
59
|
+
console.log(` 2. ${kleur.dim('export')} MAGICPIXEL_API_KEY=mp_live_...`);
|
|
60
|
+
console.log(` 3. ${kleur.dim('npx')} magicpixel sync`);
|
|
61
|
+
console.log();
|
|
62
|
+
console.log(kleur.dim('Tip: use `magicpixel sync --watch` while editing.'));
|
|
63
|
+
}
|
|
64
|
+
async function detectFramework() {
|
|
65
|
+
try {
|
|
66
|
+
const pkgPath = resolve(process.cwd(), 'package.json');
|
|
67
|
+
if (!existsSync(pkgPath))
|
|
68
|
+
return null;
|
|
69
|
+
const pkg = JSON.parse(await readFile(pkgPath, 'utf8'));
|
|
70
|
+
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
71
|
+
if (deps['next'])
|
|
72
|
+
return 'Next.js';
|
|
73
|
+
if (deps['vite'])
|
|
74
|
+
return 'Vite';
|
|
75
|
+
if (deps['@remix-run/react'])
|
|
76
|
+
return 'Remix';
|
|
77
|
+
if (deps['react-scripts'])
|
|
78
|
+
return 'Create React App';
|
|
79
|
+
if (deps['astro'])
|
|
80
|
+
return 'Astro';
|
|
81
|
+
if (deps['nuxt'])
|
|
82
|
+
return 'Nuxt';
|
|
83
|
+
if (deps['@sveltejs/kit'])
|
|
84
|
+
return 'SvelteKit';
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
function suggestOutDir(framework) {
|
|
92
|
+
switch (framework) {
|
|
93
|
+
case 'Next.js':
|
|
94
|
+
case 'Astro':
|
|
95
|
+
case 'Nuxt':
|
|
96
|
+
case 'Create React App':
|
|
97
|
+
return 'public/magicpixel';
|
|
98
|
+
case 'SvelteKit':
|
|
99
|
+
return 'static/magicpixel';
|
|
100
|
+
case 'Vite':
|
|
101
|
+
case 'Remix':
|
|
102
|
+
default:
|
|
103
|
+
return 'src/assets/magicpixel';
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* `public/` and `static/` are served as-is by frameworks and can't be
|
|
108
|
+
* `import`ed by a bundler — emitting a typed index there is dead code that
|
|
109
|
+
* also gets shipped as a static asset.
|
|
110
|
+
*/
|
|
111
|
+
function isImportableOutDir(outDir) {
|
|
112
|
+
const norm = outDir.replace(/\\/g, '/').replace(/^\.\//, '');
|
|
113
|
+
return !/^(public|static)(\/|$)/.test(norm);
|
|
114
|
+
}
|
|
115
|
+
async function ensureGitignore() {
|
|
116
|
+
const path = resolve(process.cwd(), '.gitignore');
|
|
117
|
+
const marker = '.magicpixel/';
|
|
118
|
+
if (existsSync(path)) {
|
|
119
|
+
const current = await readFile(path, 'utf8');
|
|
120
|
+
if (current.split('\n').some((l) => l.trim() === marker))
|
|
121
|
+
return false;
|
|
122
|
+
await appendFile(path, (current.endsWith('\n') ? '' : '\n') + `\n# MagicPixel CLI state\n${marker}\n`);
|
|
123
|
+
return true;
|
|
124
|
+
}
|
|
125
|
+
await writeFile(path, `# MagicPixel CLI state\n${marker}\n`, 'utf8');
|
|
126
|
+
return true;
|
|
127
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import kleur from 'kleur';
|
|
2
|
+
import { loadConfig } from '../config.js';
|
|
3
|
+
import { fetchAllManifest } from '../api.js';
|
|
4
|
+
export async function listCommand() {
|
|
5
|
+
const config = await loadConfig();
|
|
6
|
+
const entries = await fetchAllManifest(config);
|
|
7
|
+
if (entries.length === 0) {
|
|
8
|
+
console.log(kleur.yellow('No matching assets.'));
|
|
9
|
+
return;
|
|
10
|
+
}
|
|
11
|
+
const widthKey = Math.max(3, ...entries.map((e) => e.key.length));
|
|
12
|
+
const widthSize = 8;
|
|
13
|
+
console.log(kleur.bold('KEY'.padEnd(widthKey)) + ' ' +
|
|
14
|
+
kleur.bold('SIZE'.padEnd(widthSize)) + ' ' +
|
|
15
|
+
kleur.bold('SHA256'));
|
|
16
|
+
for (const e of entries) {
|
|
17
|
+
const size = e.size_bytes != null ? `${e.size_bytes}B` : '-';
|
|
18
|
+
const sha = e.sha256 ? e.sha256.slice(0, 12) : '-';
|
|
19
|
+
console.log(`${e.key.padEnd(widthKey)} ${size.padEnd(widthSize)} ${sha}`);
|
|
20
|
+
}
|
|
21
|
+
console.log();
|
|
22
|
+
console.log(kleur.dim(`${entries.length} asset${entries.length === 1 ? '' : 's'}`));
|
|
23
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import kleur from 'kleur';
|
|
2
|
+
import { loadConfig, saveConfig } from '../config.js';
|
|
3
|
+
export async function removeCommand(glob) {
|
|
4
|
+
const config = await loadConfig();
|
|
5
|
+
const before = config.include.length;
|
|
6
|
+
config.include = config.include.filter((g) => g !== glob);
|
|
7
|
+
if (config.include.length === before) {
|
|
8
|
+
console.log(kleur.yellow(`not found in include: ${glob}`));
|
|
9
|
+
return;
|
|
10
|
+
}
|
|
11
|
+
await saveConfig(config);
|
|
12
|
+
console.log(kleur.green(`✓ removed include pattern: ${glob}`));
|
|
13
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import kleur from 'kleur';
|
|
2
|
+
import { loadConfig, loadState, resolveEndpoint } from '../config.js';
|
|
3
|
+
import { fetchAllManifest } from '../api.js';
|
|
4
|
+
import { fileSha256 } from '../util/hash.js';
|
|
5
|
+
import { assetDiskPath } from '../util/paths.js';
|
|
6
|
+
export async function statusCommand() {
|
|
7
|
+
const config = await loadConfig();
|
|
8
|
+
const state = await loadState();
|
|
9
|
+
console.log(kleur.bold('Config'));
|
|
10
|
+
console.log(` outDir: ${config.outDir}`);
|
|
11
|
+
console.log(` include: ${config.include.join(', ') || '(none)'}`);
|
|
12
|
+
console.log(` exclude: ${config.exclude.join(', ') || '(none)'}`);
|
|
13
|
+
console.log(` endpoint: ${resolveEndpoint(config)}`);
|
|
14
|
+
console.log();
|
|
15
|
+
console.log(kleur.bold('State'));
|
|
16
|
+
console.log(` lastSync: ${state.lastSync ?? kleur.dim('never')}`);
|
|
17
|
+
console.log();
|
|
18
|
+
console.log(kleur.bold('Auth'));
|
|
19
|
+
const key = process.env.MAGICPIXEL_API_KEY;
|
|
20
|
+
if (!key) {
|
|
21
|
+
console.log(kleur.red(' MAGICPIXEL_API_KEY not set'));
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
console.log(` key: ${kleur.green('set')} (${maskKey(key)})`);
|
|
25
|
+
console.log();
|
|
26
|
+
console.log(kleur.bold('Diff vs remote'));
|
|
27
|
+
try {
|
|
28
|
+
const manifest = await fetchAllManifest(config);
|
|
29
|
+
let changed = 0;
|
|
30
|
+
let unchanged = 0;
|
|
31
|
+
let missing = 0;
|
|
32
|
+
for (const entry of manifest) {
|
|
33
|
+
const local = await fileSha256(assetDiskPath(config.outDir, entry));
|
|
34
|
+
if (!local)
|
|
35
|
+
missing++;
|
|
36
|
+
else if (entry.sha256 && entry.sha256 === local)
|
|
37
|
+
unchanged++;
|
|
38
|
+
else
|
|
39
|
+
changed++;
|
|
40
|
+
}
|
|
41
|
+
console.log(` remote assets: ${manifest.length}`);
|
|
42
|
+
console.log(` ${kleur.dim('=')} unchanged: ${unchanged}`);
|
|
43
|
+
console.log(` ${kleur.yellow('~')} changed: ${changed}`);
|
|
44
|
+
console.log(` ${kleur.green('+')} missing: ${missing}`);
|
|
45
|
+
}
|
|
46
|
+
catch (e) {
|
|
47
|
+
console.log(kleur.red(` manifest fetch failed: ${e.message}`));
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
function maskKey(k) {
|
|
51
|
+
if (k.length <= 12)
|
|
52
|
+
return '***';
|
|
53
|
+
return `${k.slice(0, 8)}…${k.slice(-4)}`;
|
|
54
|
+
}
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import kleur from 'kleur';
|
|
2
|
+
import ora from 'ora';
|
|
3
|
+
import { mkdir, writeFile, unlink, readdir, rm, rename } from 'node:fs/promises';
|
|
4
|
+
import { existsSync } from 'node:fs';
|
|
5
|
+
import { dirname, relative, resolve } from 'node:path';
|
|
6
|
+
import { loadConfig, loadState, saveState } from '../config.js';
|
|
7
|
+
import { fetchAllManifest, fetchAssetBytes } from '../api.js';
|
|
8
|
+
import { fileSha256 } from '../util/hash.js';
|
|
9
|
+
import { assetDiskPath } from '../util/paths.js';
|
|
10
|
+
import { createLimit } from '../util/limit.js';
|
|
11
|
+
import { emitTypedIndex } from '../util/emitIndex.js';
|
|
12
|
+
export async function syncCommand(opts) {
|
|
13
|
+
if (opts.watch) {
|
|
14
|
+
await watchLoop(opts);
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
await runOnce(opts);
|
|
18
|
+
}
|
|
19
|
+
async function watchLoop(opts) {
|
|
20
|
+
const intervalSec = typeof opts.watch === 'string' ? Math.max(2, parseInt(opts.watch, 10) || 10) : 10;
|
|
21
|
+
console.log(kleur.dim(`[watch] polling every ${intervalSec}s. Ctrl+C to stop.`));
|
|
22
|
+
// Run once eagerly so first sync isn't delayed by the interval.
|
|
23
|
+
let inFlight = false;
|
|
24
|
+
const tick = async () => {
|
|
25
|
+
if (inFlight)
|
|
26
|
+
return;
|
|
27
|
+
inFlight = true;
|
|
28
|
+
try {
|
|
29
|
+
const r = await runOnce({ ...opts, quiet: true, watch: false });
|
|
30
|
+
if (r.downloaded > 0 || r.pruned > 0) {
|
|
31
|
+
// \x1b[2K\r clears any residual "no changes" line from the previous tick.
|
|
32
|
+
process.stdout.write('\x1b[2K\r');
|
|
33
|
+
console.log(`${kleur.dim(timestamp())} ${kleur.green('✓')} ${r.downloaded} downloaded` +
|
|
34
|
+
(r.pruned ? `, ${r.pruned} pruned` : '') +
|
|
35
|
+
(r.failed ? kleur.yellow(`, ${r.failed} failed`) : ''));
|
|
36
|
+
}
|
|
37
|
+
else {
|
|
38
|
+
process.stdout.write(`\r\x1b[2K${kleur.dim(`${timestamp()} no changes (${r.unchanged} unchanged)`)}`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
catch (e) {
|
|
42
|
+
console.log(`\n${kleur.dim(timestamp())} ${kleur.red('!')} ${e.message.split('\n')[0]}`);
|
|
43
|
+
}
|
|
44
|
+
finally {
|
|
45
|
+
inFlight = false;
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
await tick();
|
|
49
|
+
// eslint-disable-next-line no-constant-condition
|
|
50
|
+
while (true) {
|
|
51
|
+
await new Promise((r) => setTimeout(r, intervalSec * 1000));
|
|
52
|
+
await tick();
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
async function runOnce(opts) {
|
|
56
|
+
const config = await loadConfig();
|
|
57
|
+
const state = await loadState();
|
|
58
|
+
const startedAt = new Date().toISOString();
|
|
59
|
+
const concurrency = Math.max(1, Math.min(opts.concurrency ?? 6, 16));
|
|
60
|
+
const quiet = !!opts.quiet;
|
|
61
|
+
// Incremental: only fetch what changed since last successful sync.
|
|
62
|
+
// --full forces a from-scratch fetch.
|
|
63
|
+
const since = opts.full ? undefined : state.lastSync;
|
|
64
|
+
const spinner = quiet
|
|
65
|
+
? null
|
|
66
|
+
: ora(since ? `Fetching manifest since ${humanTime(since)}…` : 'Fetching manifest…').start();
|
|
67
|
+
let manifest;
|
|
68
|
+
try {
|
|
69
|
+
manifest = await fetchAllManifest(config, since);
|
|
70
|
+
spinner?.succeed(`Manifest: ${manifest.length} asset${manifest.length === 1 ? '' : 's'}${since ? kleur.dim(' (incremental)') : ''}`);
|
|
71
|
+
}
|
|
72
|
+
catch (e) {
|
|
73
|
+
spinner?.fail('Manifest fetch failed');
|
|
74
|
+
throw e;
|
|
75
|
+
}
|
|
76
|
+
// Diff
|
|
77
|
+
const toDownload = [];
|
|
78
|
+
const skipped = [];
|
|
79
|
+
let bytesSaved = 0;
|
|
80
|
+
for (const entry of manifest) {
|
|
81
|
+
const diskPath = assetDiskPath(config.outDir, entry);
|
|
82
|
+
const localSha = await fileSha256(diskPath);
|
|
83
|
+
if (entry.sha256 && localSha && entry.sha256 === localSha) {
|
|
84
|
+
skipped.push(entry);
|
|
85
|
+
if (entry.size_bytes)
|
|
86
|
+
bytesSaved += entry.size_bytes;
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
toDownload.push(entry);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
// Orphan detection only when we have the full picture.
|
|
93
|
+
let orphans = [];
|
|
94
|
+
if (!since) {
|
|
95
|
+
const remoteDiskPaths = new Set(manifest.map((e) => assetDiskPath(config.outDir, e)));
|
|
96
|
+
orphans = (await findLocalPngs(resolve(process.cwd(), config.outDir)))
|
|
97
|
+
.filter((p) => !remoteDiskPaths.has(p));
|
|
98
|
+
}
|
|
99
|
+
if (!quiet) {
|
|
100
|
+
console.log();
|
|
101
|
+
console.log(kleur.bold('Plan:'));
|
|
102
|
+
console.log(` ${kleur.green('+')} download ${toDownload.length}`);
|
|
103
|
+
console.log(` ${kleur.dim('=')} unchanged ${skipped.length}${bytesSaved ? kleur.dim(` (~${formatBytes(bytesSaved)} saved)`) : ''}`);
|
|
104
|
+
if (since) {
|
|
105
|
+
console.log(` ${kleur.dim('orphan check skipped (incremental — use --full)')}`);
|
|
106
|
+
}
|
|
107
|
+
else if (orphans.length > 0) {
|
|
108
|
+
const verb = opts.prune ? kleur.red('delete') : kleur.yellow('keep (orphan)');
|
|
109
|
+
console.log(` ${verb} ${orphans.length}`);
|
|
110
|
+
}
|
|
111
|
+
console.log();
|
|
112
|
+
}
|
|
113
|
+
if (opts.dryRun) {
|
|
114
|
+
if (!quiet) {
|
|
115
|
+
console.log(kleur.dim('--dry-run: no files written.'));
|
|
116
|
+
if (orphans.length > 0)
|
|
117
|
+
printOrphans(orphans);
|
|
118
|
+
}
|
|
119
|
+
return { downloaded: 0, unchanged: skipped.length, pruned: 0, failed: 0, bytesIn: 0, bytesSaved };
|
|
120
|
+
}
|
|
121
|
+
// Download with progress bar
|
|
122
|
+
const result = {
|
|
123
|
+
downloaded: 0,
|
|
124
|
+
unchanged: skipped.length,
|
|
125
|
+
pruned: 0,
|
|
126
|
+
failed: 0,
|
|
127
|
+
bytesIn: 0,
|
|
128
|
+
bytesSaved,
|
|
129
|
+
};
|
|
130
|
+
let progress = null;
|
|
131
|
+
if (!quiet && toDownload.length > 0) {
|
|
132
|
+
progress = ora({ text: progressText(0, toDownload.length, 0), spinner: 'dots' }).start();
|
|
133
|
+
}
|
|
134
|
+
const run = createLimit(concurrency);
|
|
135
|
+
let done = 0;
|
|
136
|
+
await Promise.all(toDownload.map((entry) => run(async () => {
|
|
137
|
+
const diskPath = assetDiskPath(config.outDir, entry);
|
|
138
|
+
try {
|
|
139
|
+
const localSha = await fileSha256(diskPath);
|
|
140
|
+
const bytes = await fetchAssetBytes(config, entry.key, localSha);
|
|
141
|
+
if (bytes === null) {
|
|
142
|
+
result.unchanged++;
|
|
143
|
+
}
|
|
144
|
+
else {
|
|
145
|
+
await mkdir(dirname(diskPath), { recursive: true });
|
|
146
|
+
const tmp = `${diskPath}.${process.pid}.tmp`;
|
|
147
|
+
await writeFile(tmp, bytes);
|
|
148
|
+
await rename(tmp, diskPath);
|
|
149
|
+
result.downloaded++;
|
|
150
|
+
result.bytesIn += bytes.byteLength;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
catch (e) {
|
|
154
|
+
result.failed++;
|
|
155
|
+
progress?.stop();
|
|
156
|
+
console.log(` ${kleur.red('!')} ${entry.key}: ${e.message.split('\n')[0]}`);
|
|
157
|
+
progress?.start();
|
|
158
|
+
}
|
|
159
|
+
finally {
|
|
160
|
+
done++;
|
|
161
|
+
if (progress)
|
|
162
|
+
progress.text = progressText(done, toDownload.length, result.bytesIn);
|
|
163
|
+
}
|
|
164
|
+
})));
|
|
165
|
+
if (progress) {
|
|
166
|
+
if (result.failed === 0)
|
|
167
|
+
progress.succeed(`Downloaded ${result.downloaded} (${formatBytes(result.bytesIn)})`);
|
|
168
|
+
else
|
|
169
|
+
progress.warn(`Downloaded ${result.downloaded}, failed ${result.failed} (${formatBytes(result.bytesIn)})`);
|
|
170
|
+
}
|
|
171
|
+
// Prune
|
|
172
|
+
if (opts.prune && orphans.length > 0) {
|
|
173
|
+
for (const p of orphans) {
|
|
174
|
+
await unlink(p);
|
|
175
|
+
if (!quiet)
|
|
176
|
+
console.log(` ${kleur.red('-')} ${relative(process.cwd(), p)}`);
|
|
177
|
+
}
|
|
178
|
+
await pruneEmptyDirs(resolve(process.cwd(), config.outDir));
|
|
179
|
+
result.pruned = orphans.length;
|
|
180
|
+
}
|
|
181
|
+
else if (!quiet && orphans.length > 0) {
|
|
182
|
+
printOrphans(orphans);
|
|
183
|
+
}
|
|
184
|
+
// Emit typed index if enabled AND any file change happened (or first sync).
|
|
185
|
+
// In incremental mode `manifest` only contains changed entries, so we must
|
|
186
|
+
// re-fetch the full manifest (cheap — JSON only) to write a complete index.
|
|
187
|
+
if (config.emitIndex && (result.downloaded > 0 || result.pruned > 0 || !since)) {
|
|
188
|
+
try {
|
|
189
|
+
const fullManifest = since ? await fetchAllManifest(config) : manifest;
|
|
190
|
+
const indexPath = await emitTypedIndex(config.outDir, fullManifest);
|
|
191
|
+
if (!quiet)
|
|
192
|
+
console.log(kleur.dim(` index → ${relative(process.cwd(), indexPath)}`));
|
|
193
|
+
}
|
|
194
|
+
catch (e) {
|
|
195
|
+
if (!quiet)
|
|
196
|
+
console.log(kleur.yellow(` index emit failed: ${e.message}`));
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
if (result.failed === 0) {
|
|
200
|
+
await saveState({ lastSync: startedAt });
|
|
201
|
+
}
|
|
202
|
+
else if (!quiet) {
|
|
203
|
+
console.log(kleur.yellow(`\n${result.failed} download${result.failed === 1 ? '' : 's'} failed — lastSync not advanced. Re-run to retry.`));
|
|
204
|
+
}
|
|
205
|
+
if (!quiet) {
|
|
206
|
+
console.log();
|
|
207
|
+
const summary = `downloaded ${result.downloaded}, unchanged ${result.unchanged}` +
|
|
208
|
+
(opts.prune ? `, pruned ${result.pruned}` : '') +
|
|
209
|
+
(result.failed ? `, failed ${result.failed}` : '');
|
|
210
|
+
console.log(result.failed ? kleur.yellow(`done with errors. ${summary}`) : kleur.green(`✓ done. ${summary}`));
|
|
211
|
+
}
|
|
212
|
+
if (result.failed)
|
|
213
|
+
process.exitCode = 1;
|
|
214
|
+
return result;
|
|
215
|
+
}
|
|
216
|
+
function progressText(done, total, bytes) {
|
|
217
|
+
const pct = total === 0 ? 100 : Math.round((done / total) * 100);
|
|
218
|
+
const barWidth = 24;
|
|
219
|
+
const filled = Math.round((pct / 100) * barWidth);
|
|
220
|
+
const bar = '█'.repeat(filled) + '░'.repeat(barWidth - filled);
|
|
221
|
+
return `${bar} ${done}/${total} ${kleur.dim(formatBytes(bytes))}`;
|
|
222
|
+
}
|
|
223
|
+
function formatBytes(n) {
|
|
224
|
+
if (n < 1024)
|
|
225
|
+
return `${n} B`;
|
|
226
|
+
if (n < 1024 * 1024)
|
|
227
|
+
return `${(n / 1024).toFixed(1)} KB`;
|
|
228
|
+
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
|
229
|
+
}
|
|
230
|
+
function timestamp() {
|
|
231
|
+
const d = new Date();
|
|
232
|
+
return `[${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}:${String(d.getSeconds()).padStart(2, '0')}]`;
|
|
233
|
+
}
|
|
234
|
+
function humanTime(iso) {
|
|
235
|
+
try {
|
|
236
|
+
const d = new Date(iso);
|
|
237
|
+
return d.toLocaleString();
|
|
238
|
+
}
|
|
239
|
+
catch {
|
|
240
|
+
return iso;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
function printOrphans(orphans) {
|
|
244
|
+
console.log();
|
|
245
|
+
console.log(kleur.yellow(`Orphaned local files (not in manifest):`));
|
|
246
|
+
for (const p of orphans) {
|
|
247
|
+
console.log(` ${kleur.dim('?')} ${relative(process.cwd(), p)}`);
|
|
248
|
+
}
|
|
249
|
+
console.log(kleur.dim(' Run with --prune to delete them.'));
|
|
250
|
+
}
|
|
251
|
+
async function findLocalPngs(dir) {
|
|
252
|
+
if (!existsSync(dir))
|
|
253
|
+
return [];
|
|
254
|
+
const out = [];
|
|
255
|
+
async function walk(d) {
|
|
256
|
+
const entries = await readdir(d, { withFileTypes: true });
|
|
257
|
+
for (const ent of entries) {
|
|
258
|
+
const full = resolve(d, ent.name);
|
|
259
|
+
if (ent.isDirectory()) {
|
|
260
|
+
await walk(full);
|
|
261
|
+
}
|
|
262
|
+
else if (ent.isFile() && ent.name.toLowerCase().endsWith('.png')) {
|
|
263
|
+
out.push(full);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
await walk(dir);
|
|
268
|
+
return out;
|
|
269
|
+
}
|
|
270
|
+
async function pruneEmptyDirs(root) {
|
|
271
|
+
if (!existsSync(root))
|
|
272
|
+
return;
|
|
273
|
+
async function walk(d) {
|
|
274
|
+
const entries = await readdir(d, { withFileTypes: true });
|
|
275
|
+
let isEmpty = true;
|
|
276
|
+
for (const ent of entries) {
|
|
277
|
+
const full = resolve(d, ent.name);
|
|
278
|
+
if (ent.isDirectory()) {
|
|
279
|
+
const childEmpty = await walk(full);
|
|
280
|
+
if (childEmpty) {
|
|
281
|
+
await rm(full, { recursive: true, force: true });
|
|
282
|
+
}
|
|
283
|
+
else {
|
|
284
|
+
isEmpty = false;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
else {
|
|
288
|
+
isEmpty = false;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
return isEmpty;
|
|
292
|
+
}
|
|
293
|
+
const entries = await readdir(root, { withFileTypes: true });
|
|
294
|
+
for (const ent of entries) {
|
|
295
|
+
if (ent.isDirectory()) {
|
|
296
|
+
const full = resolve(root, ent.name);
|
|
297
|
+
const empty = await walk(full);
|
|
298
|
+
if (empty)
|
|
299
|
+
await rm(full, { recursive: true, force: true });
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import kleur from 'kleur';
|
|
2
|
+
import { loadConfig, resolveEndpoint, getApiKey } from '../config.js';
|
|
3
|
+
export async function whoamiCommand() {
|
|
4
|
+
const config = await loadConfig();
|
|
5
|
+
const url = new URL(`${resolveEndpoint(config)}/manifest`);
|
|
6
|
+
url.searchParams.set('limit', '1');
|
|
7
|
+
const res = await fetch(url, {
|
|
8
|
+
headers: { Authorization: `Bearer ${getApiKey()}` },
|
|
9
|
+
});
|
|
10
|
+
if (res.status === 401 || res.status === 403) {
|
|
11
|
+
console.log(kleur.red('✗ API key rejected (401/403).'));
|
|
12
|
+
console.log(kleur.dim(' Generate a new key at MagicPixel → Settings → API Keys.'));
|
|
13
|
+
process.exitCode = 1;
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
if (!res.ok) {
|
|
17
|
+
console.log(kleur.red(`✗ ${res.status}: ${await res.text()}`));
|
|
18
|
+
process.exitCode = 1;
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
const body = (await res.json());
|
|
22
|
+
console.log(kleur.green('✓ key valid'));
|
|
23
|
+
console.log(` endpoint: ${resolveEndpoint(config)}`);
|
|
24
|
+
console.log(` visible: ${body.count > 0 ? `${body.count}+ asset${body.count === 1 ? '' : 's'} (first: ${body.items[0]?.key ?? '-'})` : kleur.yellow('0 assets — is the key bound to a project with content?')}`);
|
|
25
|
+
}
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { dirname, resolve } from 'node:path';
|
|
4
|
+
const CONFIG_FILENAME = 'magicpixel.json';
|
|
5
|
+
const STATE_DIR = '.magicpixel';
|
|
6
|
+
const STATE_FILENAME = 'state.json';
|
|
7
|
+
const DEFAULT_ENDPOINT = 'https://sddsilidjhvtvejzvolx.supabase.co/functions/v1/integration-assets';
|
|
8
|
+
export const defaultConfig = {
|
|
9
|
+
outDir: 'src/assets/magicpixel',
|
|
10
|
+
include: ['**/*'],
|
|
11
|
+
exclude: [],
|
|
12
|
+
emitIndex: true,
|
|
13
|
+
};
|
|
14
|
+
export function configPath(cwd = process.cwd()) {
|
|
15
|
+
return resolve(cwd, CONFIG_FILENAME);
|
|
16
|
+
}
|
|
17
|
+
export function statePath(cwd = process.cwd()) {
|
|
18
|
+
return resolve(cwd, STATE_DIR, STATE_FILENAME);
|
|
19
|
+
}
|
|
20
|
+
export async function loadConfig(cwd = process.cwd()) {
|
|
21
|
+
const path = configPath(cwd);
|
|
22
|
+
if (!existsSync(path)) {
|
|
23
|
+
throw new Error(`No ${CONFIG_FILENAME} found in ${cwd}.\n` +
|
|
24
|
+
` Fix: run \`npx magicpixel init\` to create one.`);
|
|
25
|
+
}
|
|
26
|
+
const raw = await readFile(path, 'utf8');
|
|
27
|
+
let parsed;
|
|
28
|
+
try {
|
|
29
|
+
parsed = JSON.parse(raw);
|
|
30
|
+
}
|
|
31
|
+
catch (e) {
|
|
32
|
+
throw new Error(`${CONFIG_FILENAME} is not valid JSON: ${e.message}\n` +
|
|
33
|
+
` Fix: open the file and check for trailing commas or quotes.`);
|
|
34
|
+
}
|
|
35
|
+
return {
|
|
36
|
+
outDir: parsed.outDir ?? defaultConfig.outDir,
|
|
37
|
+
include: parsed.include ?? defaultConfig.include,
|
|
38
|
+
exclude: parsed.exclude ?? defaultConfig.exclude,
|
|
39
|
+
endpoint: parsed.endpoint,
|
|
40
|
+
emitIndex: parsed.emitIndex ?? defaultConfig.emitIndex,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
export async function saveConfig(config, cwd = process.cwd()) {
|
|
44
|
+
await writeFile(configPath(cwd), JSON.stringify(config, null, 2) + '\n', 'utf8');
|
|
45
|
+
}
|
|
46
|
+
export async function loadState(cwd = process.cwd()) {
|
|
47
|
+
const path = statePath(cwd);
|
|
48
|
+
if (!existsSync(path))
|
|
49
|
+
return {};
|
|
50
|
+
try {
|
|
51
|
+
return JSON.parse(await readFile(path, 'utf8'));
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return {};
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
export async function saveState(state, cwd = process.cwd()) {
|
|
58
|
+
const path = statePath(cwd);
|
|
59
|
+
await mkdir(dirname(path), { recursive: true });
|
|
60
|
+
await writeFile(path, JSON.stringify(state, null, 2) + '\n', 'utf8');
|
|
61
|
+
}
|
|
62
|
+
export function resolveEndpoint(config) {
|
|
63
|
+
return config.endpoint ?? DEFAULT_ENDPOINT;
|
|
64
|
+
}
|
|
65
|
+
export function getApiKey() {
|
|
66
|
+
const key = process.env.MAGICPIXEL_API_KEY;
|
|
67
|
+
if (!key) {
|
|
68
|
+
throw new Error('MAGICPIXEL_API_KEY is not set.\n' +
|
|
69
|
+
' Fix:\n' +
|
|
70
|
+
' 1. Get a key at https://magicpixel.art/settings (API Keys).\n' +
|
|
71
|
+
' 2. export MAGICPIXEL_API_KEY=mp_live_...\n' +
|
|
72
|
+
' 3. Re-run the command.');
|
|
73
|
+
}
|
|
74
|
+
if (!/^mp_(live|test)_/.test(key)) {
|
|
75
|
+
throw new Error(`MAGICPIXEL_API_KEY does not look right (expected to start with "mp_live_").\n` +
|
|
76
|
+
` Fix: re-copy the key from https://magicpixel.art/settings.`);
|
|
77
|
+
}
|
|
78
|
+
return key;
|
|
79
|
+
}
|
|
80
|
+
export const CLI_USER_AGENT = `@magicpixel/cli/0.1.0 (node ${process.versions.node})`;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from 'commander';
|
|
3
|
+
import kleur from 'kleur';
|
|
4
|
+
import { initCommand } from './commands/init.js';
|
|
5
|
+
import { syncCommand } from './commands/sync.js';
|
|
6
|
+
import { addCommand } from './commands/add.js';
|
|
7
|
+
import { removeCommand } from './commands/remove.js';
|
|
8
|
+
import { listCommand } from './commands/list.js';
|
|
9
|
+
import { statusCommand } from './commands/status.js';
|
|
10
|
+
import { whoamiCommand } from './commands/whoami.js';
|
|
11
|
+
// Node version guard
|
|
12
|
+
const major = Number(process.versions.node.split('.')[0]);
|
|
13
|
+
if (major < 18) {
|
|
14
|
+
console.error(kleur.red(`magicpixel requires Node.js >= 18 (you have ${process.versions.node}).`));
|
|
15
|
+
process.exit(1);
|
|
16
|
+
}
|
|
17
|
+
const program = new Command();
|
|
18
|
+
program
|
|
19
|
+
.name('magicpixel')
|
|
20
|
+
.description('Sync MagicPixel pixel-art assets to your local project')
|
|
21
|
+
.version('0.1.0');
|
|
22
|
+
const wrap = (fn) => async (...args) => {
|
|
23
|
+
try {
|
|
24
|
+
await fn(...args);
|
|
25
|
+
}
|
|
26
|
+
catch (e) {
|
|
27
|
+
const msg = e.message ?? String(e);
|
|
28
|
+
// Multi-line messages are already formatted with "Fix:" hints — print as-is.
|
|
29
|
+
console.error(kleur.red(msg));
|
|
30
|
+
process.exit(1);
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
program
|
|
34
|
+
.command('init')
|
|
35
|
+
.description('Create magicpixel.json (interactive)')
|
|
36
|
+
.option('--force', 'Overwrite existing config')
|
|
37
|
+
.option('-y, --yes', 'Skip prompts, use defaults (CI-friendly)')
|
|
38
|
+
.action(wrap(async (opts) => initCommand(opts)));
|
|
39
|
+
program
|
|
40
|
+
.command('sync')
|
|
41
|
+
.description('Download changed assets from MagicPixel')
|
|
42
|
+
.option('--prune', 'Delete local files no longer in the manifest')
|
|
43
|
+
.option('--dry-run', 'Print the plan without writing files')
|
|
44
|
+
.option('--full', 'Ignore lastSync state; re-fetch the full manifest')
|
|
45
|
+
.option('-w, --watch [seconds]', 'Poll for changes (default 10s)', (v) => v ?? true)
|
|
46
|
+
.option('-q, --quiet', 'Minimal output (for CI)')
|
|
47
|
+
.option('-c, --concurrency <n>', 'Parallel downloads (1–16, default 6)', (v) => parseInt(v, 10))
|
|
48
|
+
.action(wrap(async (opts) => syncCommand(opts)));
|
|
49
|
+
program
|
|
50
|
+
.command('add <glob>')
|
|
51
|
+
.description('Append a glob pattern to include')
|
|
52
|
+
.action(wrap(async (glob) => addCommand(glob)));
|
|
53
|
+
program
|
|
54
|
+
.command('remove <glob>')
|
|
55
|
+
.description('Remove a glob pattern from include')
|
|
56
|
+
.action(wrap(async (glob) => removeCommand(glob)));
|
|
57
|
+
program
|
|
58
|
+
.command('list')
|
|
59
|
+
.description('List the matching assets in the manifest')
|
|
60
|
+
.action(wrap(async () => listCommand()));
|
|
61
|
+
program
|
|
62
|
+
.command('status')
|
|
63
|
+
.description('Show config, last sync, and diff vs remote')
|
|
64
|
+
.action(wrap(async () => statusCommand()));
|
|
65
|
+
program
|
|
66
|
+
.command('whoami')
|
|
67
|
+
.description('Verify the API key and show what it can see')
|
|
68
|
+
.action(wrap(async () => whoamiCommand()));
|
|
69
|
+
program.parseAsync(process.argv);
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { writeFile } from 'node:fs/promises';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
/**
|
|
4
|
+
* Emit `<outDir>/index.ts` with a typed map of every asset key → URL.
|
|
5
|
+
*
|
|
6
|
+
* import { MagicPixelAssets } from '@/assets/magicpixel';
|
|
7
|
+
* const url = MagicPixelAssets['player/walk'];
|
|
8
|
+
*
|
|
9
|
+
* Vite, webpack, esbuild, Parcel, Rollup, etc. all hash these URLs at build
|
|
10
|
+
* time. No runtime, no bundler config.
|
|
11
|
+
*/
|
|
12
|
+
export async function emitTypedIndex(outDir, manifest, cwd = process.cwd()) {
|
|
13
|
+
const sorted = [...manifest].sort((a, b) => a.key.localeCompare(b.key));
|
|
14
|
+
const lines = [
|
|
15
|
+
'// AUTOGENERATED by @magicpixel/cli. Do not edit.',
|
|
16
|
+
'// Re-runs of `magicpixel sync` overwrite this file.',
|
|
17
|
+
'',
|
|
18
|
+
];
|
|
19
|
+
for (const e of sorted) {
|
|
20
|
+
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
|
+
}
|
|
24
|
+
lines.push('');
|
|
25
|
+
lines.push('export const MagicPixelAssets = {');
|
|
26
|
+
for (const e of sorted) {
|
|
27
|
+
lines.push(` ${JSON.stringify(e.key)}: asset_${slugToIdent(e.key)},`);
|
|
28
|
+
}
|
|
29
|
+
lines.push('} as const;');
|
|
30
|
+
lines.push('');
|
|
31
|
+
lines.push('export type MagicPixelAssetKey = keyof typeof MagicPixelAssets;');
|
|
32
|
+
lines.push('');
|
|
33
|
+
const path = resolve(cwd, outDir, 'index.ts');
|
|
34
|
+
await writeFile(path, lines.join('\n'), 'utf8');
|
|
35
|
+
return path;
|
|
36
|
+
}
|
|
37
|
+
function slugToIdent(key) {
|
|
38
|
+
return key.replace(/[^a-zA-Z0-9]/g, '_');
|
|
39
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { existsSync } from 'node:fs';
|
|
4
|
+
export async function fileSha256(path) {
|
|
5
|
+
if (!existsSync(path))
|
|
6
|
+
return null;
|
|
7
|
+
const buf = await readFile(path);
|
|
8
|
+
return createHash('sha256').update(buf).digest('hex');
|
|
9
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tiny concurrency limiter. No deps.
|
|
3
|
+
* const run = createLimit(6);
|
|
4
|
+
* await Promise.all(items.map(item => run(() => doWork(item))));
|
|
5
|
+
*/
|
|
6
|
+
export function createLimit(concurrency) {
|
|
7
|
+
let active = 0;
|
|
8
|
+
const queue = [];
|
|
9
|
+
const next = () => {
|
|
10
|
+
if (active >= concurrency)
|
|
11
|
+
return;
|
|
12
|
+
const job = queue.shift();
|
|
13
|
+
if (job)
|
|
14
|
+
job();
|
|
15
|
+
};
|
|
16
|
+
return function run(fn) {
|
|
17
|
+
return new Promise((resolve, reject) => {
|
|
18
|
+
const start = () => {
|
|
19
|
+
active++;
|
|
20
|
+
fn().then((v) => {
|
|
21
|
+
active--;
|
|
22
|
+
resolve(v);
|
|
23
|
+
next();
|
|
24
|
+
}, (e) => {
|
|
25
|
+
active--;
|
|
26
|
+
reject(e);
|
|
27
|
+
next();
|
|
28
|
+
});
|
|
29
|
+
};
|
|
30
|
+
if (active < concurrency)
|
|
31
|
+
start();
|
|
32
|
+
else
|
|
33
|
+
queue.push(start);
|
|
34
|
+
});
|
|
35
|
+
};
|
|
36
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { resolve } from 'node:path';
|
|
2
|
+
/**
|
|
3
|
+
* Disk path for a manifest entry under `outDir`. Mirrors the MagicPixel
|
|
4
|
+
* folder structure: `outDir/<folder>/<slug>.png`. Unsorted assets land at
|
|
5
|
+
* `outDir/<slug>.png`.
|
|
6
|
+
*/
|
|
7
|
+
export function assetDiskPath(outDir, entry, cwd = process.cwd()) {
|
|
8
|
+
const base = resolve(cwd, outDir);
|
|
9
|
+
const file = `${entry.slug}.png`;
|
|
10
|
+
return entry.folder ? resolve(base, entry.folder, file) : resolve(base, file);
|
|
11
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@magicpixelart/cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Sync MagicPixel pixel-art assets to your local project.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"magicpixel": "dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist",
|
|
11
|
+
"README.md",
|
|
12
|
+
"LICENSE"
|
|
13
|
+
],
|
|
14
|
+
"scripts": {
|
|
15
|
+
"build": "tsc && node -e \"require('fs').chmodSync('dist/index.js', 0o755)\"",
|
|
16
|
+
"dev": "tsx src/index.ts",
|
|
17
|
+
"prepublishOnly": "npm run build"
|
|
18
|
+
},
|
|
19
|
+
"keywords": [
|
|
20
|
+
"magicpixel",
|
|
21
|
+
"pixel-art",
|
|
22
|
+
"cli",
|
|
23
|
+
"asset-sync",
|
|
24
|
+
"gamedev",
|
|
25
|
+
"sprites"
|
|
26
|
+
],
|
|
27
|
+
"homepage": "https://magicpixel.art",
|
|
28
|
+
"repository": {
|
|
29
|
+
"type": "git",
|
|
30
|
+
"url": "git+https://github.com/handpoke/magicpixel-cli.git"
|
|
31
|
+
},
|
|
32
|
+
"bugs": {
|
|
33
|
+
"url": "https://github.com/magicpixel/cli/issues"
|
|
34
|
+
},
|
|
35
|
+
"license": "MIT",
|
|
36
|
+
"author": "MagicPixel",
|
|
37
|
+
"sideEffects": false,
|
|
38
|
+
"publishConfig": {
|
|
39
|
+
"access": "public"
|
|
40
|
+
},
|
|
41
|
+
"engines": {
|
|
42
|
+
"node": ">=18"
|
|
43
|
+
},
|
|
44
|
+
"dependencies": {
|
|
45
|
+
"commander": "^12.1.0",
|
|
46
|
+
"kleur": "^4.1.5",
|
|
47
|
+
"ora": "^8.1.0"
|
|
48
|
+
},
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"@types/node": "^20.14.0",
|
|
51
|
+
"tsx": "^4.19.0",
|
|
52
|
+
"typescript": "^5.5.0"
|
|
53
|
+
}
|
|
54
|
+
}
|