@pixelbyte-software/pixcode 1.33.9 → 1.33.11
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/api-docs.html +373 -857
- package/dist/assets/{index-DpIcI9Q1.js → index-oLYHJ2X5.js} +154 -166
- package/dist/index.html +1 -1
- package/dist/openapi.yaml +1311 -0
- package/dist-server/server/gemini-cli.js +59 -0
- package/dist-server/server/gemini-cli.js.map +1 -1
- package/dist-server/server/index.js +6 -1
- package/dist-server/server/index.js.map +1 -1
- package/dist-server/server/middleware/auth.js +51 -9
- package/dist-server/server/middleware/auth.js.map +1 -1
- package/dist-server/server/modules/providers/list/opencode/opencode-sessions.provider.js +54 -15
- package/dist-server/server/modules/providers/list/opencode/opencode-sessions.provider.js.map +1 -1
- package/dist-server/server/modules/providers/list/qwen/qwen-sessions.provider.js +46 -0
- package/dist-server/server/modules/providers/list/qwen/qwen-sessions.provider.js.map +1 -1
- package/dist-server/server/modules/providers/provider.routes.js +32 -1
- package/dist-server/server/modules/providers/provider.routes.js.map +1 -1
- package/dist-server/server/opencode-cli.js +41 -2
- package/dist-server/server/opencode-cli.js.map +1 -1
- package/dist-server/server/opencode-response-handler.js +36 -34
- package/dist-server/server/opencode-response-handler.js.map +1 -1
- package/dist-server/server/routes/agent.js +187 -56
- package/dist-server/server/routes/agent.js.map +1 -1
- package/dist-server/server/routes/projects.js +134 -8
- package/dist-server/server/routes/projects.js.map +1 -1
- package/dist-server/server/services/provider-credentials.js +42 -8
- package/dist-server/server/services/provider-credentials.js.map +1 -1
- package/package.json +1 -1
- package/server/gemini-cli.js +60 -0
- package/server/index.js +6 -1
- package/server/middleware/auth.js +50 -9
- package/server/modules/providers/list/opencode/opencode-sessions.provider.ts +60 -21
- package/server/modules/providers/list/qwen/qwen-sessions.provider.ts +47 -0
- package/server/modules/providers/provider.routes.ts +37 -4
- package/server/opencode-cli.js +41 -2
- package/server/opencode-response-handler.js +36 -29
- package/server/routes/agent.js +178 -58
- package/server/routes/projects.js +136 -8
- package/server/services/provider-credentials.js +42 -8
package/server/routes/agent.js
CHANGED
|
@@ -10,6 +10,8 @@ import { queryClaudeSDK } from '../claude-sdk.js';
|
|
|
10
10
|
import { spawnCursor } from '../cursor-cli.js';
|
|
11
11
|
import { queryCodex } from '../openai-codex.js';
|
|
12
12
|
import { spawnGemini } from '../gemini-cli.js';
|
|
13
|
+
import { spawnQwen } from '../qwen-code-cli.js';
|
|
14
|
+
import { spawnOpencode } from '../opencode-cli.js';
|
|
13
15
|
import { Octokit } from '@octokit/rest';
|
|
14
16
|
import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS } from '../../shared/modelConstants.js';
|
|
15
17
|
import { IS_PLATFORM } from '../constants/config.js';
|
|
@@ -44,11 +46,19 @@ const validateExternalApiKey = (req, res, next) => {
|
|
|
44
46
|
}
|
|
45
47
|
}
|
|
46
48
|
|
|
47
|
-
// Self-hosted mode:
|
|
48
|
-
|
|
49
|
+
// Self-hosted mode: validate API key from any of the supported transports.
|
|
50
|
+
// - Authorization: Bearer ck_... (added so /api/agent accepts the same
|
|
51
|
+
// auth shape as the rest of the API, per the auth-unify in this turn)
|
|
52
|
+
// - X-API-Key: ck_... (legacy, kept working)
|
|
53
|
+
// - ?apiKey=ck_... (EventSource workaround)
|
|
54
|
+
const authHeader = req.headers['authorization'];
|
|
55
|
+
const bearer = authHeader && authHeader.startsWith('Bearer ') ? authHeader.slice(7).trim() : null;
|
|
56
|
+
const apiKey = (bearer && bearer.startsWith('ck_') ? bearer : null)
|
|
57
|
+
|| req.headers['x-api-key']
|
|
58
|
+
|| (typeof req.query.apiKey === 'string' ? req.query.apiKey : null);
|
|
49
59
|
|
|
50
60
|
if (!apiKey) {
|
|
51
|
-
return res.status(401).json({ error: 'API key required' });
|
|
61
|
+
return res.status(401).json({ error: 'API key required (Authorization: Bearer ck_..., X-API-Key, or ?apiKey=)' });
|
|
52
62
|
}
|
|
53
63
|
|
|
54
64
|
const user = apiKeysDb.validateApiKey(apiKey);
|
|
@@ -529,74 +539,129 @@ class ResponseCollector {
|
|
|
529
539
|
}
|
|
530
540
|
|
|
531
541
|
/**
|
|
532
|
-
* Get filtered assistant messages
|
|
542
|
+
* Get filtered assistant messages.
|
|
543
|
+
*
|
|
544
|
+
* Two message shapes are observed in the wild:
|
|
545
|
+
* 1. Legacy Claude-only: { type:'claude-response', data:{ type:'assistant', message:{...} } }
|
|
546
|
+
* 2. Unified normalized: { kind:'stream_delta'|'tool_use'|... , provider, content, ... }
|
|
547
|
+
* (every provider after the v1.30+ unified-message migration emits this)
|
|
548
|
+
*
|
|
549
|
+
* Pre-fix this method only matched (1), so qwen / gemini / opencode / codex
|
|
550
|
+
* runs all returned an empty array even when the provider streamed real
|
|
551
|
+
* text. Now it builds:
|
|
552
|
+
* - one synthetic assistant entry per chat turn from concatenated
|
|
553
|
+
* `stream_delta` content (boundary = `stream_end` or `complete`)
|
|
554
|
+
* - tool_use / tool_result entries pass through verbatim
|
|
533
555
|
*/
|
|
534
556
|
getAssistantMessages() {
|
|
535
|
-
const
|
|
557
|
+
const out = [];
|
|
558
|
+
let textBuffer = '';
|
|
559
|
+
|
|
560
|
+
const flushText = () => {
|
|
561
|
+
if (!textBuffer) return;
|
|
562
|
+
out.push({
|
|
563
|
+
type: 'assistant',
|
|
564
|
+
message: {
|
|
565
|
+
role: 'assistant',
|
|
566
|
+
content: [{ type: 'text', text: textBuffer }],
|
|
567
|
+
},
|
|
568
|
+
});
|
|
569
|
+
textBuffer = '';
|
|
570
|
+
};
|
|
536
571
|
|
|
537
|
-
for (const
|
|
538
|
-
|
|
539
|
-
|
|
572
|
+
for (const raw of this.messages) {
|
|
573
|
+
const data = typeof raw === 'string'
|
|
574
|
+
? (() => { try { return JSON.parse(raw); } catch { return null; } })()
|
|
575
|
+
: raw;
|
|
576
|
+
if (!data) continue;
|
|
577
|
+
if (data.type === 'status') continue;
|
|
578
|
+
|
|
579
|
+
// Unified shape (every modern provider).
|
|
580
|
+
// - `stream_delta`: incremental text chunk (most providers)
|
|
581
|
+
// - `text`: full text part for one assistant turn (Claude SDK + history reads)
|
|
582
|
+
// - `thinking`: reasoning blocks; we coalesce as plain text so the API caller sees something
|
|
583
|
+
if ((data.kind === 'stream_delta' || data.kind === 'text' || data.kind === 'thinking')
|
|
584
|
+
&& (typeof data.content === 'string' || Array.isArray(data.content))) {
|
|
585
|
+
const text = typeof data.content === 'string'
|
|
586
|
+
? data.content
|
|
587
|
+
: data.content.map((part) => (typeof part === 'string' ? part : (part?.text || ''))).join('');
|
|
588
|
+
textBuffer += text;
|
|
589
|
+
continue;
|
|
590
|
+
}
|
|
591
|
+
if (data.kind === 'stream_end' || data.kind === 'complete') {
|
|
592
|
+
flushText();
|
|
593
|
+
continue;
|
|
594
|
+
}
|
|
595
|
+
if (data.kind === 'tool_use') {
|
|
596
|
+
flushText();
|
|
597
|
+
out.push({ type: 'tool_use', id: data.toolId, name: data.toolName, input: data.toolInput });
|
|
598
|
+
continue;
|
|
599
|
+
}
|
|
600
|
+
if (data.kind === 'tool_result') {
|
|
601
|
+
out.push({ type: 'tool_result', tool_use_id: data.toolId, content: data.content, is_error: data.isError });
|
|
602
|
+
continue;
|
|
603
|
+
}
|
|
604
|
+
if (data.kind === 'error' && typeof data.content === 'string') {
|
|
605
|
+
flushText();
|
|
606
|
+
out.push({ type: 'error', content: data.content });
|
|
540
607
|
continue;
|
|
541
608
|
}
|
|
542
609
|
|
|
543
|
-
//
|
|
544
|
-
if (
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
// Only include claude-response messages with assistant type
|
|
548
|
-
if (parsed.type === 'claude-response' && parsed.data && parsed.data.type === 'assistant') {
|
|
549
|
-
assistantMessages.push(parsed.data);
|
|
550
|
-
}
|
|
551
|
-
} catch (e) {
|
|
552
|
-
// Not JSON, skip
|
|
553
|
-
}
|
|
610
|
+
// Legacy Claude shape — kept so old SDK builds still report cleanly.
|
|
611
|
+
if (data.type === 'claude-response' && data.data && data.data.type === 'assistant') {
|
|
612
|
+
flushText();
|
|
613
|
+
out.push(data.data);
|
|
554
614
|
}
|
|
555
615
|
}
|
|
556
|
-
|
|
557
|
-
return
|
|
616
|
+
flushText();
|
|
617
|
+
return out;
|
|
558
618
|
}
|
|
559
619
|
|
|
560
620
|
/**
|
|
561
|
-
* Calculate total tokens from all messages
|
|
621
|
+
* Calculate total tokens from all messages.
|
|
622
|
+
*
|
|
623
|
+
* Two usage shapes observed:
|
|
624
|
+
* 1. Legacy Claude: { type:'claude-response', data:{ message:{ usage:{ input_tokens, output_tokens, cache_*_input_tokens } } } }
|
|
625
|
+
* 2. Unified `complete`/ { kind:'complete'|'stream_end', usage:{ input, output, cacheRead?, cacheCreation? }, cost? }
|
|
626
|
+
* `stream_end` events
|
|
562
627
|
*/
|
|
563
628
|
getTotalTokens() {
|
|
564
|
-
let
|
|
565
|
-
let
|
|
566
|
-
let
|
|
567
|
-
let
|
|
568
|
-
|
|
569
|
-
for (const
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
if (
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
629
|
+
let inputTokens = 0;
|
|
630
|
+
let outputTokens = 0;
|
|
631
|
+
let cacheReadTokens = 0;
|
|
632
|
+
let cacheCreationTokens = 0;
|
|
633
|
+
|
|
634
|
+
for (const raw of this.messages) {
|
|
635
|
+
const data = typeof raw === 'string'
|
|
636
|
+
? (() => { try { return JSON.parse(raw); } catch { return null; } })()
|
|
637
|
+
: raw;
|
|
638
|
+
if (!data) continue;
|
|
639
|
+
|
|
640
|
+
// Unified shape
|
|
641
|
+
if (data.usage && typeof data.usage === 'object') {
|
|
642
|
+
inputTokens += data.usage.input || data.usage.inputTokens || data.usage.input_tokens || 0;
|
|
643
|
+
outputTokens += data.usage.output || data.usage.outputTokens || data.usage.output_tokens || 0;
|
|
644
|
+
cacheReadTokens += data.usage.cacheRead || data.usage.cache_read_input_tokens || 0;
|
|
645
|
+
cacheCreationTokens += data.usage.cacheCreation || data.usage.cache_creation_input_tokens || 0;
|
|
646
|
+
continue;
|
|
579
647
|
}
|
|
580
648
|
|
|
581
|
-
//
|
|
582
|
-
if (data
|
|
583
|
-
const
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
totalCacheRead += usage.cache_read_input_tokens || 0;
|
|
589
|
-
totalCacheCreation += usage.cache_creation_input_tokens || 0;
|
|
590
|
-
}
|
|
649
|
+
// Legacy Claude
|
|
650
|
+
if (data.type === 'claude-response' && data.data && data.data.message && data.data.message.usage) {
|
|
651
|
+
const u = data.data.message.usage;
|
|
652
|
+
inputTokens += u.input_tokens || 0;
|
|
653
|
+
outputTokens += u.output_tokens || 0;
|
|
654
|
+
cacheReadTokens += u.cache_read_input_tokens || 0;
|
|
655
|
+
cacheCreationTokens += u.cache_creation_input_tokens || 0;
|
|
591
656
|
}
|
|
592
657
|
}
|
|
593
658
|
|
|
594
659
|
return {
|
|
595
|
-
inputTokens
|
|
596
|
-
outputTokens
|
|
597
|
-
cacheReadTokens
|
|
598
|
-
cacheCreationTokens
|
|
599
|
-
totalTokens:
|
|
660
|
+
inputTokens,
|
|
661
|
+
outputTokens,
|
|
662
|
+
cacheReadTokens,
|
|
663
|
+
cacheCreationTokens,
|
|
664
|
+
totalTokens: inputTokens + outputTokens + cacheReadTokens + cacheCreationTokens,
|
|
600
665
|
};
|
|
601
666
|
}
|
|
602
667
|
}
|
|
@@ -859,8 +924,8 @@ router.post('/', validateExternalApiKey, async (req, res) => {
|
|
|
859
924
|
return res.status(400).json({ error: 'message is required' });
|
|
860
925
|
}
|
|
861
926
|
|
|
862
|
-
if (!['claude', 'cursor', 'codex', 'gemini'].includes(provider)) {
|
|
863
|
-
return res.status(400).json({ error: 'provider must be
|
|
927
|
+
if (!['claude', 'cursor', 'codex', 'gemini', 'qwen', 'opencode'].includes(provider)) {
|
|
928
|
+
return res.status(400).json({ error: 'provider must be one of: claude, cursor, codex, gemini, qwen, opencode' });
|
|
864
929
|
}
|
|
865
930
|
|
|
866
931
|
// Validate GitHub branch/PR creation requirements
|
|
@@ -985,6 +1050,27 @@ router.post('/', validateExternalApiKey, async (req, res) => {
|
|
|
985
1050
|
model: model,
|
|
986
1051
|
skipPermissions: true // CLI mode bypasses permissions
|
|
987
1052
|
}, writer);
|
|
1053
|
+
} else if (provider === 'qwen') {
|
|
1054
|
+
console.log('🐉 Starting Qwen Code CLI session');
|
|
1055
|
+
|
|
1056
|
+
await spawnQwen(message.trim(), {
|
|
1057
|
+
projectPath: finalProjectPath,
|
|
1058
|
+
cwd: finalProjectPath,
|
|
1059
|
+
sessionId: sessionId || null,
|
|
1060
|
+
model: model,
|
|
1061
|
+
skipPermissions: true,
|
|
1062
|
+
}, writer);
|
|
1063
|
+
} else if (provider === 'opencode') {
|
|
1064
|
+
console.log('🅾️ Starting OpenCode CLI session');
|
|
1065
|
+
|
|
1066
|
+
await spawnOpencode(message.trim(), {
|
|
1067
|
+
projectPath: finalProjectPath,
|
|
1068
|
+
cwd: finalProjectPath,
|
|
1069
|
+
sessionId: sessionId || null,
|
|
1070
|
+
model: model,
|
|
1071
|
+
permissionMode: 'bypassPermissions',
|
|
1072
|
+
toolsSettings: { allowPatterns: [], denyPatterns: [], skipPermissions: true },
|
|
1073
|
+
}, writer);
|
|
988
1074
|
}
|
|
989
1075
|
|
|
990
1076
|
// Handle GitHub branch and PR creation after successful agent completion
|
|
@@ -1177,13 +1263,30 @@ router.post('/', validateExternalApiKey, async (req, res) => {
|
|
|
1177
1263
|
const assistantMessages = writer.getAssistantMessages();
|
|
1178
1264
|
const tokenSummary = writer.getTotalTokens();
|
|
1179
1265
|
|
|
1266
|
+
// Promote provider-side errors (`writer.send({ kind:'error', ... })`)
|
|
1267
|
+
// to the response envelope. Without this, providers like Codex —
|
|
1268
|
+
// whose SDK swallows throws and only emits an error message — left
|
|
1269
|
+
// callers with `{ success:true, messages:[] }`, indistinguishable
|
|
1270
|
+
// from a quiet success. Now: any error event => success:false and
|
|
1271
|
+
// the human-readable text on `error`.
|
|
1272
|
+
const errorEntry = assistantMessages.find((m) => m.type === 'error');
|
|
1273
|
+
const hasAssistantText = assistantMessages.some(
|
|
1274
|
+
(m) => m.type === 'assistant' && m.message?.content?.some?.((p) => p.type === 'text' && p.text)
|
|
1275
|
+
);
|
|
1276
|
+
const succeeded = !errorEntry && (hasAssistantText || assistantMessages.some((m) => m.type === 'tool_use' || m.type === 'tool_result'));
|
|
1277
|
+
|
|
1180
1278
|
const response = {
|
|
1181
|
-
success:
|
|
1279
|
+
success: succeeded,
|
|
1182
1280
|
sessionId: writer.getSessionId(),
|
|
1183
1281
|
messages: assistantMessages,
|
|
1184
1282
|
tokens: tokenSummary,
|
|
1185
1283
|
projectPath: finalProjectPath
|
|
1186
1284
|
};
|
|
1285
|
+
if (errorEntry) {
|
|
1286
|
+
response.error = errorEntry.content;
|
|
1287
|
+
} else if (!succeeded) {
|
|
1288
|
+
response.error = 'Provider returned no assistant text. Check backend log for details.';
|
|
1289
|
+
}
|
|
1187
1290
|
|
|
1188
1291
|
// Add branch/PR info if created
|
|
1189
1292
|
if (branchInfo) {
|
|
@@ -1193,7 +1296,7 @@ router.post('/', validateExternalApiKey, async (req, res) => {
|
|
|
1193
1296
|
response.pullRequest = prInfo;
|
|
1194
1297
|
}
|
|
1195
1298
|
|
|
1196
|
-
res.json(response);
|
|
1299
|
+
res.status(succeeded ? 200 : 502).json(response);
|
|
1197
1300
|
}
|
|
1198
1301
|
|
|
1199
1302
|
// Clean up if requested
|
|
@@ -1234,9 +1337,26 @@ router.post('/', validateExternalApiKey, async (req, res) => {
|
|
|
1234
1337
|
writer.end();
|
|
1235
1338
|
}
|
|
1236
1339
|
} else if (!res.headersSent) {
|
|
1237
|
-
|
|
1340
|
+
// Surface any provider-side stderr/error events the writer collected
|
|
1341
|
+
// BEFORE the throw — without this, callers only see the bland
|
|
1342
|
+
// "Gemini CLI exited with code 403" wrapper and lose the actual
|
|
1343
|
+
// "PERMISSION_DENIED, model not enabled for this account" detail
|
|
1344
|
+
// that the CLI printed to stderr.
|
|
1345
|
+
let collectedError = null;
|
|
1346
|
+
let collectedMessages = [];
|
|
1347
|
+
if (writer && typeof writer.getAssistantMessages === 'function') {
|
|
1348
|
+
try {
|
|
1349
|
+
collectedMessages = writer.getAssistantMessages();
|
|
1350
|
+
const errEntry = collectedMessages.find((m) => m.type === 'error');
|
|
1351
|
+
if (errEntry) collectedError = errEntry.content;
|
|
1352
|
+
} catch { /* ignore — fall back to error.message */ }
|
|
1353
|
+
}
|
|
1354
|
+
res.status(502).json({
|
|
1238
1355
|
success: false,
|
|
1239
|
-
|
|
1356
|
+
sessionId: writer && typeof writer.getSessionId === 'function' ? writer.getSessionId() : null,
|
|
1357
|
+
error: collectedError || error.message,
|
|
1358
|
+
wrapperError: collectedError ? error.message : undefined,
|
|
1359
|
+
messages: collectedMessages,
|
|
1240
1360
|
});
|
|
1241
1361
|
}
|
|
1242
1362
|
}
|
|
@@ -18,7 +18,18 @@ export const WORKSPACES_BASE = path.resolve(
|
|
|
18
18
|
process.env.WORKSPACES_BASE || path.join(WORKSPACES_ROOT, 'pixcode', 'projects')
|
|
19
19
|
);
|
|
20
20
|
|
|
21
|
-
// System-critical paths that should never be used as workspace directories
|
|
21
|
+
// System-critical paths that should never be used as workspace directories.
|
|
22
|
+
// `/root` is conditional — included only when the server is NOT running as
|
|
23
|
+
// root. On a typical VPS deployment (`sudo` install, root-owned daemon)
|
|
24
|
+
// `/root` IS the user's home directory, blocking projects under it locks
|
|
25
|
+
// users out of their own filesystem. The carve-out below was meant to
|
|
26
|
+
// handle this but only allowed paths under WORKSPACES_BASE — users with
|
|
27
|
+
// `/root/foo` from before Pixcode existed couldn't open them.
|
|
28
|
+
const RUNNING_AS_ROOT =
|
|
29
|
+
process.platform !== 'win32' &&
|
|
30
|
+
typeof process.getuid === 'function' &&
|
|
31
|
+
process.getuid() === 0;
|
|
32
|
+
|
|
22
33
|
export const FORBIDDEN_PATHS = [
|
|
23
34
|
// Unix
|
|
24
35
|
'/',
|
|
@@ -31,7 +42,7 @@ export const FORBIDDEN_PATHS = [
|
|
|
31
42
|
'/sys',
|
|
32
43
|
'/var',
|
|
33
44
|
'/boot',
|
|
34
|
-
'/root',
|
|
45
|
+
...(RUNNING_AS_ROOT ? [] : ['/root']),
|
|
35
46
|
'/lib',
|
|
36
47
|
'/lib64',
|
|
37
48
|
'/opt',
|
|
@@ -413,15 +424,18 @@ router.post('/quick-start', async (req, res) => {
|
|
|
413
424
|
*/
|
|
414
425
|
router.post('/create-workspace', async (req, res) => {
|
|
415
426
|
try {
|
|
416
|
-
const { workspaceType, path: workspacePath, githubUrl, githubTokenId, newGithubToken } = req.body;
|
|
427
|
+
const { workspaceType, path: workspacePath, githubUrl, githubTokenId, newGithubToken, subfolderName } = req.body;
|
|
417
428
|
|
|
418
429
|
// Validate required fields
|
|
419
430
|
if (!workspaceType || !workspacePath) {
|
|
420
431
|
return res.status(400).json({ error: 'workspaceType and path are required' });
|
|
421
432
|
}
|
|
422
433
|
|
|
423
|
-
|
|
424
|
-
|
|
434
|
+
// 'existing' = open the picked folder as-is
|
|
435
|
+
// 'new' = clone a github repo into the picked folder (legacy name kept for client compat)
|
|
436
|
+
// 'subfolder'= create a fresh subfolder INSIDE the picked folder and open that
|
|
437
|
+
if (!['existing', 'new', 'subfolder'].includes(workspaceType)) {
|
|
438
|
+
return res.status(400).json({ error: 'workspaceType must be "existing", "new", or "subfolder"' });
|
|
425
439
|
}
|
|
426
440
|
|
|
427
441
|
// Validate path safety before any operations
|
|
@@ -452,13 +466,127 @@ router.post('/create-workspace', async (req, res) => {
|
|
|
452
466
|
throw error;
|
|
453
467
|
}
|
|
454
468
|
|
|
455
|
-
// Add the existing workspace to the project list
|
|
456
|
-
|
|
469
|
+
// Add the existing workspace to the project list. If the user picks
|
|
470
|
+
// a folder Pixcode has already registered (very common when bouncing
|
|
471
|
+
// between sessions or re-opening the wizard on the same project),
|
|
472
|
+
// `addProjectManually` throws "Project already configured…" — that
|
|
473
|
+
// used to surface as a hard error in the UI even though the right
|
|
474
|
+
// outcome is "great, let's just open it." Treat that one specific
|
|
475
|
+
// throw as a soft re-open and return a 200 with `alreadyExisted: true`
|
|
476
|
+
// so the wizard can show "Opened existing workspace" instead of the
|
|
477
|
+
// raw error message.
|
|
478
|
+
let project;
|
|
479
|
+
let alreadyExisted = false;
|
|
480
|
+
try {
|
|
481
|
+
project = await addProjectManually(absolutePath);
|
|
482
|
+
} catch (error) {
|
|
483
|
+
const msg = error?.message || '';
|
|
484
|
+
if (!/already configured/i.test(msg)) throw error;
|
|
485
|
+
alreadyExisted = true;
|
|
486
|
+
project = {
|
|
487
|
+
name: absolutePath.replace(/[\\/:]/g, '-').replace(/\./g, '-'),
|
|
488
|
+
path: absolutePath,
|
|
489
|
+
fullPath: absolutePath,
|
|
490
|
+
displayName: path.basename(absolutePath),
|
|
491
|
+
isManuallyAdded: true,
|
|
492
|
+
sessions: [],
|
|
493
|
+
cursorSessions: [],
|
|
494
|
+
};
|
|
495
|
+
}
|
|
457
496
|
|
|
458
497
|
return res.json({
|
|
459
498
|
success: true,
|
|
460
499
|
project,
|
|
461
|
-
|
|
500
|
+
alreadyExisted,
|
|
501
|
+
message: alreadyExisted
|
|
502
|
+
? 'Workspace was already registered — opening it'
|
|
503
|
+
: 'Existing workspace added successfully'
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// Handle subfolder creation: user picked a parent dir, we mkdir
|
|
508
|
+
// <parent>/<subfolderName> and open that.
|
|
509
|
+
if (workspaceType === 'subfolder') {
|
|
510
|
+
const trimmedName = typeof subfolderName === 'string' ? subfolderName.trim() : '';
|
|
511
|
+
if (!trimmedName) {
|
|
512
|
+
return res.status(400).json({ error: 'subfolderName is required when workspaceType is "subfolder"' });
|
|
513
|
+
}
|
|
514
|
+
// Reject path-traversal / nested separators / reserved names. The
|
|
515
|
+
// wizard's UI will only ever send a flat folder name; anything else
|
|
516
|
+
// is either a bug or someone fishing.
|
|
517
|
+
if (/[\\/]/.test(trimmedName) || trimmedName === '.' || trimmedName === '..') {
|
|
518
|
+
return res.status(400).json({ error: 'subfolderName must be a single folder name (no path separators)' });
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// Verify parent dir exists (we don't auto-create the picked parent —
|
|
522
|
+
// user already pointed at a real folder).
|
|
523
|
+
try {
|
|
524
|
+
const stats = await fs.stat(absolutePath);
|
|
525
|
+
if (!stats.isDirectory()) {
|
|
526
|
+
return res.status(400).json({ error: 'Parent path is not a directory' });
|
|
527
|
+
}
|
|
528
|
+
} catch (error) {
|
|
529
|
+
if (error.code === 'ENOENT') {
|
|
530
|
+
return res.status(404).json({ error: 'Parent directory does not exist' });
|
|
531
|
+
}
|
|
532
|
+
throw error;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
const childPath = path.join(absolutePath, trimmedName);
|
|
536
|
+
|
|
537
|
+
// Validate the resulting path too — don't let "subfolder=foo/../../etc"
|
|
538
|
+
// bypass the parent-only check above. validateWorkspacePath already
|
|
539
|
+
// rejects symlink escapes and FORBIDDEN_PATHS.
|
|
540
|
+
const childValidation = await validateWorkspacePath(childPath);
|
|
541
|
+
if (!childValidation.valid) {
|
|
542
|
+
return res.status(400).json({
|
|
543
|
+
error: 'Invalid subfolder path',
|
|
544
|
+
details: childValidation.error,
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
const childAbsolute = childValidation.resolvedPath;
|
|
548
|
+
|
|
549
|
+
// Refuse to clobber an existing folder with content — user can pick
|
|
550
|
+
// "existing" instead. Empty/missing → mkdir.
|
|
551
|
+
try {
|
|
552
|
+
const childEntries = await fs.readdir(childAbsolute);
|
|
553
|
+
if (childEntries.length > 0) {
|
|
554
|
+
return res.status(409).json({
|
|
555
|
+
error: 'Subfolder already exists and is not empty',
|
|
556
|
+
details: `Pick a different name or open "${childAbsolute}" as an existing workspace.`,
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
} catch (error) {
|
|
560
|
+
if (error.code !== 'ENOENT') throw error;
|
|
561
|
+
}
|
|
562
|
+
await fs.mkdir(childAbsolute, { recursive: true });
|
|
563
|
+
|
|
564
|
+
let subProject;
|
|
565
|
+
let subAlreadyExisted = false;
|
|
566
|
+
try {
|
|
567
|
+
subProject = await addProjectManually(childAbsolute);
|
|
568
|
+
} catch (error) {
|
|
569
|
+
const msg = error?.message || '';
|
|
570
|
+
if (!/already configured/i.test(msg)) throw error;
|
|
571
|
+
subAlreadyExisted = true;
|
|
572
|
+
subProject = {
|
|
573
|
+
name: childAbsolute.replace(/[\\/:]/g, '-').replace(/\./g, '-'),
|
|
574
|
+
path: childAbsolute,
|
|
575
|
+
fullPath: childAbsolute,
|
|
576
|
+
displayName: trimmedName,
|
|
577
|
+
isManuallyAdded: true,
|
|
578
|
+
sessions: [],
|
|
579
|
+
cursorSessions: [],
|
|
580
|
+
};
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
return res.json({
|
|
584
|
+
success: true,
|
|
585
|
+
project: subProject,
|
|
586
|
+
alreadyExisted: subAlreadyExisted,
|
|
587
|
+
message: subAlreadyExisted
|
|
588
|
+
? 'Subfolder was already registered — opening it'
|
|
589
|
+
: 'Subfolder created successfully',
|
|
462
590
|
});
|
|
463
591
|
}
|
|
464
592
|
|
|
@@ -20,17 +20,36 @@ import path from 'node:path';
|
|
|
20
20
|
const CONFIG_FILE = path.join(os.homedir(), '.pixcode', 'provider-credentials.json');
|
|
21
21
|
|
|
22
22
|
/**
|
|
23
|
-
* Map provider id → {apiKeyEnv, baseUrlEnv} so we know which env
|
|
24
|
-
* inject into the spawn env. Cursor is OAuth-only; it has no api-key
|
|
23
|
+
* Map provider id → {apiKeyEnv, baseUrlEnv, extraEnv?} so we know which env
|
|
24
|
+
* vars to inject into the spawn env. Cursor is OAuth-only; it has no api-key
|
|
25
|
+
* entry.
|
|
26
|
+
*
|
|
27
|
+
* `baseUrlEnv` lets users point a provider at any OpenAI-compatible (or
|
|
28
|
+
* Gemini-compatible) endpoint they want — third-party gateways, self-hosted
|
|
29
|
+
* proxies, OpenRouter, Together, etc. — without forking the CLI. The CLI
|
|
30
|
+
* picks the env var up natively because every supported CLI honours its
|
|
31
|
+
* vendor's standard variable names. **Don't rename these.** Pixcode is just
|
|
32
|
+
* a passthrough; people expect the same names that work outside Pixcode.
|
|
33
|
+
*
|
|
34
|
+
* `extraEnv` is a list of additional env-var names that should be mirrored
|
|
35
|
+
* across with the same value as `baseUrlEnv` — handy when a provider has
|
|
36
|
+
* historical aliases (e.g. Gemini's `GOOGLE_GEMINI_BASE_URL` vs newer
|
|
37
|
+
* `GEMINI_BASE_URL` clients).
|
|
25
38
|
*/
|
|
26
39
|
export const PROVIDER_ENV_VARS = Object.freeze({
|
|
27
40
|
claude: { apiKeyEnv: 'ANTHROPIC_API_KEY', baseUrlEnv: 'ANTHROPIC_BASE_URL' },
|
|
28
41
|
codex: { apiKeyEnv: 'OPENAI_API_KEY', baseUrlEnv: 'OPENAI_BASE_URL' },
|
|
29
|
-
gemini: {
|
|
42
|
+
gemini: {
|
|
43
|
+
apiKeyEnv: 'GEMINI_API_KEY',
|
|
44
|
+
baseUrlEnv: 'GOOGLE_GEMINI_BASE_URL',
|
|
45
|
+
// Some Gemini-API-compatible gateways pick up the shorter
|
|
46
|
+
// `GEMINI_BASE_URL` name; mirror so either client works.
|
|
47
|
+
extraBaseUrlEnv: ['GEMINI_BASE_URL'],
|
|
48
|
+
},
|
|
30
49
|
qwen: { apiKeyEnv: 'OPENAI_API_KEY', baseUrlEnv: 'OPENAI_BASE_URL' },
|
|
31
|
-
// OpenCode is multi-provider.
|
|
32
|
-
//
|
|
33
|
-
//
|
|
50
|
+
// OpenCode is multi-provider. Default-set ANTHROPIC_*, but ALSO mirror
|
|
51
|
+
// the same key into OPENAI_API_KEY when the user picks an OpenAI-flavour
|
|
52
|
+
// model — handled at spawn time in opencode-cli.js, not here.
|
|
34
53
|
opencode: { apiKeyEnv: 'ANTHROPIC_API_KEY', baseUrlEnv: 'ANTHROPIC_BASE_URL' },
|
|
35
54
|
});
|
|
36
55
|
|
|
@@ -98,7 +117,13 @@ export async function buildSpawnEnv(provider, baseEnv = process.env) {
|
|
|
98
117
|
if (!creds) return env;
|
|
99
118
|
|
|
100
119
|
if (envVars.apiKeyEnv) env[envVars.apiKeyEnv] = creds.apiKey;
|
|
101
|
-
if (envVars.baseUrlEnv && creds.baseUrl)
|
|
120
|
+
if (envVars.baseUrlEnv && creds.baseUrl) {
|
|
121
|
+
env[envVars.baseUrlEnv] = creds.baseUrl;
|
|
122
|
+
// Mirror to alias env-var names so clients that read either work.
|
|
123
|
+
for (const alias of envVars.extraBaseUrlEnv || []) {
|
|
124
|
+
env[alias] = creds.baseUrl;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
102
127
|
return env;
|
|
103
128
|
}
|
|
104
129
|
|
|
@@ -116,7 +141,12 @@ export async function applyAllStoredCredentialsToEnv() {
|
|
|
116
141
|
const apiKey = typeof entry.apiKey === 'string' ? entry.apiKey.trim() : '';
|
|
117
142
|
const baseUrl = typeof entry.baseUrl === 'string' ? entry.baseUrl.trim() : '';
|
|
118
143
|
if (envVars.apiKeyEnv && apiKey) process.env[envVars.apiKeyEnv] = apiKey;
|
|
119
|
-
if (envVars.baseUrlEnv && baseUrl)
|
|
144
|
+
if (envVars.baseUrlEnv && baseUrl) {
|
|
145
|
+
process.env[envVars.baseUrlEnv] = baseUrl;
|
|
146
|
+
for (const alias of envVars.extraBaseUrlEnv || []) {
|
|
147
|
+
process.env[alias] = baseUrl;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
120
150
|
}
|
|
121
151
|
}
|
|
122
152
|
|
|
@@ -136,6 +166,10 @@ export async function applyProviderCredentialsToEnv(provider) {
|
|
|
136
166
|
if (envVars.baseUrlEnv) {
|
|
137
167
|
if (creds?.baseUrl) process.env[envVars.baseUrlEnv] = creds.baseUrl;
|
|
138
168
|
else delete process.env[envVars.baseUrlEnv];
|
|
169
|
+
for (const alias of envVars.extraBaseUrlEnv || []) {
|
|
170
|
+
if (creds?.baseUrl) process.env[alias] = creds.baseUrl;
|
|
171
|
+
else delete process.env[alias];
|
|
172
|
+
}
|
|
139
173
|
}
|
|
140
174
|
}
|
|
141
175
|
|