@pixelbyte-software/pixcode 1.53.11 → 1.53.13
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/dist/assets/index-BEyC4q_s.css +32 -0
- package/dist/assets/{index-Bo8oVc2f.js → index-CZReJ0_S.js} +184 -184
- package/dist/index.html +2 -2
- package/dist-server/server/database/db.js +26 -0
- package/dist-server/server/database/db.js.map +1 -1
- package/dist-server/server/index.js +269 -53
- package/dist-server/server/index.js.map +1 -1
- package/dist-server/server/projects.js +126 -43
- package/dist-server/server/projects.js.map +1 -1
- package/dist-server/server/routes/auth.js +41 -1
- package/dist-server/server/routes/auth.js.map +1 -1
- package/dist-server/server/routes/network.js +17 -1
- package/dist-server/server/routes/network.js.map +1 -1
- package/dist-server/server/routes/plugins.js +364 -0
- package/dist-server/server/routes/plugins.js.map +1 -1
- package/dist-server/server/services/external-access.js +45 -21
- package/dist-server/server/services/external-access.js.map +1 -1
- package/dist-server/server/services/qr-login.js +115 -0
- package/dist-server/server/services/qr-login.js.map +1 -0
- package/dist-server/server/services/telegram/bot.js +18 -1
- package/dist-server/server/services/telegram/bot.js.map +1 -1
- package/dist-server/server/services/telegram/control-center.js +187 -5
- package/dist-server/server/services/telegram/control-center.js.map +1 -1
- package/dist-server/server/services/telegram/translations.js +34 -2
- package/dist-server/server/services/telegram/translations.js.map +1 -1
- package/package.json +1 -1
- package/server/database/db.js +26 -0
- package/server/index.js +291 -58
- package/server/projects.js +128 -41
- package/server/routes/auth.js +47 -1
- package/server/routes/network.js +18 -1
- package/server/routes/plugins.js +370 -0
- package/server/services/external-access.js +49 -21
- package/server/services/qr-login.js +126 -0
- package/server/services/telegram/bot.js +15 -0
- package/server/services/telegram/control-center.js +192 -5
- package/server/services/telegram/translations.js +34 -2
- package/dist/assets/index-FhOsv2Yy.css +0 -32
package/server/routes/plugins.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import path from 'path';
|
|
2
2
|
import http from 'http';
|
|
3
3
|
import fs from 'fs';
|
|
4
|
+
import os from 'os';
|
|
4
5
|
|
|
5
6
|
import mime from 'mime-types';
|
|
6
7
|
import express from 'express';
|
|
@@ -24,6 +25,375 @@ import {
|
|
|
24
25
|
} from '../utils/plugin-process-manager.js';
|
|
25
26
|
|
|
26
27
|
const router = express.Router();
|
|
28
|
+
const MARKETPLACE_SOURCES_PATH = path.join(os.homedir(), '.pixcode', 'marketplace-sources.json');
|
|
29
|
+
const MARKETPLACE_SEARCH_TIMEOUT_MS = 8000;
|
|
30
|
+
const ALL_CLI_COMPATIBILITY = ['claude', 'codex', 'cursor', 'gemini', 'qwen', 'opencode'];
|
|
31
|
+
|
|
32
|
+
const CURATED_MARKETPLACE_ENTRIES = [
|
|
33
|
+
{
|
|
34
|
+
id: 'github:hesreallyhim/awesome-claude-code',
|
|
35
|
+
name: 'Awesome Claude Code',
|
|
36
|
+
type: 'skill-library',
|
|
37
|
+
category: 'popular',
|
|
38
|
+
sourceUrl: 'https://github.com/hesreallyhim/awesome-claude-code',
|
|
39
|
+
repo: 'hesreallyhim/awesome-claude-code',
|
|
40
|
+
description: 'Curated skills, slash commands, hooks, orchestration patterns, and agent resources.',
|
|
41
|
+
stars: 46000,
|
|
42
|
+
forks: 0,
|
|
43
|
+
updatedAt: null,
|
|
44
|
+
compatibleCli: ALL_CLI_COMPATIBILITY,
|
|
45
|
+
installKind: 'source',
|
|
46
|
+
tags: ['skills', 'agents', 'commands'],
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
id: 'github:sickn33/antigravity-awesome-skills',
|
|
50
|
+
name: 'Antigravity Awesome Skills',
|
|
51
|
+
type: 'skill-library',
|
|
52
|
+
category: 'popular',
|
|
53
|
+
sourceUrl: 'https://github.com/sickn33/antigravity-awesome-skills',
|
|
54
|
+
repo: 'sickn33/antigravity-awesome-skills',
|
|
55
|
+
description: 'Large installable library of cross-CLI agent skills for Claude, Codex, Cursor, Gemini, and more.',
|
|
56
|
+
stars: 40000,
|
|
57
|
+
forks: 0,
|
|
58
|
+
updatedAt: null,
|
|
59
|
+
compatibleCli: ALL_CLI_COMPATIBILITY,
|
|
60
|
+
installKind: 'source',
|
|
61
|
+
tags: ['skills', 'multi-cli'],
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
id: 'github:wshobson/agents',
|
|
65
|
+
name: 'Agents',
|
|
66
|
+
type: 'agent-pack',
|
|
67
|
+
category: 'popular',
|
|
68
|
+
sourceUrl: 'https://github.com/wshobson/agents',
|
|
69
|
+
repo: 'wshobson/agents',
|
|
70
|
+
description: 'Multi-harness agent pack and marketplace patterns for Claude Code, Codex CLI, Cursor, OpenCode, Copilot, and Gemini.',
|
|
71
|
+
stars: 36000,
|
|
72
|
+
forks: 0,
|
|
73
|
+
updatedAt: null,
|
|
74
|
+
compatibleCli: ALL_CLI_COMPATIBILITY,
|
|
75
|
+
installKind: 'source',
|
|
76
|
+
tags: ['agents', 'multi-cli'],
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
id: 'github:alirezarezvani/claude-skills',
|
|
80
|
+
name: 'Claude Skills',
|
|
81
|
+
type: 'skill-library',
|
|
82
|
+
category: 'popular',
|
|
83
|
+
sourceUrl: 'https://github.com/alirezarezvani/claude-skills',
|
|
84
|
+
repo: 'alirezarezvani/claude-skills',
|
|
85
|
+
description: 'Broad skill, agent, and plugin collection for Claude Code, Codex, Gemini CLI, Cursor, and related tools.',
|
|
86
|
+
stars: 18000,
|
|
87
|
+
forks: 0,
|
|
88
|
+
updatedAt: null,
|
|
89
|
+
compatibleCli: ALL_CLI_COMPATIBILITY,
|
|
90
|
+
installKind: 'source',
|
|
91
|
+
tags: ['skills', 'plugins'],
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
id: 'github:voltagent/awesome-agent-skills',
|
|
95
|
+
name: 'Awesome Agent Skills',
|
|
96
|
+
type: 'skill-library',
|
|
97
|
+
category: 'skills',
|
|
98
|
+
sourceUrl: 'https://github.com/VoltAgent/awesome-agent-skills',
|
|
99
|
+
repo: 'VoltAgent/awesome-agent-skills',
|
|
100
|
+
description: 'Cross-agent skill catalogue covering Claude, Codex, Cursor, Gemini, and OpenCode ecosystems.',
|
|
101
|
+
stars: 0,
|
|
102
|
+
forks: 0,
|
|
103
|
+
updatedAt: null,
|
|
104
|
+
compatibleCli: ALL_CLI_COMPATIBILITY,
|
|
105
|
+
installKind: 'source',
|
|
106
|
+
tags: ['skills', 'catalog'],
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
id: 'github:awesome-opencode/awesome-opencode',
|
|
110
|
+
name: 'Awesome OpenCode',
|
|
111
|
+
type: 'native-plugin',
|
|
112
|
+
category: 'plugin',
|
|
113
|
+
sourceUrl: 'https://github.com/awesome-opencode/awesome-opencode',
|
|
114
|
+
repo: 'awesome-opencode/awesome-opencode',
|
|
115
|
+
description: 'Curated OpenCode plugins, themes, agents, projects, and resources.',
|
|
116
|
+
stars: 8000,
|
|
117
|
+
forks: 0,
|
|
118
|
+
updatedAt: null,
|
|
119
|
+
compatibleCli: ['opencode'],
|
|
120
|
+
installKind: 'source',
|
|
121
|
+
tags: ['opencode', 'plugins'],
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
id: 'github:jenslys/opencode-gemini-auth',
|
|
125
|
+
name: 'OpenCode Gemini Auth',
|
|
126
|
+
type: 'native-plugin',
|
|
127
|
+
category: 'plugin',
|
|
128
|
+
sourceUrl: 'https://github.com/jenslys/opencode-gemini-auth',
|
|
129
|
+
repo: 'jenslys/opencode-gemini-auth',
|
|
130
|
+
description: 'OpenCode authentication plugin for Gemini accounts.',
|
|
131
|
+
stars: 0,
|
|
132
|
+
forks: 0,
|
|
133
|
+
updatedAt: null,
|
|
134
|
+
compatibleCli: ['opencode', 'gemini'],
|
|
135
|
+
installKind: 'source',
|
|
136
|
+
tags: ['opencode', 'auth'],
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
id: 'github:supermemoryai/opencode-supermemory',
|
|
140
|
+
name: 'OpenCode Supermemory',
|
|
141
|
+
type: 'native-plugin',
|
|
142
|
+
category: 'plugin',
|
|
143
|
+
sourceUrl: 'https://github.com/supermemoryai/opencode-supermemory',
|
|
144
|
+
repo: 'supermemoryai/opencode-supermemory',
|
|
145
|
+
description: 'Supermemory integration plugin for OpenCode.',
|
|
146
|
+
stars: 0,
|
|
147
|
+
forks: 0,
|
|
148
|
+
updatedAt: null,
|
|
149
|
+
compatibleCli: ['opencode'],
|
|
150
|
+
installKind: 'source',
|
|
151
|
+
tags: ['opencode', 'memory'],
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
id: 'github:griffinmartin/opencode-claude-auth',
|
|
155
|
+
name: 'OpenCode Claude Auth',
|
|
156
|
+
type: 'native-plugin',
|
|
157
|
+
category: 'plugin',
|
|
158
|
+
sourceUrl: 'https://github.com/griffinmartin/opencode-claude-auth',
|
|
159
|
+
repo: 'griffinmartin/opencode-claude-auth',
|
|
160
|
+
description: 'OpenCode plugin that reuses Claude Code credentials.',
|
|
161
|
+
stars: 0,
|
|
162
|
+
forks: 0,
|
|
163
|
+
updatedAt: null,
|
|
164
|
+
compatibleCli: ['opencode', 'claude'],
|
|
165
|
+
installKind: 'source',
|
|
166
|
+
tags: ['opencode', 'auth'],
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
id: 'github:othmanadi/planning-with-files',
|
|
170
|
+
name: 'Planning With Files',
|
|
171
|
+
type: 'skill-library',
|
|
172
|
+
category: 'workflow',
|
|
173
|
+
sourceUrl: 'https://github.com/OthmanAdi/planning-with-files',
|
|
174
|
+
repo: 'OthmanAdi/planning-with-files',
|
|
175
|
+
description: 'Persistent file-based planning skill for multiple AI coding CLIs.',
|
|
176
|
+
stars: 0,
|
|
177
|
+
forks: 0,
|
|
178
|
+
updatedAt: null,
|
|
179
|
+
compatibleCli: ALL_CLI_COMPATIBILITY,
|
|
180
|
+
installKind: 'source',
|
|
181
|
+
tags: ['planning', 'workflow'],
|
|
182
|
+
},
|
|
183
|
+
{
|
|
184
|
+
id: 'github:safishamsi/graphify',
|
|
185
|
+
name: 'Graphify',
|
|
186
|
+
type: 'skill-library',
|
|
187
|
+
category: 'workflow',
|
|
188
|
+
sourceUrl: 'https://github.com/safishamsi/graphify',
|
|
189
|
+
repo: 'safishamsi/graphify',
|
|
190
|
+
description: 'Knowledge graph skill for Claude Code, Codex, OpenCode, Cursor, Gemini, and other coding agents.',
|
|
191
|
+
stars: 0,
|
|
192
|
+
forks: 0,
|
|
193
|
+
updatedAt: null,
|
|
194
|
+
compatibleCli: ALL_CLI_COMPATIBILITY,
|
|
195
|
+
installKind: 'source',
|
|
196
|
+
tags: ['knowledge-graph', 'memory'],
|
|
197
|
+
},
|
|
198
|
+
];
|
|
199
|
+
|
|
200
|
+
function ensurePixcodeConfigDir() {
|
|
201
|
+
const dir = path.dirname(MARKETPLACE_SOURCES_PATH);
|
|
202
|
+
if (!fs.existsSync(dir)) {
|
|
203
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function readMarketplaceSources() {
|
|
208
|
+
try {
|
|
209
|
+
if (!fs.existsSync(MARKETPLACE_SOURCES_PATH)) return [];
|
|
210
|
+
const parsed = JSON.parse(fs.readFileSync(MARKETPLACE_SOURCES_PATH, 'utf-8'));
|
|
211
|
+
return Array.isArray(parsed?.sources) ? parsed.sources : [];
|
|
212
|
+
} catch {
|
|
213
|
+
return [];
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function writeMarketplaceSources(sources) {
|
|
218
|
+
ensurePixcodeConfigDir();
|
|
219
|
+
fs.writeFileSync(
|
|
220
|
+
MARKETPLACE_SOURCES_PATH,
|
|
221
|
+
JSON.stringify({ version: 1, sources }, null, 2),
|
|
222
|
+
{ mode: 0o600 },
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function parseGithubRepoFromUrl(sourceUrl) {
|
|
227
|
+
try {
|
|
228
|
+
const url = new URL(sourceUrl);
|
|
229
|
+
if (url.hostname !== 'github.com' && url.hostname !== 'www.github.com') return null;
|
|
230
|
+
const [owner, repo] = url.pathname.replace(/^\/+/, '').split('/');
|
|
231
|
+
if (!owner || !repo) return null;
|
|
232
|
+
return `${owner}/${repo.replace(/\.git$/, '')}`;
|
|
233
|
+
} catch {
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function marketplaceIdForSource(sourceUrl, fallbackName = '') {
|
|
239
|
+
const repo = parseGithubRepoFromUrl(sourceUrl);
|
|
240
|
+
if (repo) return `github:${repo.toLowerCase()}`;
|
|
241
|
+
return `source:${String(fallbackName || sourceUrl).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 80)}`;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function inferCliCompatibility(text) {
|
|
245
|
+
const haystack = String(text || '').toLowerCase();
|
|
246
|
+
const matches = ALL_CLI_COMPATIBILITY.filter((cli) => haystack.includes(cli));
|
|
247
|
+
if (haystack.includes('claude code') && !matches.includes('claude')) matches.push('claude');
|
|
248
|
+
if (haystack.includes('open code') && !matches.includes('opencode')) matches.push('opencode');
|
|
249
|
+
return matches.length > 0 ? [...new Set(matches)] : ALL_CLI_COMPATIBILITY;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function inferMarketplaceType(text) {
|
|
253
|
+
const haystack = String(text || '').toLowerCase();
|
|
254
|
+
if (haystack.includes('agent')) return 'agent-pack';
|
|
255
|
+
if (haystack.includes('plugin') || haystack.includes('opencode')) return 'native-plugin';
|
|
256
|
+
return 'skill-library';
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function normalizeMarketplaceSource(input) {
|
|
260
|
+
const sourceUrl = typeof input?.sourceUrl === 'string' ? input.sourceUrl.trim() : '';
|
|
261
|
+
if (!sourceUrl.startsWith('https://github.com/')) {
|
|
262
|
+
throw new Error('Only public GitHub HTTPS sources are supported for global market sources.');
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const repo = parseGithubRepoFromUrl(sourceUrl);
|
|
266
|
+
const name = String(input?.name || repo || 'GitHub source').trim().slice(0, 120);
|
|
267
|
+
const description = String(input?.description || '').trim().slice(0, 500);
|
|
268
|
+
const compatibleCli = Array.isArray(input?.compatibleCli)
|
|
269
|
+
? input.compatibleCli.filter((cli) => ALL_CLI_COMPATIBILITY.includes(cli))
|
|
270
|
+
: inferCliCompatibility(`${name} ${description} ${repo || ''}`);
|
|
271
|
+
|
|
272
|
+
return {
|
|
273
|
+
id: marketplaceIdForSource(sourceUrl, name),
|
|
274
|
+
name,
|
|
275
|
+
type: ['skill-library', 'agent-pack', 'native-plugin', 'pixcode-plugin'].includes(input?.type)
|
|
276
|
+
? input.type
|
|
277
|
+
: inferMarketplaceType(`${name} ${description}`),
|
|
278
|
+
category: typeof input?.category === 'string' ? input.category.slice(0, 40) : 'installed',
|
|
279
|
+
sourceUrl,
|
|
280
|
+
repo,
|
|
281
|
+
description,
|
|
282
|
+
stars: Number.isFinite(Number(input?.stars)) ? Number(input.stars) : 0,
|
|
283
|
+
forks: Number.isFinite(Number(input?.forks)) ? Number(input.forks) : 0,
|
|
284
|
+
updatedAt: typeof input?.updatedAt === 'string' ? input.updatedAt : null,
|
|
285
|
+
compatibleCli: compatibleCli.length > 0 ? compatibleCli : ALL_CLI_COMPATIBILITY,
|
|
286
|
+
installKind: input?.installKind === 'pixcode-plugin' ? 'pixcode-plugin' : 'source',
|
|
287
|
+
tags: Array.isArray(input?.tags) ? input.tags.filter((tag) => typeof tag === 'string').slice(0, 8) : [],
|
|
288
|
+
addedAt: typeof input?.addedAt === 'string' ? input.addedAt : new Date().toISOString(),
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function githubRepoToMarketplaceEntry(repo, category = 'search') {
|
|
293
|
+
const text = `${repo.full_name || ''} ${repo.description || ''} ${repo.topics?.join(' ') || ''}`;
|
|
294
|
+
return {
|
|
295
|
+
id: `github:${String(repo.full_name || repo.html_url || repo.id).toLowerCase()}`,
|
|
296
|
+
name: repo.name || repo.full_name || 'GitHub repository',
|
|
297
|
+
type: inferMarketplaceType(text),
|
|
298
|
+
category,
|
|
299
|
+
sourceUrl: repo.html_url,
|
|
300
|
+
repo: repo.full_name || null,
|
|
301
|
+
description: repo.description || '',
|
|
302
|
+
stars: repo.stargazers_count || 0,
|
|
303
|
+
forks: repo.forks_count || 0,
|
|
304
|
+
updatedAt: repo.updated_at || null,
|
|
305
|
+
compatibleCli: inferCliCompatibility(text),
|
|
306
|
+
installKind: 'source',
|
|
307
|
+
tags: Array.isArray(repo.topics) ? repo.topics.slice(0, 6) : [],
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// GET /marketplace — curated global skill/plugin market plus added sources.
|
|
312
|
+
router.get('/marketplace', (req, res) => {
|
|
313
|
+
try {
|
|
314
|
+
res.json({
|
|
315
|
+
entries: CURATED_MARKETPLACE_ENTRIES,
|
|
316
|
+
installedSources: readMarketplaceSources(),
|
|
317
|
+
categories: ['popular', 'skills', 'plugin', 'workflow', 'new'],
|
|
318
|
+
});
|
|
319
|
+
} catch (err) {
|
|
320
|
+
res.status(500).json({ error: 'Failed to read marketplace', details: err.message });
|
|
321
|
+
}
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
// GET /marketplace/search?q=... — live GitHub repository discovery for skill/plugin sources.
|
|
325
|
+
router.get('/marketplace/search', async (req, res) => {
|
|
326
|
+
const q = String(req.query.q || '').trim().slice(0, 120);
|
|
327
|
+
const sort = req.query.sort === 'new' ? 'updated' : 'stars';
|
|
328
|
+
const baseQuery = q || 'agent skills plugin cli claude codex cursor gemini opencode';
|
|
329
|
+
const searchQuery = `${baseQuery} in:name,description,readme`;
|
|
330
|
+
const controller = new AbortController();
|
|
331
|
+
const timeout = setTimeout(() => controller.abort(), MARKETPLACE_SEARCH_TIMEOUT_MS);
|
|
332
|
+
|
|
333
|
+
try {
|
|
334
|
+
const url = new URL('https://api.github.com/search/repositories');
|
|
335
|
+
url.searchParams.set('q', searchQuery);
|
|
336
|
+
url.searchParams.set('sort', sort);
|
|
337
|
+
url.searchParams.set('order', 'desc');
|
|
338
|
+
url.searchParams.set('per_page', '12');
|
|
339
|
+
const response = await fetch(url, {
|
|
340
|
+
signal: controller.signal,
|
|
341
|
+
headers: {
|
|
342
|
+
accept: 'application/vnd.github+json',
|
|
343
|
+
'user-agent': 'pixcode-marketplace',
|
|
344
|
+
},
|
|
345
|
+
});
|
|
346
|
+
if (!response.ok) {
|
|
347
|
+
const detail = await response.text().catch(() => '');
|
|
348
|
+
return res.status(response.status).json({
|
|
349
|
+
error: 'GitHub search failed',
|
|
350
|
+
details: detail.slice(0, 500) || response.statusText,
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const body = await response.json();
|
|
355
|
+
const entries = Array.isArray(body?.items)
|
|
356
|
+
? body.items.map((repo) => githubRepoToMarketplaceEntry(repo, sort === 'updated' ? 'new' : 'popular'))
|
|
357
|
+
: [];
|
|
358
|
+
res.json({ entries });
|
|
359
|
+
} catch (err) {
|
|
360
|
+
const status = err?.name === 'AbortError' ? 504 : 502;
|
|
361
|
+
res.status(status).json({ error: 'GitHub search failed', details: err.message });
|
|
362
|
+
} finally {
|
|
363
|
+
clearTimeout(timeout);
|
|
364
|
+
}
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
// POST /marketplace/sources — add a global cross-CLI skill/plugin source.
|
|
368
|
+
router.post('/marketplace/sources', (req, res) => {
|
|
369
|
+
try {
|
|
370
|
+
const source = normalizeMarketplaceSource(req.body?.entry || req.body || {});
|
|
371
|
+
const sources = readMarketplaceSources();
|
|
372
|
+
const existingIndex = sources.findIndex((item) => item.id === source.id || item.sourceUrl === source.sourceUrl);
|
|
373
|
+
if (existingIndex >= 0) {
|
|
374
|
+
sources[existingIndex] = { ...sources[existingIndex], ...source, addedAt: sources[existingIndex].addedAt || source.addedAt };
|
|
375
|
+
} else {
|
|
376
|
+
sources.unshift(source);
|
|
377
|
+
}
|
|
378
|
+
writeMarketplaceSources(sources.slice(0, 200));
|
|
379
|
+
res.json({ success: true, source, installedSources: readMarketplaceSources() });
|
|
380
|
+
} catch (err) {
|
|
381
|
+
res.status(400).json({ error: 'Failed to add source', details: err.message });
|
|
382
|
+
}
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
// DELETE /marketplace/sources/:id — remove a global source by id.
|
|
386
|
+
router.delete('/marketplace/sources/:id', (req, res) => {
|
|
387
|
+
try {
|
|
388
|
+
const id = String(req.params.id || '');
|
|
389
|
+
const sources = readMarketplaceSources();
|
|
390
|
+
const next = sources.filter((source) => source.id !== id);
|
|
391
|
+
writeMarketplaceSources(next);
|
|
392
|
+
res.json({ success: true, installedSources: next });
|
|
393
|
+
} catch (err) {
|
|
394
|
+
res.status(400).json({ error: 'Failed to remove source', details: err.message });
|
|
395
|
+
}
|
|
396
|
+
});
|
|
27
397
|
|
|
28
398
|
// GET / — List all installed plugins (includes server running status)
|
|
29
399
|
router.get('/', (req, res) => {
|
|
@@ -44,6 +44,7 @@ let restoreInFlight = null;
|
|
|
44
44
|
let tunnelState = {
|
|
45
45
|
running: false,
|
|
46
46
|
binary: null, // 'cloudflared' | 'ngrok'
|
|
47
|
+
provider: null, // 'cloudflare' | 'ngrok'
|
|
47
48
|
url: null,
|
|
48
49
|
error: null,
|
|
49
50
|
installHint: null,
|
|
@@ -55,7 +56,7 @@ let tunnelState = {
|
|
|
55
56
|
const DEFAULT_TUNNEL_PREFERENCE = Object.freeze({
|
|
56
57
|
desired: false,
|
|
57
58
|
port: null,
|
|
58
|
-
provider:
|
|
59
|
+
provider: 'cloudflare',
|
|
59
60
|
lastUrl: null,
|
|
60
61
|
lastStartedAt: null,
|
|
61
62
|
lastStoppedAt: null,
|
|
@@ -97,8 +98,14 @@ const appendLog = (line) => {
|
|
|
97
98
|
if (tunnelState.log.length > 200) tunnelState.log.shift();
|
|
98
99
|
};
|
|
99
100
|
|
|
100
|
-
const
|
|
101
|
-
|
|
101
|
+
const normalizeTunnelProvider = (provider) => (provider === 'ngrok' ? 'ngrok' : 'cloudflare');
|
|
102
|
+
|
|
103
|
+
const tunnelProviderBinary = (provider) => (normalizeTunnelProvider(provider) === 'ngrok' ? 'ngrok' : 'cloudflared');
|
|
104
|
+
|
|
105
|
+
const tunnelBinaryProvider = (binary) => (binary === 'ngrok' ? 'ngrok' : 'cloudflare');
|
|
106
|
+
|
|
107
|
+
const detectBinary = async (provider = null) => {
|
|
108
|
+
const candidates = provider ? [tunnelProviderBinary(provider)] : ['cloudflared', 'ngrok'];
|
|
102
109
|
for (const name of candidates) {
|
|
103
110
|
try {
|
|
104
111
|
// `which` isn't guaranteed on Windows; we probe with `--version` instead
|
|
@@ -116,17 +123,31 @@ const detectBinary = async () => {
|
|
|
116
123
|
return null;
|
|
117
124
|
};
|
|
118
125
|
|
|
119
|
-
const createTunnelInstallHint = () =>
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
126
|
+
const createTunnelInstallHint = (provider = 'cloudflare') => {
|
|
127
|
+
if (normalizeTunnelProvider(provider) === 'ngrok') {
|
|
128
|
+
return {
|
|
129
|
+
title: 'ngrok binary required',
|
|
130
|
+
message: 'Install and authenticate ngrok to create a public mobile URL. Local LAN QR codes still work on the same Wi-Fi/network.',
|
|
131
|
+
commands: [
|
|
132
|
+
'macOS: brew install ngrok/ngrok/ngrok',
|
|
133
|
+
'Windows: winget install ngrok.ngrok',
|
|
134
|
+
'Linux: install ngrok from https://ngrok.com/download',
|
|
135
|
+
],
|
|
136
|
+
docsUrl: 'https://ngrok.com/download',
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
title: 'Cloudflare Tunnel binary required',
|
|
142
|
+
message: 'Install cloudflared to create a public Cloudflare URL. Local LAN QR codes still work on the same Wi-Fi/network.',
|
|
143
|
+
commands: [
|
|
144
|
+
'macOS: brew install cloudflared',
|
|
145
|
+
'Windows: winget install Cloudflare.cloudflared',
|
|
146
|
+
'Linux: install cloudflared from https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/',
|
|
147
|
+
],
|
|
148
|
+
docsUrl: 'https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/',
|
|
149
|
+
};
|
|
150
|
+
};
|
|
130
151
|
|
|
131
152
|
const cloudflareUrlRegex = /https?:\/\/[a-z0-9.-]+trycloudflare\.com/i;
|
|
132
153
|
const ngrokUrlRegex = /https?:\/\/[a-z0-9.-]+\.ngrok(-free)?\.(app|io)/i;
|
|
@@ -143,13 +164,14 @@ const extractUrl = (binary, text) => {
|
|
|
143
164
|
return null;
|
|
144
165
|
};
|
|
145
166
|
|
|
146
|
-
export const startTunnel = async ({ port, persistPreference = false, restoring = false } = {}) => {
|
|
167
|
+
export const startTunnel = async ({ port, provider: requestedProvider = 'cloudflare', persistPreference = false, restoring = false } = {}) => {
|
|
168
|
+
const provider = normalizeTunnelProvider(requestedProvider);
|
|
147
169
|
if (tunnelProc) {
|
|
148
170
|
if (persistPreference) {
|
|
149
171
|
await persistTunnelPreference({
|
|
150
172
|
desired: true,
|
|
151
173
|
port,
|
|
152
|
-
provider: tunnelState.binary,
|
|
174
|
+
provider: tunnelState.provider || tunnelBinaryProvider(tunnelState.binary),
|
|
153
175
|
lastUrl: tunnelState.url,
|
|
154
176
|
});
|
|
155
177
|
tunnelState = { ...tunnelState, desired: true, restoring: false };
|
|
@@ -158,20 +180,21 @@ export const startTunnel = async ({ port, persistPreference = false, restoring =
|
|
|
158
180
|
}
|
|
159
181
|
suppressNextTunnelRestore = false;
|
|
160
182
|
|
|
161
|
-
const binary = await detectBinary();
|
|
183
|
+
const binary = await detectBinary(provider);
|
|
162
184
|
if (!binary) {
|
|
163
|
-
const installHint = createTunnelInstallHint();
|
|
185
|
+
const installHint = createTunnelInstallHint(provider);
|
|
164
186
|
tunnelState = {
|
|
165
187
|
running: false,
|
|
166
188
|
binary: null,
|
|
189
|
+
provider,
|
|
167
190
|
url: null,
|
|
168
|
-
error: '
|
|
191
|
+
error: provider === 'cloudflare' ? 'cloudflared was not found' : 'ngrok was not found',
|
|
169
192
|
installHint,
|
|
170
193
|
desired: Boolean(persistPreference || restoring),
|
|
171
194
|
restoring,
|
|
172
195
|
log: [],
|
|
173
196
|
};
|
|
174
|
-
const err = new Error(
|
|
197
|
+
const err = new Error(tunnelState.error);
|
|
175
198
|
err.code = 'ENOENT_TUNNEL';
|
|
176
199
|
err.installHint = installHint;
|
|
177
200
|
throw err;
|
|
@@ -183,6 +206,7 @@ export const startTunnel = async ({ port, persistPreference = false, restoring =
|
|
|
183
206
|
tunnelState = {
|
|
184
207
|
running: true,
|
|
185
208
|
binary,
|
|
209
|
+
provider,
|
|
186
210
|
url: null,
|
|
187
211
|
error: null,
|
|
188
212
|
installHint: null,
|
|
@@ -207,6 +231,7 @@ export const startTunnel = async ({ port, persistPreference = false, restoring =
|
|
|
207
231
|
tunnelState = {
|
|
208
232
|
running: false,
|
|
209
233
|
binary,
|
|
234
|
+
provider,
|
|
210
235
|
url: null,
|
|
211
236
|
error: code === 0 ? null : `Tunnel exited with code ${code}`,
|
|
212
237
|
installHint: null,
|
|
@@ -242,7 +267,7 @@ export const startTunnel = async ({ port, persistPreference = false, restoring =
|
|
|
242
267
|
await persistTunnelPreference({
|
|
243
268
|
desired: true,
|
|
244
269
|
port,
|
|
245
|
-
provider
|
|
270
|
+
provider,
|
|
246
271
|
lastUrl: tunnelState.url,
|
|
247
272
|
lastStartedAt: new Date().toISOString(),
|
|
248
273
|
});
|
|
@@ -284,6 +309,7 @@ export const stopTunnel = async ({ persistPreference = true } = {}) => {
|
|
|
284
309
|
tunnelState = {
|
|
285
310
|
running: false,
|
|
286
311
|
binary: null,
|
|
312
|
+
provider: null,
|
|
287
313
|
url: null,
|
|
288
314
|
error: null,
|
|
289
315
|
installHint: null,
|
|
@@ -302,6 +328,7 @@ export const stopTunnel = async ({ persistPreference = true } = {}) => {
|
|
|
302
328
|
tunnelState = {
|
|
303
329
|
running: false,
|
|
304
330
|
binary: null,
|
|
331
|
+
provider: null,
|
|
305
332
|
url: null,
|
|
306
333
|
error: null,
|
|
307
334
|
installHint: null,
|
|
@@ -341,6 +368,7 @@ export const restoreRequestedTunnel = async ({ port } = {}) => {
|
|
|
341
368
|
try {
|
|
342
369
|
return await startTunnel({
|
|
343
370
|
port: restorePort,
|
|
371
|
+
provider: preference.provider || 'cloudflare',
|
|
344
372
|
persistPreference: true,
|
|
345
373
|
restoring: true,
|
|
346
374
|
});
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
import { appConfigDb, userDb } from '../database/db.js';
|
|
4
|
+
|
|
5
|
+
const CONFIG_KEY = 'qr_login_settings';
|
|
6
|
+
const DEFAULT_SETTINGS = Object.freeze({
|
|
7
|
+
enabled: false,
|
|
8
|
+
ttlSeconds: 300,
|
|
9
|
+
});
|
|
10
|
+
const MIN_TTL_SECONDS = 60;
|
|
11
|
+
const MAX_TTL_SECONDS = 15 * 60;
|
|
12
|
+
const qrLoginTokens = new Map();
|
|
13
|
+
|
|
14
|
+
function clampTtl(value) {
|
|
15
|
+
const parsed = Number.parseInt(String(value || ''), 10);
|
|
16
|
+
if (!Number.isFinite(parsed)) return DEFAULT_SETTINGS.ttlSeconds;
|
|
17
|
+
return Math.min(MAX_TTL_SECONDS, Math.max(MIN_TTL_SECONDS, parsed));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function normalizeSettings(value = {}) {
|
|
21
|
+
const raw = value && typeof value === 'object' ? value : {};
|
|
22
|
+
return {
|
|
23
|
+
enabled: raw.enabled === true,
|
|
24
|
+
ttlSeconds: clampTtl(raw.ttlSeconds),
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function readStoredSettings() {
|
|
29
|
+
const raw = appConfigDb.get(CONFIG_KEY);
|
|
30
|
+
if (!raw) return { ...DEFAULT_SETTINGS };
|
|
31
|
+
try {
|
|
32
|
+
return normalizeSettings(JSON.parse(raw));
|
|
33
|
+
} catch {
|
|
34
|
+
return { ...DEFAULT_SETTINGS };
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function cleanupExpiredTokens() {
|
|
39
|
+
const now = Date.now();
|
|
40
|
+
for (const [token, entry] of qrLoginTokens.entries()) {
|
|
41
|
+
if (!entry || entry.expiresAtMs <= now) {
|
|
42
|
+
qrLoginTokens.delete(token);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function appendQrLoginToken(baseUrl, token) {
|
|
48
|
+
const url = new URL(baseUrl);
|
|
49
|
+
url.searchParams.set('qrLoginToken', token);
|
|
50
|
+
return url.toString();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function getQrLoginSettings() {
|
|
54
|
+
return readStoredSettings();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function saveQrLoginSettings(input = {}) {
|
|
58
|
+
const settings = normalizeSettings(input);
|
|
59
|
+
appConfigDb.set(CONFIG_KEY, JSON.stringify(settings));
|
|
60
|
+
if (!settings.enabled) {
|
|
61
|
+
qrLoginTokens.clear();
|
|
62
|
+
}
|
|
63
|
+
return settings;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function createQrLoginToken({ userId, baseUrl }) {
|
|
67
|
+
const settings = readStoredSettings();
|
|
68
|
+
if (!settings.enabled) {
|
|
69
|
+
const error = new Error('QR login is disabled.');
|
|
70
|
+
error.code = 'QR_LOGIN_DISABLED';
|
|
71
|
+
throw error;
|
|
72
|
+
}
|
|
73
|
+
const user = userDb.getUserById(userId);
|
|
74
|
+
if (!user) {
|
|
75
|
+
const error = new Error('QR login user was not found.');
|
|
76
|
+
error.code = 'QR_LOGIN_USER_NOT_FOUND';
|
|
77
|
+
throw error;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
cleanupExpiredTokens();
|
|
81
|
+
const token = crypto.randomBytes(32).toString('base64url');
|
|
82
|
+
const expiresAtMs = Date.now() + settings.ttlSeconds * 1000;
|
|
83
|
+
qrLoginTokens.set(token, {
|
|
84
|
+
userId: user.id,
|
|
85
|
+
createdAtMs: Date.now(),
|
|
86
|
+
expiresAtMs,
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
token,
|
|
91
|
+
expiresAt: new Date(expiresAtMs).toISOString(),
|
|
92
|
+
ttlSeconds: settings.ttlSeconds,
|
|
93
|
+
qrUrl: baseUrl ? appendQrLoginToken(baseUrl, token) : null,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function consumeQrLoginToken(token) {
|
|
98
|
+
const settings = readStoredSettings();
|
|
99
|
+
if (!settings.enabled) {
|
|
100
|
+
const error = new Error('QR login is disabled.');
|
|
101
|
+
error.code = 'QR_LOGIN_DISABLED';
|
|
102
|
+
throw error;
|
|
103
|
+
}
|
|
104
|
+
if (!token || typeof token !== 'string') {
|
|
105
|
+
const error = new Error('QR login token is required.');
|
|
106
|
+
error.code = 'QR_LOGIN_TOKEN_REQUIRED';
|
|
107
|
+
throw error;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
cleanupExpiredTokens();
|
|
111
|
+
const entry = qrLoginTokens.get(token);
|
|
112
|
+
qrLoginTokens.delete(token);
|
|
113
|
+
if (!entry) {
|
|
114
|
+
const error = new Error('QR login token is invalid or expired.');
|
|
115
|
+
error.code = 'QR_LOGIN_INVALID';
|
|
116
|
+
throw error;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const user = userDb.getUserById(entry.userId);
|
|
120
|
+
if (!user) {
|
|
121
|
+
const error = new Error('QR login user was not found.');
|
|
122
|
+
error.code = 'QR_LOGIN_USER_NOT_FOUND';
|
|
123
|
+
throw error;
|
|
124
|
+
}
|
|
125
|
+
return user;
|
|
126
|
+
}
|