bluedither 1.0.0 → 1.0.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.
package/cli/bin.js CHANGED
@@ -21,7 +21,9 @@ const HELP = `
21
21
  bluedither install [target-dir] Install theme into a project
22
22
  bluedither tune [--port N] Launch fine-tuner dev server
23
23
  bluedither extract [dir] [--validate] Validate theme package completeness
24
- bluedither publish Validate and prepare for publishing
24
+ bluedither publish Validate and publish to marketplace
25
+ bluedither login Authenticate with marketplace
26
+ bluedither search <query> Search marketplace for themes
25
27
 
26
28
  Options:
27
29
  --help Show help for a command
@@ -48,6 +50,8 @@ const commands = {
48
50
  tune: () => import('./commands/tune.js'),
49
51
  extract: () => import('./commands/extract.js'),
50
52
  publish: () => import('./commands/publish.js'),
53
+ login: () => import('./commands/login.js'),
54
+ search: () => import('./commands/search.js'),
51
55
  };
52
56
 
53
57
  if (!commands[command]) {
@@ -8,8 +8,9 @@
8
8
  */
9
9
 
10
10
  import { readFileSync, writeFileSync, copyFileSync, mkdirSync, existsSync } from 'fs';
11
- import { resolve, dirname, basename } from 'path';
11
+ import { resolve, dirname, basename, join } from 'path';
12
12
  import { fileURLToPath } from 'url';
13
+ import { getDownloadUrl } from '../lib/registry.js';
13
14
 
14
15
  const __dirname = dirname(fileURLToPath(import.meta.url));
15
16
  const BLUEDITHER_ROOT = resolve(__dirname, '..', '..');
@@ -44,7 +45,18 @@ export default async function install(args) {
44
45
  return;
45
46
  }
46
47
 
47
- const targetDir = resolve(args[0] || '.');
48
+ // Check if first arg is a registry reference (@designer/theme-name)
49
+ const firstArg = args[0] || '.';
50
+ const registryMatch = firstArg.match(/^@([^/]+)\/(.+)$/);
51
+
52
+ if (registryMatch) {
53
+ const slug = registryMatch[2];
54
+ const targetDir = resolve(args[1] || '.');
55
+ await installFromRegistry(slug, targetDir);
56
+ return;
57
+ }
58
+
59
+ const targetDir = resolve(firstArg);
48
60
 
49
61
  if (!existsSync(targetDir)) {
50
62
  console.error(`Target directory does not exist: ${targetDir}`);
@@ -66,6 +78,7 @@ export default async function install(args) {
66
78
  ['theme/tokens.schema.json', 'tokens.schema.json'],
67
79
  ['theme/structure.json', 'structure.json'],
68
80
  ['theme/rules.md', 'rules.md'],
81
+ ['theme/template/index.html', 'template/index.html'],
69
82
  ['theme/shaders/bluedither-shader.js', 'shaders/bluedither-shader.js'],
70
83
  ['theme/shaders/paper-shaders-bundle.js', 'shaders/paper-shaders-bundle.js'],
71
84
  ['skill.md', 'skill.md'],
@@ -124,3 +137,80 @@ $ARGUMENTS
124
137
  Or run: npx bluedither tune
125
138
  `);
126
139
  }
