@magicpixelart/cli 0.1.2 → 0.3.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/README.md +54 -13
- package/dist/commands/doctor.js +92 -0
- package/dist/commands/init.js +60 -45
- package/dist/commands/login.js +98 -0
- package/dist/commands/logout.js +17 -0
- package/dist/commands/start.js +105 -0
- package/dist/commands/sync.js +243 -72
- package/dist/config.js +22 -6
- package/dist/index.js +23 -1
- package/dist/util/credentials.js +82 -0
- package/dist/util/emitIndex.js +141 -15
- package/dist/util/framework.js +56 -0
- package/dist/util/paths.js +17 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -14,36 +14,75 @@ Requires Node.js ≥ 18.
|
|
|
14
14
|
|
|
15
15
|
## Quickstart
|
|
16
16
|
|
|
17
|
+
One command, inside your project folder (the one with `package.json`):
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
npx magicpixel start
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
That walks you through everything: detects your framework, writes `magicpixel.json`, prompts for your API key (paste it from [magicpixel.art/settings](https://magicpixel.art/settings) → API Keys), pulls your sprites, and tells you how to keep them fresh.
|
|
24
|
+
|
|
25
|
+
When it finishes:
|
|
26
|
+
|
|
17
27
|
```bash
|
|
18
|
-
|
|
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
|
|
28
|
+
npm run magicpixel:watch # keeps sprites fresh while you edit them in MagicPixel
|
|
22
29
|
```
|
|
23
30
|
|
|
24
|
-
|
|
31
|
+
Production keys are prefixed `mp_live_`; sandbox keys (used internally for tests) are `mp_test_` and accepted the same way.
|
|
32
|
+
|
|
33
|
+
### Where your key lives
|
|
34
|
+
|
|
35
|
+
- **`MAGICPIXEL_API_KEY` env var** — highest priority, so CI keeps working with no code changes.
|
|
36
|
+
- **`.magicpixel/credentials`** — written by `magicpixel login`, mode `0600`, automatically gitignored. This is what `start` uses for local dev.
|
|
37
|
+
|
|
38
|
+
If `start` finds a `MAGICPIXEL_API_KEY` in your `.env` / `.env.local`, it offers to move it to the credentials file so it stops shipping in your bundler.
|
|
39
|
+
|
|
40
|
+
### Manual setup (advanced)
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
npx magicpixel init # writes magicpixel.json
|
|
44
|
+
npx magicpixel login # stores your key (or: export MAGICPIXEL_API_KEY=mp_live_...)
|
|
45
|
+
npx magicpixel sync # downloads changed assets
|
|
46
|
+
npx magicpixel sync --watch # keeps assets fresh while you work
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### Something not working?
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
npx magicpixel doctor
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Prints a one-screen diagnostic (CLI version, framework, outDir, key source, last sync, last error). Paste it to your AI agent — it's designed to be the only context they need.
|
|
25
56
|
|
|
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
57
|
|
|
28
58
|
## Typed asset index (default on)
|
|
29
59
|
|
|
30
60
|
If `emitIndex` is true in `magicpixel.json`, every sync writes `<outDir>/index.ts`:
|
|
31
61
|
|
|
32
62
|
```ts
|
|
33
|
-
import { MagicPixelAssets,
|
|
63
|
+
import { MagicPixelAssets, MagicPixelAssetsById } from '@/assets/magicpixel';
|
|
34
64
|
|
|
65
|
+
// Key-based: ergonomic, but breaks if you rename or move the asset in the editor.
|
|
35
66
|
<img src={MagicPixelAssets['player/walk']} />
|
|
36
|
-
|
|
67
|
+
|
|
68
|
+
// Id-based: survives every rename. Use for assets you don't want to chase imports for.
|
|
69
|
+
<img src={MagicPixelAssetsById['abc123…']} />
|
|
37
70
|
```
|
|
38
71
|
|
|
72
|
+
The index is built from what's actually on disk after each sync, so it can never disagree with the PNGs your bundler picks up. Renames are detected against the prior sync's snapshot — `sync` prints the old → new key plus the matching `MagicPixelAssetsById[...]` import hint so you can pick whichever you prefer.
|
|
73
|
+
|
|
39
74
|
No bundler config, no runtime, no extra package.
|
|
40
75
|
|
|
41
76
|
## Commands
|
|
42
77
|
|
|
43
78
|
| Command | What it does |
|
|
44
79
|
| --- | --- |
|
|
45
|
-
| `
|
|
46
|
-
| `
|
|
80
|
+
| `start [--force]` | One-command bootstrap. Init + login + first sync. The only command to tell new users to run. |
|
|
81
|
+
| `init [-y] [--force]` | Interactive config wizard. Offers a `magicpixel:watch` npm script. `-y` for CI. |
|
|
82
|
+
| `login [--key <key>]` | Save your API key to `.magicpixel/credentials` (mode `0600`). Validates against the server first. |
|
|
83
|
+
| `logout` | Remove the stored API key. |
|
|
84
|
+
| `doctor` | Print a one-screen diagnostic — paste it to your AI agent when something breaks. |
|
|
85
|
+
| `sync [...flags]` | Fetch manifest, diff against disk, download changed assets, prune orphans. |
|
|
47
86
|
| `add <glob>` / `remove <glob>` | Manage `include` patterns. |
|
|
48
87
|
| `list` | Print matching manifest as a table. |
|
|
49
88
|
| `status` | Config, last sync, diff vs remote. |
|
|
@@ -54,12 +93,14 @@ No bundler config, no runtime, no extra package.
|
|
|
54
93
|
| Flag | Meaning |
|
|
55
94
|
| --- | --- |
|
|
56
95
|
| `-w, --watch [seconds]` | Poll for changes (default 10s). Ideal during development. |
|
|
57
|
-
| `--prune` |
|
|
96
|
+
| `--no-prune` | Keep local files not in the manifest (default: prune them on full syncs). |
|
|
58
97
|
| `--dry-run` | Print plan, write nothing. |
|
|
59
98
|
| `--full` | Ignore `lastSync`; re-fetch the full manifest. |
|
|
60
99
|
| `-c, --concurrency <n>` | Parallel downloads (1–16, default 6). |
|
|
61
100
|
| `-q, --quiet` | Minimal output (for CI). |
|
|
62
101
|
|
|
102
|
+
Each successful sync prints a per-file change list (`+` added, `~` modified, `↪` renamed, `-` pruned) so you (and any AI agent reading the logs) know exactly what to wire up.
|
|
103
|
+
|
|
63
104
|
Sync is built to be cheap: a no-op run is one small manifest request, zero PNG bytes.
|
|
64
105
|
|
|
65
106
|
- sha256 diff against disk — matching files are never re-downloaded.
|
|
@@ -99,7 +140,7 @@ jobs:
|
|
|
99
140
|
- uses: actions/checkout@v4
|
|
100
141
|
- uses: actions/setup-node@v4
|
|
101
142
|
with: { node-version: 20 }
|
|
102
|
-
- run: npx -y @magicpixelart/cli sync --
|
|
143
|
+
- run: npx -y @magicpixelart/cli sync --quiet --full
|
|
103
144
|
env:
|
|
104
145
|
MAGICPIXEL_API_KEY: ${{ secrets.MAGICPIXEL_API_KEY }}
|
|
105
146
|
- uses: peter-evans/create-pull-request@v6
|
|
@@ -123,7 +164,7 @@ Every error message includes a `Fix:` block. Common ones:
|
|
|
123
164
|
|
|
124
165
|
| Symptom | Fix |
|
|
125
166
|
| --- | --- |
|
|
126
|
-
| `
|
|
167
|
+
| `No MagicPixel API key found` | Run `magicpixel login`, or `export MAGICPIXEL_API_KEY=mp_live_...`. |
|
|
127
168
|
| `401` / `403` | Regenerate the key at magicpixel.art/settings. |
|
|
128
169
|
| `whoami` shows 0 assets | The key is bound to an empty project. Mint a key for the project that has art. |
|
|
129
170
|
| `index.ts` doesn't update | Run `sync --full` once; renames may take a full pass to propagate. |
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import kleur from 'kleur';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { readFile } from 'node:fs/promises';
|
|
4
|
+
import { resolve, relative } from 'node:path';
|
|
5
|
+
import { loadConfig, loadState, resolveEndpoint, defaultConfig } from '../config.js';
|
|
6
|
+
import { describeKeySource } from '../util/credentials.js';
|
|
7
|
+
import { detectFramework, hasPackageJson } from '../util/framework.js';
|
|
8
|
+
import { CLI_VERSION } from '../version.js';
|
|
9
|
+
/**
|
|
10
|
+
* Single-page diagnostic that a non-technical user can paste to their AI agent
|
|
11
|
+
* when something breaks. Read-only — never mutates state or hits the network.
|
|
12
|
+
*/
|
|
13
|
+
export async function doctorCommand() {
|
|
14
|
+
const lines = [];
|
|
15
|
+
const push = (s = '') => lines.push(s);
|
|
16
|
+
push(kleur.bold('MagicPixel CLI — doctor'));
|
|
17
|
+
push(kleur.dim(` Paste this to your AI agent if something isn't working.`));
|
|
18
|
+
push();
|
|
19
|
+
push(`CLI version: ${CLI_VERSION}`);
|
|
20
|
+
push(`Node version: ${process.versions.node}`);
|
|
21
|
+
push(`Platform: ${process.platform}`);
|
|
22
|
+
push(`cwd: ${process.cwd()}`);
|
|
23
|
+
push();
|
|
24
|
+
// Framework + package.json
|
|
25
|
+
const framework = await detectFramework();
|
|
26
|
+
push(`package.json: ${hasPackageJson() ? 'found' : kleur.yellow('missing')}`);
|
|
27
|
+
push(`Framework detected: ${framework ?? kleur.dim('none')}`);
|
|
28
|
+
// Config
|
|
29
|
+
let config = null;
|
|
30
|
+
try {
|
|
31
|
+
config = await loadConfig();
|
|
32
|
+
push(`magicpixel.json: found`);
|
|
33
|
+
push(` outDir: ${config.outDir}`);
|
|
34
|
+
push(` emitIndex: ${config.emitIndex === false ? 'false' : 'true'}`);
|
|
35
|
+
push(` include: ${config.include.join(', ')}`);
|
|
36
|
+
if (config.exclude.length)
|
|
37
|
+
push(` exclude: ${config.exclude.join(', ')}`);
|
|
38
|
+
if (config.endpoint)
|
|
39
|
+
push(` endpoint: ${kleur.yellow(config.endpoint)} (custom)`);
|
|
40
|
+
}
|
|
41
|
+
catch (e) {
|
|
42
|
+
push(`magicpixel.json: ${kleur.yellow('missing')} (${e.message.split('\n')[0]})`);
|
|
43
|
+
}
|
|
44
|
+
push(`API endpoint: ${resolveEndpoint(config ?? { ...defaultConfig })}`);
|
|
45
|
+
// Key source
|
|
46
|
+
const keySource = describeKeySource();
|
|
47
|
+
const sourceLabel = keySource === 'env'
|
|
48
|
+
? 'environment variable (MAGICPIXEL_API_KEY)'
|
|
49
|
+
: keySource === 'credentials-file'
|
|
50
|
+
? '.magicpixel/credentials'
|
|
51
|
+
: kleur.yellow('none — run `magicpixel login`');
|
|
52
|
+
push(`API key source: ${sourceLabel}`);
|
|
53
|
+
// Watch script
|
|
54
|
+
let watchScript = null;
|
|
55
|
+
try {
|
|
56
|
+
const pkgPath = resolve(process.cwd(), 'package.json');
|
|
57
|
+
if (existsSync(pkgPath)) {
|
|
58
|
+
const pkg = JSON.parse(await readFile(pkgPath, 'utf8'));
|
|
59
|
+
watchScript = pkg.scripts?.['magicpixel:watch'] ?? null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
// ignore
|
|
64
|
+
}
|
|
65
|
+
push(`Watch script: ${watchScript ? `"${watchScript}"` : kleur.dim('not configured')}`);
|
|
66
|
+
// State
|
|
67
|
+
const state = await loadState();
|
|
68
|
+
push(`Last sync: ${state.lastSync ? new Date(state.lastSync).toLocaleString() : kleur.dim('never')}`);
|
|
69
|
+
push(`Assets in state: ${state.assets ? Object.keys(state.assets).length : 0}`);
|
|
70
|
+
if (state.lastError)
|
|
71
|
+
push(`Last error: ${kleur.yellow(state.lastError)}`);
|
|
72
|
+
// outDir contents (approximate)
|
|
73
|
+
if (config) {
|
|
74
|
+
const out = resolve(process.cwd(), config.outDir);
|
|
75
|
+
if (existsSync(out)) {
|
|
76
|
+
push(`outDir on disk: ${relative(process.cwd(), out)} (exists)`);
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
push(`outDir on disk: ${kleur.dim('not created yet')}`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
push();
|
|
83
|
+
push(kleur.dim('Next steps:'));
|
|
84
|
+
if (keySource === 'none')
|
|
85
|
+
push(kleur.dim(' • Run `magicpixel login` to store your API key.'));
|
|
86
|
+
if (!config)
|
|
87
|
+
push(kleur.dim(' • Run `magicpixel start` (or `magicpixel init`) to bootstrap.'));
|
|
88
|
+
if (!watchScript && hasPackageJson()) {
|
|
89
|
+
push(kleur.dim(' • Run `npm run magicpixel:watch` after init for live sync.'));
|
|
90
|
+
}
|
|
91
|
+
console.log(lines.join('\n'));
|
|
92
|
+
}
|
package/dist/commands/init.js
CHANGED
|
@@ -5,6 +5,9 @@ import { resolve } from 'node:path';
|
|
|
5
5
|
import { createInterface } from 'node:readline/promises';
|
|
6
6
|
import { stdin, stdout } from 'node:process';
|
|
7
7
|
import { configPath, defaultConfig, saveConfig } from '../config.js';
|
|
8
|
+
import { detectFramework, suggestOutDir, hasPackageJson } from '../util/framework.js';
|
|
9
|
+
const WATCH_SCRIPT_NAME = 'magicpixel:watch';
|
|
10
|
+
const WATCH_SCRIPT_CMD = 'magicpixel sync --watch';
|
|
8
11
|
export async function initCommand(opts) {
|
|
9
12
|
const path = configPath();
|
|
10
13
|
if (existsSync(path) && !opts.force) {
|
|
@@ -17,6 +20,9 @@ export async function initCommand(opts) {
|
|
|
17
20
|
const framework = await detectFramework();
|
|
18
21
|
const suggestedOutDir = suggestOutDir(framework);
|
|
19
22
|
config.outDir = suggestedOutDir;
|
|
23
|
+
const pkgExists = hasPackageJson();
|
|
24
|
+
const pkgPath = resolve(process.cwd(), 'package.json');
|
|
25
|
+
let addWatchScript = pkgExists;
|
|
20
26
|
if (interactive) {
|
|
21
27
|
const rl = createInterface({ input: stdin, output: stdout });
|
|
22
28
|
try {
|
|
@@ -37,6 +43,10 @@ export async function initCommand(opts) {
|
|
|
37
43
|
}
|
|
38
44
|
const giAns = (await rl.question(`${kleur.cyan('?')} Add .magicpixel/ to .gitignore? ${kleur.dim('(Y/n)')} `)).trim().toLowerCase();
|
|
39
45
|
addGitignore = giAns !== 'n' && giAns !== 'no';
|
|
46
|
+
if (pkgExists) {
|
|
47
|
+
const wAns = (await rl.question(`${kleur.cyan('?')} Add a "${WATCH_SCRIPT_NAME}" script to package.json? ${kleur.dim('(Y/n)')} `)).trim().toLowerCase();
|
|
48
|
+
addWatchScript = wAns !== 'n' && wAns !== 'no';
|
|
49
|
+
}
|
|
40
50
|
}
|
|
41
51
|
finally {
|
|
42
52
|
rl.close();
|
|
@@ -53,54 +63,59 @@ export async function initCommand(opts) {
|
|
|
53
63
|
if (added)
|
|
54
64
|
console.log(kleur.green('✓ added .magicpixel/ to .gitignore'));
|
|
55
65
|
}
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
|
|
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;
|
|
66
|
+
let watchScriptAdded = false;
|
|
67
|
+
if (addWatchScript && pkgExists) {
|
|
68
|
+
const r = await ensureWatchScript(pkgPath);
|
|
69
|
+
watchScriptAdded = r.added;
|
|
70
|
+
if (r.added)
|
|
71
|
+
console.log(kleur.green(`✓ added "${WATCH_SCRIPT_NAME}" script to package.json`));
|
|
72
|
+
else if (r.alreadyPresent)
|
|
73
|
+
console.log(kleur.dim(` "${WATCH_SCRIPT_NAME}" script already present in package.json`));
|
|
74
|
+
else if (r.error)
|
|
75
|
+
console.log(kleur.yellow(` could not patch package.json: ${r.error}`));
|
|
86
76
|
}
|
|
87
|
-
|
|
88
|
-
|
|
77
|
+
// Only print the "Next" block in fully-interactive init runs. `start`
|
|
78
|
+
// orchestrates its own onboarding summary, so doubling them up is noisy.
|
|
79
|
+
if (interactive) {
|
|
80
|
+
console.log();
|
|
81
|
+
console.log(kleur.bold('Next:'));
|
|
82
|
+
console.log(` 1. ${kleur.dim('magicpixel login')} ${kleur.dim('# paste your key from https://magicpixel.art/settings')}`);
|
|
83
|
+
console.log(` 2. ${kleur.dim('magicpixel sync')} ${kleur.dim('# downloads your assets')}`);
|
|
84
|
+
if (watchScriptAdded) {
|
|
85
|
+
console.log(` 3. ${kleur.dim('npm run')} ${WATCH_SCRIPT_NAME} ${kleur.dim('# keeps assets fresh while you edit')}`);
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
console.log(` 3. ${kleur.dim('npx')} magicpixel sync --watch ${kleur.dim('# keeps assets fresh while you edit')}`);
|
|
89
|
+
}
|
|
90
|
+
console.log();
|
|
89
91
|
}
|
|
90
92
|
}
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
return
|
|
93
|
+
/**
|
|
94
|
+
* Add a `magicpixel:watch` npm script. Preserves existing scripts and 2-space
|
|
95
|
+
* indentation; never overwrites a user-defined script of the same name.
|
|
96
|
+
*/
|
|
97
|
+
async function ensureWatchScript(pkgPath) {
|
|
98
|
+
try {
|
|
99
|
+
const raw = await readFile(pkgPath, 'utf8');
|
|
100
|
+
let pkg;
|
|
101
|
+
try {
|
|
102
|
+
pkg = JSON.parse(raw);
|
|
103
|
+
}
|
|
104
|
+
catch (e) {
|
|
105
|
+
return { added: false, alreadyPresent: false, error: `package.json is not valid JSON (${e.message})` };
|
|
106
|
+
}
|
|
107
|
+
const scripts = (pkg.scripts && typeof pkg.scripts === 'object' ? pkg.scripts : {});
|
|
108
|
+
if (typeof scripts[WATCH_SCRIPT_NAME] === 'string') {
|
|
109
|
+
return { added: false, alreadyPresent: true };
|
|
110
|
+
}
|
|
111
|
+
scripts[WATCH_SCRIPT_NAME] = WATCH_SCRIPT_CMD;
|
|
112
|
+
pkg.scripts = scripts;
|
|
113
|
+
const trailingNewline = raw.endsWith('\n') ? '\n' : '';
|
|
114
|
+
await writeFile(pkgPath, JSON.stringify(pkg, null, 2) + trailingNewline, 'utf8');
|
|
115
|
+
return { added: true, alreadyPresent: false };
|
|
116
|
+
}
|
|
117
|
+
catch (e) {
|
|
118
|
+
return { added: false, alreadyPresent: false, error: e.message };
|
|
104
119
|
}
|
|
105
120
|
}
|
|
106
121
|
/**
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import kleur from 'kleur';
|
|
2
|
+
import { createInterface } from 'node:readline/promises';
|
|
3
|
+
import { stdin, stdout } from 'node:process';
|
|
4
|
+
import { resolveEndpoint, loadConfig, defaultConfig } from '../config.js';
|
|
5
|
+
import { writeCredentials, credentialsPath } from '../util/credentials.js';
|
|
6
|
+
import { safeFetch } from '../util/security.js';
|
|
7
|
+
import { CLI_USER_AGENT } from '../version.js';
|
|
8
|
+
import { existsSync } from 'node:fs';
|
|
9
|
+
import { relative } from 'node:path';
|
|
10
|
+
const KEY_RE = /^mp_(live|test)_[a-f0-9]{64}$/;
|
|
11
|
+
export async function loginCommand(opts = {}) {
|
|
12
|
+
// Config is optional for `login` — a brand-new user runs it before `init`.
|
|
13
|
+
let config;
|
|
14
|
+
try {
|
|
15
|
+
config = await loadConfig();
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
config = { ...defaultConfig };
|
|
19
|
+
}
|
|
20
|
+
let key = opts.key?.trim();
|
|
21
|
+
if (!key) {
|
|
22
|
+
if (!stdin.isTTY) {
|
|
23
|
+
throw new Error('magicpixel login: no key provided and stdin is not a TTY.\n' +
|
|
24
|
+
' Fix: pass --key mp_live_… or run interactively.');
|
|
25
|
+
}
|
|
26
|
+
key = await promptForKey(3, config);
|
|
27
|
+
}
|
|
28
|
+
else {
|
|
29
|
+
if (!KEY_RE.test(key)) {
|
|
30
|
+
throw new Error(`That key doesn't look right (expected mp_live_… or mp_test_…).\n` +
|
|
31
|
+
` Fix: copy a fresh key from https://magicpixel.art/settings.`);
|
|
32
|
+
}
|
|
33
|
+
await assertKeyValid(key, config);
|
|
34
|
+
}
|
|
35
|
+
const path = await writeCredentials(key);
|
|
36
|
+
console.log();
|
|
37
|
+
console.log(kleur.green('✓ logged in'));
|
|
38
|
+
console.log(kleur.dim(` Key stored at ${relative(process.cwd(), path)} (mode 0600).`));
|
|
39
|
+
if (process.env.MAGICPIXEL_API_KEY) {
|
|
40
|
+
console.log(kleur.yellow(' Note: MAGICPIXEL_API_KEY is still set in your environment and takes precedence.'));
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
async function promptForKey(maxAttempts, config) {
|
|
44
|
+
const rl = createInterface({ input: stdin, output: stdout });
|
|
45
|
+
try {
|
|
46
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
47
|
+
const answer = (await rl.question(`${kleur.cyan('?')} Paste your key from ${kleur.cyan('https://magicpixel.art/settings')} ${kleur.dim("(we'll store it in .magicpixel/credentials, gitignored)")}: `)).trim();
|
|
48
|
+
if (!answer) {
|
|
49
|
+
console.log(kleur.yellow(' No key entered.'));
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (!KEY_RE.test(answer)) {
|
|
53
|
+
console.log(kleur.yellow(` That doesn't look like a MagicPixel key (expected mp_live_… or mp_test_…).`));
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
try {
|
|
57
|
+
await assertKeyValid(answer, config);
|
|
58
|
+
return answer;
|
|
59
|
+
}
|
|
60
|
+
catch (e) {
|
|
61
|
+
console.log(kleur.yellow(` ${e.message.split('\n')[0]}`));
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
throw new Error(`Gave up after ${maxAttempts} attempts.\n Fix: generate a fresh key at https://magicpixel.art/settings and re-run.`);
|
|
65
|
+
}
|
|
66
|
+
finally {
|
|
67
|
+
rl.close();
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
async function assertKeyValid(key, config) {
|
|
71
|
+
const url = new URL(`${resolveEndpoint(config)}/manifest`);
|
|
72
|
+
url.searchParams.set('limit', '1');
|
|
73
|
+
let res;
|
|
74
|
+
try {
|
|
75
|
+
res = await safeFetch(url.href, {
|
|
76
|
+
headers: { Authorization: `Bearer ${key}`, 'User-Agent': CLI_USER_AGENT },
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
catch (e) {
|
|
80
|
+
throw new Error(`Could not reach MagicPixel (${e.message}). Try again in a moment.`);
|
|
81
|
+
}
|
|
82
|
+
if (res.status === 401 || res.status === 403) {
|
|
83
|
+
await res.body?.cancel();
|
|
84
|
+
throw new Error('Key was rejected (401). Generate a fresh one at https://magicpixel.art/settings.');
|
|
85
|
+
}
|
|
86
|
+
if (!res.ok) {
|
|
87
|
+
const body = await res.text();
|
|
88
|
+
throw new Error(`Server returned ${res.status}: ${body.slice(0, 120)}`);
|
|
89
|
+
}
|
|
90
|
+
await res.body?.cancel();
|
|
91
|
+
}
|
|
92
|
+
// Kept here so `start` can reuse the same exact prompt-and-validate flow.
|
|
93
|
+
export async function promptAndStoreKey(config) {
|
|
94
|
+
if (existsSync(credentialsPath()) && !stdin.isTTY)
|
|
95
|
+
return;
|
|
96
|
+
const key = await promptForKey(3, config);
|
|
97
|
+
await writeCredentials(key);
|
|
98
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import kleur from 'kleur';
|
|
2
|
+
import { deleteCredentials, credentialsPath } from '../util/credentials.js';
|
|
3
|
+
import { relative } from 'node:path';
|
|
4
|
+
export async function logoutCommand() {
|
|
5
|
+
const path = credentialsPath();
|
|
6
|
+
const removed = await deleteCredentials();
|
|
7
|
+
if (removed) {
|
|
8
|
+
console.log(kleur.green(`✓ logged out`));
|
|
9
|
+
console.log(kleur.dim(` Removed ${relative(process.cwd(), path)}.`));
|
|
10
|
+
}
|
|
11
|
+
else {
|
|
12
|
+
console.log(kleur.dim('No stored credentials to remove.'));
|
|
13
|
+
}
|
|
14
|
+
if (process.env.MAGICPIXEL_API_KEY) {
|
|
15
|
+
console.log(kleur.yellow(' Note: MAGICPIXEL_API_KEY is still set in your environment — unset it to fully log out.'));
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import kleur from 'kleur';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { createInterface } from 'node:readline/promises';
|
|
4
|
+
import { stdin, stdout } from 'node:process';
|
|
5
|
+
import { resolve } from 'node:path';
|
|
6
|
+
import { configPath } from '../config.js';
|
|
7
|
+
import { hasPackageJson } from '../util/framework.js';
|
|
8
|
+
import { findKeyInDotenv, readCredentialsSync, writeCredentials } from '../util/credentials.js';
|
|
9
|
+
import { initCommand } from './init.js';
|
|
10
|
+
import { loginCommand } from './login.js';
|
|
11
|
+
import { syncCommand } from './sync.js';
|
|
12
|
+
/**
|
|
13
|
+
* One-command bootstrap for new users. Runs init → key prompt → first sync →
|
|
14
|
+
* prints copy-pasteable watch instructions. Designed so a non-technical user
|
|
15
|
+
* can paste `npx magicpixel start` and end up with sprites on disk and a watch
|
|
16
|
+
* script ready to run.
|
|
17
|
+
*/
|
|
18
|
+
export async function startCommand(opts = {}) {
|
|
19
|
+
console.log(kleur.bold('🪄 MagicPixel — first-run setup'));
|
|
20
|
+
console.log(kleur.dim(' This walks you through getting your sprites onto disk.'));
|
|
21
|
+
console.log();
|
|
22
|
+
// 1. Need a package.json — refuse to scaffold into a non-project directory.
|
|
23
|
+
if (!hasPackageJson()) {
|
|
24
|
+
console.log(kleur.yellow(' MagicPixel needs a JavaScript project (package.json) to sync into.\n' +
|
|
25
|
+
' Run this inside your project folder — the one with package.json.'));
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
// 2. Run init non-interactively unless config already exists.
|
|
29
|
+
const cfgPath = configPath();
|
|
30
|
+
if (existsSync(cfgPath) && !opts.force) {
|
|
31
|
+
console.log(kleur.dim(` Found existing magicpixel.json — skipping init.`));
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
await initCommand({ yes: true, force: opts.force });
|
|
35
|
+
}
|
|
36
|
+
// 3. Offer to migrate any key sitting in .env / .env.local.
|
|
37
|
+
await maybeMigrateDotenvKey();
|
|
38
|
+
// 4. Make sure we have a usable key. Env var or stored credentials are fine.
|
|
39
|
+
const haveEnv = !!process.env.MAGICPIXEL_API_KEY;
|
|
40
|
+
const stored = readCredentialsSync();
|
|
41
|
+
if (!haveEnv && !stored) {
|
|
42
|
+
console.log();
|
|
43
|
+
console.log(kleur.bold('Step: connect your account'));
|
|
44
|
+
await loginCommand();
|
|
45
|
+
}
|
|
46
|
+
else if (haveEnv) {
|
|
47
|
+
console.log(kleur.dim(' Using MAGICPIXEL_API_KEY from your environment.'));
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
console.log(kleur.dim(' Using stored credentials from .magicpixel/credentials.'));
|
|
51
|
+
}
|
|
52
|
+
// 5. First sync.
|
|
53
|
+
console.log();
|
|
54
|
+
console.log(kleur.bold('Step: first sync'));
|
|
55
|
+
await syncCommand({ full: true });
|
|
56
|
+
// 6. Tell the user how to run the watch loop.
|
|
57
|
+
console.log();
|
|
58
|
+
console.log(kleur.bold('You\'re set up. ✨'));
|
|
59
|
+
console.log();
|
|
60
|
+
console.log(` ${kleur.green('▶')} ${kleur.bold('npm run magicpixel:watch')} ${kleur.dim('# keeps sprites fresh while you edit them in MagicPixel')}`);
|
|
61
|
+
console.log();
|
|
62
|
+
if (await hasDevScript()) {
|
|
63
|
+
console.log(kleur.dim(' Tip: run your dev server and the watcher together with'));
|
|
64
|
+
console.log(kleur.dim(' `npx concurrently "npm run dev" "npm run magicpixel:watch"`'));
|
|
65
|
+
console.log();
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
async function hasDevScript() {
|
|
69
|
+
try {
|
|
70
|
+
const path = resolve(process.cwd(), 'package.json');
|
|
71
|
+
if (!existsSync(path))
|
|
72
|
+
return false;
|
|
73
|
+
const { readFile } = await import('node:fs/promises');
|
|
74
|
+
const pkg = JSON.parse(await readFile(path, 'utf8'));
|
|
75
|
+
return typeof pkg.scripts?.dev === 'string';
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
async function maybeMigrateDotenvKey() {
|
|
82
|
+
if (process.env.MAGICPIXEL_API_KEY)
|
|
83
|
+
return;
|
|
84
|
+
if (readCredentialsSync())
|
|
85
|
+
return;
|
|
86
|
+
if (!stdin.isTTY)
|
|
87
|
+
return;
|
|
88
|
+
const found = await findKeyInDotenv();
|
|
89
|
+
if (!found)
|
|
90
|
+
return;
|
|
91
|
+
if (!/^mp_(live|test)_[a-f0-9]{64}$/.test(found.value))
|
|
92
|
+
return;
|
|
93
|
+
const rl = createInterface({ input: stdin, output: stdout });
|
|
94
|
+
try {
|
|
95
|
+
const ans = (await rl.question(`${kleur.cyan('?')} Found MAGICPIXEL_API_KEY in ${found.file} — move it to .magicpixel/credentials so it stays out of your bundler? ${kleur.dim('(Y/n)')} `)).trim().toLowerCase();
|
|
96
|
+
if (ans === 'n' || ans === 'no')
|
|
97
|
+
return;
|
|
98
|
+
await writeCredentials(found.value);
|
|
99
|
+
console.log(kleur.green(`✓ migrated key from ${found.file} → .magicpixel/credentials`));
|
|
100
|
+
console.log(kleur.dim(` You can now remove the MAGICPIXEL_API_KEY line from ${found.file}.`));
|
|
101
|
+
}
|
|
102
|
+
finally {
|
|
103
|
+
rl.close();
|
|
104
|
+
}
|
|
105
|
+
}
|