140
+
141
+ async function installFromRegistry(slug, targetDir) {
142
+ console.log(`\n Installing theme "${slug}" from marketplace...\n`);
143
+
144
+ if (!existsSync(targetDir)) {
145
+ console.error(`Target directory does not exist: ${targetDir}`);
146
+ process.exit(1);
147
+ }
148
+
149
+ // Get download URL from marketplace
150
+ let downloadUrl;
151
+ try {
152
+ downloadUrl = await getDownloadUrl(slug);
153
+ } catch (err) {
154
+ console.error(` ✗ ${err.message}`);
155
+ process.exit(1);
156
+ }
157
+
158
+ // Download ZIP
159
+ console.log(' Downloading theme package...');
160
+ const response = await fetch(downloadUrl);
161
+ if (!response.ok) {
162
+ console.error(' ✗ Download failed.');
163
+ process.exit(1);
164
+ }
165
+ const buffer = Buffer.from(await response.arrayBuffer());
166
+
167
+ // Extract ZIP
168
+ console.log(' Extracting...');
169
+ const JSZip = (await import('jszip')).default;
170
+ const zip = await JSZip.loadAsync(buffer);
171
+
172
+ const bdDir = resolve(targetDir, 'bluedither');
173
+ mkdirSync(bdDir, { recursive: true });
174
+
175
+ let extracted = 0;
176
+ for (const [path, entry] of Object.entries(zip.files)) {
177
+ if (entry.dir) {
178
+ mkdirSync(resolve(bdDir, path), { recursive: true });
179
+ continue;
180
+ }
181
+ const content = await entry.async('nodebuffer');
182
+ const destPath = resolve(bdDir, path);
183
+ mkdirSync(dirname(destPath), { recursive: true });
184
+ writeFileSync(destPath, content);
185
+ extracted++;
186
+ }
187
+
188
+ console.log(` Extracted ${extracted} files to ${bdDir}`);
189
+
190
+ // Detect framework and create Claude Code command
191
+ const { framework, typescript } = detectFramework(targetDir);
192
+ console.log(` Detected framework: ${framework}${typescript ? ' + TypeScript' : ''}`);
193
+
194
+ const claudeDir = resolve(targetDir, '.claude', 'commands');
195
+ mkdirSync(claudeDir, { recursive: true });
196
+
197
+ const commandContent = `---
198
+ description: Apply BlueDither theme to this project
199
+ ---
200
+
201
+ Read the skill instructions at bluedither/skill.md and follow them to generate the BlueDither theme for this project.
202
+
203
+ The theme files are in the bluedither/ directory. The target framework is ${framework}${typescript ? ' with TypeScript' : ''}.
204
+
205
+ $ARGUMENTS
206
+ `;
207
+
208
+ writeFileSync(resolve(claudeDir, 'apply-theme.md'), commandContent);
209
+
210
+ console.log(`
211
+ ✓ Theme "${slug}" installed!
212
+
213
+ To apply the theme, use Claude Code:
214
+ /apply-theme [description of your site]
215
+ `);
216
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * bluedither login
3
+ *
4
+ * Authenticates via the BlueDither marketplace.
5
+ * Opens browser for OAuth, receives token via local callback server.
6
+ */
7
+
8
+ import { createServer } from 'http';
9
+ import { saveCredentials } from '../lib/credentials.js';
10
+ import { REGISTRY_URL } from '../lib/registry.js';
11
+
12
+ export default async function login(args) {
13
+ if (args.includes('--help')) {
14
+ console.log(`
15
+ bluedither login
16
+
17
+ Authenticate with the BlueDither marketplace.
18
+ Opens your browser for GitHub sign-in and saves credentials locally.
19
+ `);
20
+ return;
21
+ }
22
+
23
+ return new Promise((resolvePromise, reject) => {
24
+ const server = createServer(async (req, res) => {
25
+ if (req.method === 'POST' && req.url === '/callback') {
26
+ let body = '';
27
+ req.on('data', chunk => { body += chunk; });
28
+ req.on('end', () => {
29
+ try {
30
+ const creds = JSON.parse(body);
31
+ saveCredentials(creds);
32
+ res.writeHead(200, { 'Content-Type': 'application/json' });
33
+ res.end('{"ok":true}');
34
+ console.log('\n ✓ Logged in successfully!\n');
35
+ server.close();
36
+ resolvePromise();
37
+ } catch (err) {
38
+ res.writeHead(400);
39
+ res.end();
40
+ reject(err);
41
+ }
42
+ });
43
+ } else {
44
+ res.writeHead(404);
45
+ res.end();
46
+ }
47
+ });
48
+
49
+ server.listen(0, () => {
50
+ const port = server.address().port;
51
+ const loginUrl = `${REGISTRY_URL}/login?cli_port=${port}`;
52
+
53
+ console.log(`\n Opening browser for authentication...`);
54
+ console.log(` If it doesn't open, visit: ${loginUrl}\n`);
55
+
56
+ // Open browser
57
+ import('child_process').then(({ exec }) => {
58
+ const cmd = process.platform === 'win32' ? 'start' :
59
+ process.platform === 'darwin' ? 'open' : 'xdg-open';
60
+ exec(`${cmd} "${loginUrl}"`);
61
+ });
62
+
63
+ // Timeout after 5 minutes
64
+ setTimeout(() => {
65
+ console.error('\n ✗ Login timed out.\n');
66
+ server.close();
67
+ reject(new Error('Login timed out'));
68
+ }, 300_000);
69
+ });
70
+ });
71
+ }
@@ -1,23 +1,22 @@
1
1
  /**
2
2
  * bluedither publish
3
3
  *
4
- * Validates the theme package and prepares for publishing.
5
- * Full marketplace upload will be added in Phase 6.
4
+ * Validates the theme package and publishes to the BlueDither marketplace.
6
5
  */
7
6
 
8
- import { existsSync, readFileSync } from 'fs';
7
+ import { existsSync, readFileSync, createReadStream } from 'fs';
9
8
  import { resolve } from 'path';
10
9
  import { execSync } from 'child_process';
10
+ import { getCredentials } from '../lib/credentials.js';
11
+ import { uploadPackage, publishTheme, REGISTRY_URL } from '../lib/registry.js';
11
12
 
12
13
  export default async function publish(args) {
13
14
  if (args.includes('--help')) {
14
15
  console.log(`
15
16
  bluedither publish
16
17
 
17
- Validates theme package completeness, runs the build, and prepares
18
- the package for publishing.
19
-
20
- Marketplace upload will be available in a future version.
18
+ Validates, builds, and publishes your theme to the BlueDither marketplace.
19
+ If you're not logged in, a browser window will open for authentication.
21
20
  `);
22
21
  return;
23
22
  }
@@ -34,13 +33,18 @@ export default async function publish(args) {
34
33
  const config = JSON.parse(readFileSync(configPath, 'utf-8'));
35
34
  console.log(`Publishing: ${config.name}@${config.version}\n`);
36
35
 
37
- // Step 1: Build
36
+ // Step 1: Build BlueDither assets (shader + tuner bundles)
38
37
  console.log('Step 1: Building...');
39
- try {
40
- execSync('npm run build', { cwd: dir, stdio: 'inherit' });
41
- } catch {
42
- console.error('\n✗ Build failed. Fix errors before publishing.');
43
- process.exit(1);
38
+ const buildScript = resolve(dir, 'scripts/build.js');
39
+ if (existsSync(buildScript)) {
40
+ try {
41
+ execSync(`node "${buildScript}"`, { cwd: dir, stdio: 'inherit' });
42
+ } catch {
43
+ console.error('\n✗ Build failed. Fix errors before publishing.');
44
+ process.exit(1);
45
+ }
46
+ } else {
47
+ console.log(' No build script found — skipping build step.');
44
48
  }
45
49
 
46
50
  // Step 2: Validate package completeness
@@ -76,10 +80,99 @@ export default async function publish(args) {
76
80
  process.exit(1);
77
81
  }
78
82
 
83
+ console.log('\n✓ Validation passed.\n');
84
+
85
+ // Step 4: Authenticate
86
+ let creds = getCredentials();
87
+ if (!creds?.access_token) {
88
+ console.log('\nStep 4: Authenticating...');
89
+ const { default: login } = await import('./login.js');
90
+ await login([]);
91
+ creds = getCredentials();
92
+ if (!creds?.access_token) {
93
+ console.error('\n✗ Login failed. Cannot publish without authentication.');
94
+ process.exit(1);
95
+ }
96
+ }
97
+
98
+ console.log('\nStep 5: Creating package ZIP...');
99
+ const JSZip = (await import('jszip')).default;
100
+ const { readdirSync, statSync } = await import('fs');
101
+ const zip = new JSZip();
102
+
103
+ // Collect files to include
104
+ const includeFiles = [
105
+ config.tokens, config.defaults, config.schema,
106
+ config.structure, config.rules, config.skill,
107
+ 'bluedither.config.json',
108
+ ];
109
+ if (config.tuner && existsSync(resolve(dir, config.tuner))) includeFiles.push(config.tuner);
110
+ if (config.shaders) includeFiles.push(...config.shaders);
111
+ if (config.shaderSources) includeFiles.push(...config.shaderSources);
112
+
113
+ function addDirToZip(dirPath, zipPath) {
114
+ const entries = readdirSync(dirPath, { withFileTypes: true });
115
+ for (const entry of entries) {
116
+ const full = resolve(dirPath, entry.name);
117
+ const zp = zipPath ? `${zipPath}/${entry.name}` : entry.name;
118
+ if (entry.isDirectory()) {
119
+ addDirToZip(full, zp);
120
+ } else {
121
+ zip.file(zp, readFileSync(full));
122
+ }
123
+ }
124
+ }
125
+
126
+ for (const f of includeFiles) {
127
+ const full = resolve(dir, f);
128
+ if (!existsSync(full)) continue;
129
+ if (statSync(full).isDirectory()) {
130
+ addDirToZip(full, f);
131
+ } else {
132
+ zip.file(f, readFileSync(full));
133
+ }
134
+ }
135
+
136
+ // Include generators directory
137
+ if (config.generators && existsSync(resolve(dir, config.generators))) {
138
+ addDirToZip(resolve(dir, config.generators), config.generators);
139
+ }
140
+
141
+ // Include template directory (check both source and installed layouts)
142
+ for (const tmplPath of ['theme/template', 'template']) {
143
+ const templateDir = resolve(dir, tmplPath);
144
+ if (existsSync(templateDir)) {
145
+ addDirToZip(templateDir, tmplPath);
146
+ break;
147
+ }
148
+ }
149
+
150
+ const zipBuffer = await zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' });
151
+ const slug = config.name.toLowerCase().replace(/[^a-z0-9]+/g, '-');
152
+
153
+ console.log(` ZIP size: ${(zipBuffer.length / 1024).toFixed(1)} KB`);
154
+
155
+ console.log('\nStep 6: Uploading to marketplace...');
156
+ const zipBlob = new Blob([zipBuffer], { type: 'application/zip' });
157
+ const zipFile = new File([zipBlob], `${slug}.zip`, { type: 'application/zip' });
158
+
159
+ const { path: packagePath } = await uploadPackage(zipFile, slug, creds.access_token);
160
+
161
+ console.log('Step 7: Publishing theme...');
162
+ const tokens = JSON.parse(readFileSync(resolve(dir, config.tokens), 'utf-8'));
163
+
164
+ const theme = await publishTheme({
165
+ name: config.name,
166
+ slug,
167
+ description: tokens.content?.subHeadline || '',
168
+ tokens_json: tokens,
169
+ package_path: packagePath,
170
+ }, creds.access_token);
171
+
79
172
  console.log(`
80
- Package ready for publishing.
173
+ Published to marketplace!
81
174
 
82
- Marketplace upload is not yet available.
83
- To publish as an npm package, run: npm publish
175
+ View: ${REGISTRY_URL}/themes/${slug}
176
+ Install: npx bluedither install @${slug}
84
177
  `);
85
178
  }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * bluedither search <query>
3
+ *
4
+ * Searches the BlueDither marketplace for themes.
5
+ */
6
+
7
+ import { searchThemes } from '../lib/registry.js';
8
+
9
+ export default async function search(args) {
10
+ if (args.includes('--help') || args.length === 0) {
11
+ console.log(`
12
+ bluedither search <query>
13
+
14
+ Search the BlueDither marketplace for themes.
15
+
16
+ Example:
17
+ bluedither search "dark minimal"
18
+ `);
19
+ if (args.length === 0 && !args.includes('--help')) {
20
+ process.exit(1);
21
+ }
22
+ return;
23
+ }
24
+
25
+ const query = args.join(' ');
26
+ console.log(`\n Searching for "${query}"...\n`);
27
+
28
+ try {
29
+ const themes = await searchThemes(query);
30
+
31
+ if (themes.length === 0) {
32
+ console.log(' No themes found.\n');
33
+ return;
34
+ }
35
+
36
+ // Table header
37
+ const nameW = 24, designerW = 18, dlW = 10;
38
+ console.log(
39
+ ' ' +
40
+ 'Name'.padEnd(nameW) +
41
+ 'Designer'.padEnd(designerW) +
42
+ 'Downloads'.padEnd(dlW) +
43
+ 'Install command'
44
+ );
45
+ console.log(' ' + '-'.repeat(nameW + designerW + dlW + 30));
46
+
47
+ for (const t of themes) {
48
+ const designer = t.profiles?.display_name || 'unknown';
49
+ const slug = t.slug;
50
+ const handle = designer.toLowerCase().replace(/\s+/g, '-');
51
+ console.log(
52
+ ' ' +
53
+ t.name.slice(0, nameW - 2).padEnd(nameW) +
54
+ designer.slice(0, designerW - 2).padEnd(designerW) +
55
+ String(t.download_count || 0).padEnd(dlW) +
56
+ `npx bluedither install @${handle}/${slug}`
57
+ );
58
+ }
59
+ console.log();
60
+ } catch (err) {
61
+ console.error(` ✗ ${err.message}\n`);
62
+ process.exit(1);
63
+ }
64
+ }
@@ -2,7 +2,9 @@
2
2
  * bluedither tune [--port N]
3
3
  *
4
4
  * Launches the fine-tuner dev server.
5
- * Finds the nearest bluedither.config.json or theme/ directory.
5
+ * Finds the nearest bluedither theme directory — either:
6
+ * - Source repo layout: ./theme/tokens.json + ./fine-tuner/server.js
7
+ * - Installed layout: ./bluedither/tokens.json (from `npx bluedither install`)
6
8
  */
7
9
 
8
10
  import { existsSync } from 'fs';
@@ -11,16 +13,7 @@ import { fileURLToPath } from 'url';
11
13
  import { spawn } from 'child_process';
12
14
 
13
15
  const __dirname = dirname(fileURLToPath(import.meta.url));
14
-
15
- function findThemeRoot(startDir) {
16
- let dir = startDir;
17
- while (dir !== dirname(dir)) {
18
- if (existsSync(resolve(dir, 'bluedither.config.json'))) return dir;
19
- if (existsSync(resolve(dir, 'theme', 'tokens.json'))) return dir;
20
- dir = dirname(dir);
21
- }
22
- return null;
23
- }
16
+ const PKG_ROOT = resolve(__dirname, '..', '..');
24
17
 
25
18
  export default async function tune(args) {
26
19
  if (args.includes('--help')) {
@@ -28,7 +21,7 @@ export default async function tune(args) {
28
21
  bluedither tune [--port N]
29
22
 
30
23
  Launches the fine-tuner dev server for real-time token editing.
31
- Searches upward for bluedither.config.json or theme/tokens.json.
24
+ Searches for a BlueDither theme in the current directory.
32
25
 
33
26
  Options:
34
27
  --port N Port number (default: 3333)
@@ -36,28 +29,55 @@ export default async function tune(args) {
36
29
  return;
37
30
  }
38
31
 
39
- const themeRoot = findThemeRoot(process.cwd());
32
+ const cwd = process.cwd();
33
+
34
+ // Determine where the theme files live
35
+ let themeDir = null;
36
+ let tokensPath = null;
40
37
 
41
- if (!themeRoot) {
38
+ // Case 1: Source repo layout (theme/ at cwd root)
39
+ if (existsSync(resolve(cwd, 'theme', 'tokens.json'))) {
40
+ themeDir = resolve(cwd, 'theme');
41
+ tokensPath = resolve(themeDir, 'tokens.json');
42
+ }
43
+ // Case 2: Installed layout (bluedither/ subfolder from `npx bluedither install`)
44
+ else if (existsSync(resolve(cwd, 'bluedither', 'tokens.json'))) {
45
+ themeDir = resolve(cwd, 'bluedither');
46
+ tokensPath = resolve(themeDir, 'tokens.json');
47
+ }
48
+ // Case 3: Search upward
49
+ else {
50
+ let dir = cwd;
51
+ while (dir !== dirname(dir)) {
52
+ if (existsSync(resolve(dir, 'theme', 'tokens.json'))) {
53
+ themeDir = resolve(dir, 'theme');
54
+ tokensPath = resolve(themeDir, 'tokens.json');
55
+ break;
56
+ }
57
+ if (existsSync(resolve(dir, 'bluedither', 'tokens.json'))) {
58
+ themeDir = resolve(dir, 'bluedither');
59
+ tokensPath = resolve(themeDir, 'tokens.json');
60
+ break;
61
+ }
62
+ dir = dirname(dir);
63
+ }
64
+ }
65
+
66
+ if (!themeDir) {
42
67
  console.error('Could not find a BlueDither theme directory.');
43
- console.error('Run this from a directory containing bluedither.config.json or theme/tokens.json.');
68
+ console.error('Run this from a project with bluedither/ or theme/ containing tokens.json.');
44
69
  process.exit(1);
45
70
  }
46
71
 
47
- const serverPath = resolve(themeRoot, 'fine-tuner', 'server.js');
48
-
72
+ // The fine-tuner server lives in the npm package
73
+ const serverPath = resolve(PKG_ROOT, 'fine-tuner', 'server.js');
49
74
  if (!existsSync(serverPath)) {
50
- // If we're in an installed theme (not the source repo), use the source repo's server
51
- const sourceServer = resolve(__dirname, '..', '..', 'fine-tuner', 'server.js');
52
- if (!existsSync(sourceServer)) {
53
- console.error('Fine-tuner server not found.');
54
- process.exit(1);
55
- }
56
- const child = spawn('node', [sourceServer, ...args], { cwd: themeRoot, stdio: 'inherit' });
57
- child.on('exit', (code) => process.exit(code || 0));
58
- return;
75
+ console.error('Fine-tuner server not found at: ' + serverPath);
76
+ process.exit(1);
59
77
  }
60
78
 
61
- const child = spawn('node', [serverPath, ...args], { cwd: themeRoot, stdio: 'inherit' });
79
+ // Pass the theme directory to the server via env var
80
+ const env = { ...process.env, BD_THEME_DIR: themeDir };
81
+ const child = spawn('node', [serverPath, ...args], { cwd: dirname(themeDir), env, stdio: 'inherit' });
62
82
  child.on('exit', (code) => process.exit(code || 0));
63
83
  }
@@ -0,0 +1,26 @@
1
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs';
2
+ import { resolve } from 'path';
3
+ import { homedir } from 'os';
4
+
5
+ const CRED_DIR = resolve(homedir(), '.bluedither');
6
+ const CRED_FILE = resolve(CRED_DIR, 'credentials.json');
7
+
8
+ export function getCredentials() {
9
+ if (!existsSync(CRED_FILE)) return null;
10
+ try {
11
+ return JSON.parse(readFileSync(CRED_FILE, 'utf-8'));
12
+ } catch {
13
+ return null;
14
+ }
15
+ }
16
+
17
+ export function saveCredentials(creds) {
18
+ mkdirSync(CRED_DIR, { recursive: true });
19
+ writeFileSync(CRED_FILE, JSON.stringify(creds, null, 2));
20
+ }
21
+
22
+ export function clearCredentials() {
23
+ if (existsSync(CRED_FILE)) {
24
+ writeFileSync(CRED_FILE, '{}');
25
+ }
26
+ }
@@ -0,0 +1,58 @@
1
+ const REGISTRY_URL = process.env.BLUEDITHER_REGISTRY_URL || 'https://marketplace-five-flame.vercel.app';
2
+
3
+ export async function searchThemes(query) {
4
+ const url = `${REGISTRY_URL}/api/themes?q=${encodeURIComponent(query)}`;
5
+ const res = await fetch(url);
6
+ if (!res.ok) throw new Error(`Search failed: ${res.statusText}`);
7
+ return res.json();
8
+ }
9
+
10
+ export async function getTheme(slug) {
11
+ const url = `${REGISTRY_URL}/api/themes/${encodeURIComponent(slug)}`;
12
+ const res = await fetch(url);
13
+ if (!res.ok) throw new Error(`Theme not found: ${slug}`);
14
+ return res.json();
15
+ }
16
+
17
+ export async function getDownloadUrl(slug) {
18
+ const url = `${REGISTRY_URL}/api/themes/${encodeURIComponent(slug)}/download`;
19
+ const res = await fetch(url);
20
+ if (!res.ok) throw new Error(`Download failed: ${res.statusText}`);
21
+ const data = await res.json();
22
+ return data.url;
23
+ }
24
+
25
+ export async function uploadPackage(file, slug, accessToken) {
26
+ const formData = new FormData();
27
+ formData.append('package', file);
28
+ formData.append('slug', slug);
29
+
30
+ const res = await fetch(`${REGISTRY_URL}/api/upload`, {
31
+ method: 'POST',
32
+ headers: { Authorization: `Bearer ${accessToken}` },
33
+ body: formData,
34
+ });
35
+ if (!res.ok) {
36
+ const err = await res.json().catch(() => ({}));
37
+ throw new Error(err.error || `Upload failed: ${res.statusText}`);
38
+ }
39
+ return res.json();
40
+ }
41
+
42
+ export async function publishTheme(metadata, accessToken) {
43
+ const res = await fetch(`${REGISTRY_URL}/api/themes`, {
44
+ method: 'POST',
45
+ headers: {
46
+ 'Content-Type': 'application/json',
47
+ Authorization: `Bearer ${accessToken}`,
48
+ },
49
+ body: JSON.stringify(metadata),
50
+ });
51
+ if (!res.ok) {
52
+ const err = await res.json().catch(() => ({}));
53
+ throw new Error(err.error || `Publish failed: ${res.statusText}`);
54
+ }
55
+ return res.json();
56
+ }
57
+
58
+ export { REGISTRY_URL };