@pixelbyte-software/pixcode 1.53.28 → 1.54.1
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-BBDYzJ7B.js → index-CLO8YURv.js} +148 -148
- package/dist/index.html +1 -1
- package/dist-server/server/claude-sdk.js +6 -0
- package/dist-server/server/claude-sdk.js.map +1 -1
- package/dist-server/server/cli.js +67 -0
- package/dist-server/server/cli.js.map +1 -1
- package/dist-server/server/index.js +278 -54
- package/dist-server/server/index.js.map +1 -1
- package/dist-server/server/middleware/account-lockout.js +101 -0
- package/dist-server/server/middleware/account-lockout.js.map +1 -0
- package/dist-server/server/middleware/auth.js +71 -9
- package/dist-server/server/middleware/auth.js.map +1 -1
- package/dist-server/server/middleware/rate-limiter.js +62 -0
- package/dist-server/server/middleware/rate-limiter.js.map +1 -0
- package/dist-server/server/routes/agent.js +3 -4
- package/dist-server/server/routes/agent.js.map +1 -1
- package/dist-server/server/routes/auth.js +75 -13
- package/dist-server/server/routes/auth.js.map +1 -1
- package/dist-server/server/routes/codex.js +1 -1
- package/dist-server/server/routes/codex.js.map +1 -1
- package/dist-server/server/routes/diagnostics.js +6 -3
- package/dist-server/server/routes/diagnostics.js.map +1 -1
- package/dist-server/server/routes/gemini.js +1 -1
- package/dist-server/server/routes/gemini.js.map +1 -1
- package/dist-server/server/routes/git.js +9 -9
- package/dist-server/server/routes/git.js.map +1 -1
- package/dist-server/server/routes/live-view.js +2 -2
- package/dist-server/server/routes/live-view.js.map +1 -1
- package/dist-server/server/routes/plugins.js +12 -12
- package/dist-server/server/routes/plugins.js.map +1 -1
- package/dist-server/server/routes/projects.js +2 -2
- package/dist-server/server/routes/projects.js.map +1 -1
- package/dist-server/server/routes/qwen.js +1 -1
- package/dist-server/server/routes/qwen.js.map +1 -1
- package/dist-server/server/routes/remote.js +1 -1
- package/dist-server/server/routes/remote.js.map +1 -1
- package/dist-server/server/routes/webhooks.js +1 -1
- package/dist-server/server/routes/webhooks.js.map +1 -1
- package/dist-server/server/services/startup-update.js +31 -6
- package/dist-server/server/services/startup-update.js.map +1 -1
- package/dist-server/server/services/telegram/bot.js +6 -4
- package/dist-server/server/services/telegram/bot.js.map +1 -1
- package/dist-server/server/setup-wizard.js +246 -0
- package/dist-server/server/setup-wizard.js.map +1 -0
- package/dist-server/server/utils/plugin-loader.js +3 -0
- package/dist-server/server/utils/plugin-loader.js.map +1 -1
- package/dist-server/server/utils/port-access.js +25 -8
- package/dist-server/server/utils/port-access.js.map +1 -1
- package/dist-server/server/utils/security-log.js +89 -0
- package/dist-server/server/utils/security-log.js.map +1 -0
- package/package.json +1 -1
- package/server/claude-sdk.js +7 -0
- package/server/cli.js +67 -0
- package/server/index.js +287 -55
- package/server/middleware/account-lockout.js +112 -0
- package/server/middleware/auth.js +72 -9
- package/server/middleware/rate-limiter.js +82 -0
- package/server/routes/agent.js +3 -4
- package/server/routes/auth.js +86 -13
- package/server/routes/codex.js +1 -1
- package/server/routes/diagnostics.js +6 -3
- package/server/routes/gemini.js +1 -1
- package/server/routes/git.js +9 -9
- package/server/routes/live-view.js +2 -2
- package/server/routes/plugins.js +12 -12
- package/server/routes/projects.js +2 -2
- package/server/routes/qwen.js +1 -1
- package/server/routes/remote.js +1 -1
- package/server/routes/webhooks.js +1 -1
- package/server/services/startup-update.js +31 -6
- package/server/services/telegram/bot.js +6 -4
- package/server/setup-wizard.js +263 -0
- package/server/utils/plugin-loader.js +4 -0
- package/server/utils/port-access.js +26 -8
- package/server/utils/security-log.js +84 -0
package/server/index.js
CHANGED
|
@@ -19,6 +19,7 @@ import cors from 'cors';
|
|
|
19
19
|
import { AppError, createNormalizedMessage } from '@/shared/utils.js';
|
|
20
20
|
|
|
21
21
|
import { findAppRoot, getModuleDir } from './utils/runtime-paths.js';
|
|
22
|
+
import { securityLog, getClientIp } from './utils/security-log.js';
|
|
22
23
|
|
|
23
24
|
|
|
24
25
|
|
|
@@ -94,7 +95,8 @@ function resolveMonacoAssetsPath() {
|
|
|
94
95
|
|
|
95
96
|
import { c } from './utils/colors.js';
|
|
96
97
|
|
|
97
|
-
|
|
98
|
+
// Server port is logged after binding in startServer() — avoid leaking
|
|
99
|
+
// env configuration to stdout on every import.
|
|
98
100
|
|
|
99
101
|
|
|
100
102
|
|
|
@@ -163,6 +165,7 @@ import { initializeDatabase, sessionNamesDb, applyCustomSessionNames, appConfigD
|
|
|
163
165
|
import { setNotificationWebSocketServer } from './services/notification-orchestrator.js';
|
|
164
166
|
import { configureWebPush } from './services/vapid-keys.js';
|
|
165
167
|
import { validateApiKey, authenticateToken, authenticateWebSocket, requireAdmin, requireApiScope } from './middleware/auth.js';
|
|
168
|
+
import { apiRateLimiter } from './middleware/rate-limiter.js';
|
|
166
169
|
import { filterFileTreeForUser, filterProjectsForUser, userHasProjectAccess, userHasProjectPathAccess } from './services/platformization.js';
|
|
167
170
|
import { IS_PLATFORM } from './constants/config.js';
|
|
168
171
|
|
|
@@ -368,6 +371,7 @@ async function readLatestPixcodePackageMetadata() {
|
|
|
368
371
|
return {
|
|
369
372
|
latestVersion,
|
|
370
373
|
tarballUrl: latestEntry?.dist?.tarball || null,
|
|
374
|
+
tarballIntegrity: latestEntry?.dist?.integrity || null,
|
|
371
375
|
};
|
|
372
376
|
}
|
|
373
377
|
|
|
@@ -380,7 +384,33 @@ function buildPixcodeTarballUrl(version) {
|
|
|
380
384
|
return `https://registry.npmjs.org/@pixelbyte-software/pixcode/-/pixcode-${version.trim()}.tgz`;
|
|
381
385
|
}
|
|
382
386
|
|
|
383
|
-
|
|
387
|
+
/**
|
|
388
|
+
* Verify a downloaded tarball's integrity against the registry-provided
|
|
389
|
+
* Subresource Integrity (SRI) string. Throws if the hash doesn't match.
|
|
390
|
+
* @param {Buffer} buffer - The downloaded tarball bytes
|
|
391
|
+
* @param {string} integrity - SRI string from npm registry (e.g. "sha512-...")
|
|
392
|
+
*/
|
|
393
|
+
function verifyTarballIntegrity(buffer, integrity) {
|
|
394
|
+
if (!integrity || typeof integrity !== 'string') {
|
|
395
|
+
throw new Error('Tarball integrity hash missing from registry metadata — refusing to install unverified package.');
|
|
396
|
+
}
|
|
397
|
+
const dashIndex = integrity.indexOf('-');
|
|
398
|
+
if (dashIndex < 0) {
|
|
399
|
+
throw new Error('Malformed integrity string from registry.');
|
|
400
|
+
}
|
|
401
|
+
const algo = integrity.slice(0, dashIndex);
|
|
402
|
+
const expectedHash = integrity.slice(dashIndex + 1);
|
|
403
|
+
const validAlgos = ['sha512', 'sha384', 'sha256'];
|
|
404
|
+
if (!validAlgos.includes(algo)) {
|
|
405
|
+
throw new Error(`Unsupported integrity algorithm: ${algo}`);
|
|
406
|
+
}
|
|
407
|
+
const actualHash = crypto.createHash(algo).update(buffer).digest('base64');
|
|
408
|
+
if (actualHash !== expectedHash) {
|
|
409
|
+
throw new Error(`Tarball integrity check failed: expected ${algo}-${expectedHash.slice(0, 16)}…, got ${algo}-${actualHash.slice(0, 16)}…`);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
async function runRuntimeDirUpdateJob(job, runtimeDir, latestVersion, tarballUrl, tarballIntegrity) {
|
|
384
414
|
appendUpdateJobLog(job, 'meta', `Update mode: runtime-dir\nRuntime: ${runtimeDir}\n`);
|
|
385
415
|
appendUpdateJobLog(job, 'meta', `Downloading ${tarballUrl}\n`);
|
|
386
416
|
const tarballRes = await fetch(tarballUrl);
|
|
@@ -388,6 +418,22 @@ async function runRuntimeDirUpdateJob(job, runtimeDir, latestVersion, tarballUrl
|
|
|
388
418
|
throw new Error(`Tarball fetch failed: HTTP ${tarballRes.status}`);
|
|
389
419
|
}
|
|
390
420
|
|
|
421
|
+
// Download tarball to a buffer first so we can verify integrity before extracting.
|
|
422
|
+
const tarballBuffer = Buffer.from(await tarballRes.arrayBuffer());
|
|
423
|
+
|
|
424
|
+
// Verify integrity hash against registry-provided SRI string.
|
|
425
|
+
if (tarballIntegrity) {
|
|
426
|
+
try {
|
|
427
|
+
verifyTarballIntegrity(tarballBuffer, tarballIntegrity);
|
|
428
|
+
appendUpdateJobLog(job, 'meta', 'Tarball integrity verified.\n');
|
|
429
|
+
} catch (verifyError) {
|
|
430
|
+
appendUpdateJobLog(job, 'stderr', `Integrity verification failed: ${verifyError.message}\n`);
|
|
431
|
+
throw verifyError;
|
|
432
|
+
}
|
|
433
|
+
} else {
|
|
434
|
+
appendUpdateJobLog(job, 'meta', 'WARNING: No integrity hash from registry — extracting without verification.\n');
|
|
435
|
+
}
|
|
436
|
+
|
|
391
437
|
const stagingDir = path.join(runtimeDir, '.staging');
|
|
392
438
|
const backupDir = path.join(runtimeDir, '.previous');
|
|
393
439
|
fs.rmSync(stagingDir, { recursive: true, force: true });
|
|
@@ -399,10 +445,7 @@ async function runRuntimeDirUpdateJob(job, runtimeDir, latestVersion, tarballUrl
|
|
|
399
445
|
if (!tarExtract) throw new Error('tar extractor not available');
|
|
400
446
|
|
|
401
447
|
await new Promise((resolve, reject) => {
|
|
402
|
-
const
|
|
403
|
-
const nodeStream = typeof Readable.fromWeb === 'function' && webStream?.getReader
|
|
404
|
-
? Readable.fromWeb(webStream)
|
|
405
|
-
: webStream;
|
|
448
|
+
const nodeStream = Readable.from(tarballBuffer);
|
|
406
449
|
const extractor = tarExtract({ cwd: stagingDir, strip: 1 });
|
|
407
450
|
nodeStream.pipe(extractor);
|
|
408
451
|
extractor.on('finish', resolve);
|
|
@@ -514,7 +557,7 @@ function createSystemUpdateJob(actorUser, options = {}) {
|
|
|
514
557
|
const gitUpdateScript = path.join(APP_ROOT, 'scripts', 'update-git-install.mjs');
|
|
515
558
|
const latest = await readLatestPixcodePackageMetadata().catch((error) => {
|
|
516
559
|
appendUpdateJobLog(job, 'stderr', `Registry precheck failed: ${error.message}\n`);
|
|
517
|
-
return { latestVersion: null, tarballUrl: null };
|
|
560
|
+
return { latestVersion: null, tarballUrl: null, tarballIntegrity: null };
|
|
518
561
|
});
|
|
519
562
|
const requestedVersion = isSafePackageVersion(options.targetVersion) ? options.targetVersion.trim() : null;
|
|
520
563
|
const resolvedVersion = latest.latestVersion || requestedVersion || null;
|
|
@@ -536,7 +579,7 @@ function createSystemUpdateJob(actorUser, options = {}) {
|
|
|
536
579
|
if (!latest.latestVersion) {
|
|
537
580
|
appendUpdateJobLog(job, 'meta', `Using requested target version ${resolvedVersion} after registry precheck failed.\n`);
|
|
538
581
|
}
|
|
539
|
-
job.toVersion = await runRuntimeDirUpdateJob(job, runtimeDir, resolvedVersion, resolvedTarballUrl);
|
|
582
|
+
job.toVersion = await runRuntimeDirUpdateJob(job, runtimeDir, resolvedVersion, resolvedTarballUrl, latest.tarballIntegrity);
|
|
540
583
|
} else {
|
|
541
584
|
const updateCommand = IS_PLATFORM
|
|
542
585
|
? 'npm run update:platform'
|
|
@@ -1119,8 +1162,8 @@ const SHELL_URL_PARSE_BUFFER_LIMIT = 32768;
|
|
|
1119
1162
|
const SHELL_OUTPUT_FLUSH_MAX_CHARS = 128 * 1024;
|
|
1120
1163
|
const SHELL_WS_BACKPRESSURE_LIMIT = 8 * 1024 * 1024;
|
|
1121
1164
|
const PTY_SESSION_BUFFER_MAX_BYTES = Number.parseInt(process.env.PIXCODE_PTY_BUFFER_MAX_BYTES || '', 10) || (512 * 1024);
|
|
1122
|
-
const SHELL_INPUT_CHUNK_CHARS =
|
|
1123
|
-
const SHELL_PENDING_INPUT_MAX_BYTES = Number.parseInt(process.env.PIXCODE_SHELL_PENDING_INPUT_MAX_BYTES || '', 10) || (
|
|
1165
|
+
const SHELL_INPUT_CHUNK_CHARS = 16384;
|
|
1166
|
+
const SHELL_PENDING_INPUT_MAX_BYTES = Number.parseInt(process.env.PIXCODE_SHELL_PENDING_INPUT_MAX_BYTES || '', 10) || (256 * 1024 * 1024);
|
|
1124
1167
|
const SHELL_CLI_PROVIDERS = new Set(['claude', 'codex', 'cursor', 'gemini', 'qwen', 'opencode']);
|
|
1125
1168
|
const FILE_TREE_MAX_ITEMS = Number.parseInt(process.env.PIXCODE_FILE_TREE_MAX_ITEMS || '', 10) || 5000;
|
|
1126
1169
|
const FILE_TREE_MAX_DIRECTORIES = Number.parseInt(process.env.PIXCODE_FILE_TREE_MAX_DIRECTORIES || '', 10) || 1200;
|
|
@@ -1687,7 +1730,69 @@ app.locals.installMode = installMode;
|
|
|
1687
1730
|
app.locals.serverVersion = SERVER_VERSION;
|
|
1688
1731
|
setNotificationWebSocketServer(wss);
|
|
1689
1732
|
|
|
1690
|
-
|
|
1733
|
+
// ── Security hardening ──────────────────────────────────────────────
|
|
1734
|
+
// Disable the X-Powered-By header so the framework isn't advertised.
|
|
1735
|
+
app.disable('x-powered-by');
|
|
1736
|
+
// Trust the first proxy hop so X-Forwarded-* headers are respected when
|
|
1737
|
+
// running behind nginx/Caddy/etc. (needed for correct protocol detection
|
|
1738
|
+
// in resolvePublicBaseUrl and for rate-limiting middleware if added later).
|
|
1739
|
+
app.set('trust proxy', 1);
|
|
1740
|
+
|
|
1741
|
+
// Restrict CORS to known origins instead of reflecting any requester.
|
|
1742
|
+
// In production the frontend is same-origin; in dev it's the Vite server.
|
|
1743
|
+
const ALLOWED_CORS_ORIGINS = (() => {
|
|
1744
|
+
const envOrigins = process.env.CORS_ALLOWED_ORIGINS;
|
|
1745
|
+
if (envOrigins) {
|
|
1746
|
+
return envOrigins.split(',').map((o) => o.trim()).filter(Boolean);
|
|
1747
|
+
}
|
|
1748
|
+
const devPort = process.env.VITE_PORT || 5173;
|
|
1749
|
+
return [
|
|
1750
|
+
`http://localhost:${devPort}`,
|
|
1751
|
+
`http://127.0.0.1:${devPort}`,
|
|
1752
|
+
];
|
|
1753
|
+
})();
|
|
1754
|
+
|
|
1755
|
+
app.use(cors({
|
|
1756
|
+
origin(origin, callback) {
|
|
1757
|
+
// Allow same-origin requests (no Origin header) and allowlisted origins.
|
|
1758
|
+
if (!origin || ALLOWED_CORS_ORIGINS.includes(origin)) {
|
|
1759
|
+
return callback(null, true);
|
|
1760
|
+
}
|
|
1761
|
+
return callback(null, false);
|
|
1762
|
+
},
|
|
1763
|
+
credentials: true,
|
|
1764
|
+
exposedHeaders: ['X-Refreshed-Token'],
|
|
1765
|
+
}));
|
|
1766
|
+
|
|
1767
|
+
// Security headers middleware (replaces helmet which isn't installed).
|
|
1768
|
+
app.use((req, res, next) => {
|
|
1769
|
+
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
1770
|
+
res.setHeader('X-Frame-Options', 'SAMEORIGIN');
|
|
1771
|
+
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
|
|
1772
|
+
res.setHeader('X-XSS-Protection', '1; mode=block');
|
|
1773
|
+
res.setHeader('Permissions-Policy', 'geolocation=(), microphone=(), camera=()');
|
|
1774
|
+
// Strict-Transport-Security only makes sense over HTTPS; skip for plain HTTP
|
|
1775
|
+
// so local dev doesn't pin an HSTS policy on localhost.
|
|
1776
|
+
if (req.secure || req.headers['x-forwarded-proto'] === 'https') {
|
|
1777
|
+
res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
|
|
1778
|
+
}
|
|
1779
|
+
// CSP for the SPA shell — API responses are JSON so this mainly guards
|
|
1780
|
+
// the served index.html. 'unsafe-inline' is needed for Vite's inline
|
|
1781
|
+
// module preload polyfill; style-src unsafe-inline for Tailwind injected styles.
|
|
1782
|
+
res.setHeader('Content-Security-Policy', [
|
|
1783
|
+
"default-src 'self'",
|
|
1784
|
+
"script-src 'self' 'unsafe-inline' 'unsafe-eval'",
|
|
1785
|
+
"style-src 'self' 'unsafe-inline'",
|
|
1786
|
+
"img-src 'self' data: blob: https:",
|
|
1787
|
+
"font-src 'self' data:",
|
|
1788
|
+
"connect-src 'self' ws: wss:",
|
|
1789
|
+
"frame-ancestors 'self'",
|
|
1790
|
+
"base-uri 'self'",
|
|
1791
|
+
"form-action 'self'",
|
|
1792
|
+
].join('; '));
|
|
1793
|
+
next();
|
|
1794
|
+
});
|
|
1795
|
+
|
|
1691
1796
|
app.use(express.json({
|
|
1692
1797
|
limit: '50mb',
|
|
1693
1798
|
type: (req) => {
|
|
@@ -1714,8 +1819,9 @@ app.get('/health', (req, res) => {
|
|
|
1714
1819
|
});
|
|
1715
1820
|
});
|
|
1716
1821
|
|
|
1717
|
-
// Optional API key validation (if configured)
|
|
1822
|
+
// Optional API key validation (if configured) + rate limiting for all API routes
|
|
1718
1823
|
app.use('/api', validateApiKey);
|
|
1824
|
+
app.use('/api', apiRateLimiter);
|
|
1719
1825
|
|
|
1720
1826
|
app.post('/api/shell/sessions/terminate', authenticateToken, requireProjectPathAccess('useShell'), (req, res) => {
|
|
1721
1827
|
const provider = req.body?.provider || 'claude';
|
|
@@ -2152,6 +2258,12 @@ app.get('/api/system/update-state', authenticateToken, (req, res) => {
|
|
|
2152
2258
|
});
|
|
2153
2259
|
|
|
2154
2260
|
app.post('/api/system/update-jobs', authenticateToken, requireAdmin, requireApiScope('system:update'), (req, res) => {
|
|
2261
|
+
securityLog('system_update_initiated', {
|
|
2262
|
+
ip: getClientIp(req),
|
|
2263
|
+
userId: req.user?.id,
|
|
2264
|
+
username: req.user?.username,
|
|
2265
|
+
endpoint: req.path,
|
|
2266
|
+
});
|
|
2155
2267
|
const job = createSystemUpdateJob(req.user, {
|
|
2156
2268
|
targetVersion: req.body?.targetVersion || req.body?.latestVersion,
|
|
2157
2269
|
});
|
|
@@ -2268,6 +2380,7 @@ app.post('/api/system/update', authenticateToken, requireAdmin, requireApiScope(
|
|
|
2268
2380
|
const latestVersion = metadata['dist-tags']?.latest;
|
|
2269
2381
|
const latestEntry = latestVersion ? metadata.versions?.[latestVersion] : null;
|
|
2270
2382
|
const tarballUrl = latestEntry?.dist?.tarball;
|
|
2383
|
+
const tarballIntegrity = latestEntry?.dist?.integrity;
|
|
2271
2384
|
if (!latestVersion || !tarballUrl) throw new Error('Registry response missing latest/tarball');
|
|
2272
2385
|
|
|
2273
2386
|
send('log', { stream: 'meta', chunk: `Current: ${SERVER_VERSION} → Latest: ${latestVersion}\n` });
|
|
@@ -2283,16 +2396,30 @@ app.post('/api/system/update', authenticateToken, requireAdmin, requireApiScope(
|
|
|
2283
2396
|
return;
|
|
2284
2397
|
}
|
|
2285
2398
|
|
|
2286
|
-
// 2. Download the tarball
|
|
2287
|
-
//
|
|
2288
|
-
//
|
|
2289
|
-
// intact if the download fails partway through.
|
|
2399
|
+
// 2. Download the tarball to a buffer so we can verify integrity
|
|
2400
|
+
// before extracting. Doing the extract under `.staging` first
|
|
2401
|
+
// means the live runtime stays intact if the download fails.
|
|
2290
2402
|
send('log', { stream: 'meta', chunk: `Downloading ${tarballUrl}\n` });
|
|
2291
2403
|
const tarballRes = await fetch(tarballUrl);
|
|
2292
2404
|
if (!tarballRes.ok || !tarballRes.body) {
|
|
2293
2405
|
throw new Error(`Tarball fetch failed: HTTP ${tarballRes.status}`);
|
|
2294
2406
|
}
|
|
2295
2407
|
|
|
2408
|
+
const tarballBuffer = Buffer.from(await tarballRes.arrayBuffer());
|
|
2409
|
+
|
|
2410
|
+
// Verify integrity hash against registry-provided SRI string.
|
|
2411
|
+
if (tarballIntegrity) {
|
|
2412
|
+
try {
|
|
2413
|
+
verifyTarballIntegrity(tarballBuffer, tarballIntegrity);
|
|
2414
|
+
send('log', { stream: 'meta', chunk: 'Tarball integrity verified.\n' });
|
|
2415
|
+
} catch (verifyError) {
|
|
2416
|
+
send('log', { stream: 'stderr', chunk: `Integrity verification failed: ${verifyError.message}\n` });
|
|
2417
|
+
throw verifyError;
|
|
2418
|
+
}
|
|
2419
|
+
} else {
|
|
2420
|
+
send('log', { stream: 'meta', chunk: 'WARNING: No integrity hash from registry — extracting without verification.\n' });
|
|
2421
|
+
}
|
|
2422
|
+
|
|
2296
2423
|
const stagingDir = path.join(runtimeDir, '.staging');
|
|
2297
2424
|
const backupDir = path.join(runtimeDir, '.previous');
|
|
2298
2425
|
fs.rmSync(stagingDir, { recursive: true, force: true });
|
|
@@ -2307,10 +2434,7 @@ app.post('/api/system/update', authenticateToken, requireAdmin, requireApiScope(
|
|
|
2307
2434
|
// contents to the staging dir directly so paths match the
|
|
2308
2435
|
// existing runtime layout.
|
|
2309
2436
|
await new Promise((resolve, reject) => {
|
|
2310
|
-
const
|
|
2311
|
-
const nodeStream = typeof Readable.fromWeb === 'function' && webStream?.getReader
|
|
2312
|
-
? Readable.fromWeb(webStream)
|
|
2313
|
-
: webStream;
|
|
2437
|
+
const nodeStream = Readable.from(tarballBuffer);
|
|
2314
2438
|
const extractor = tarExtract({ cwd: stagingDir, strip: 1 });
|
|
2315
2439
|
nodeStream.pipe(extractor);
|
|
2316
2440
|
extractor.on('finish', resolve);
|
|
@@ -2541,6 +2665,11 @@ app.post('/api/system/update', authenticateToken, requireAdmin, requireApiScope(
|
|
|
2541
2665
|
// (systemd/pm2/daemon manager) can bring the server back on the new code.
|
|
2542
2666
|
// Foreground installs without a wrapper will simply stop; the UI reports this.
|
|
2543
2667
|
app.post('/api/system/restart', authenticateToken, requireAdmin, requireApiScope('system:restart'), (req, res) => {
|
|
2668
|
+
securityLog('system_restart_requested', {
|
|
2669
|
+
ip: getClientIp(req),
|
|
2670
|
+
userId: req.user?.id,
|
|
2671
|
+
username: req.user?.username,
|
|
2672
|
+
});
|
|
2544
2673
|
const forceRestart = req.body?.force === true || req.query.force === 'true';
|
|
2545
2674
|
const activeWork = getActiveWorkSummary();
|
|
2546
2675
|
if (!forceRestart && activeWork.hasActiveWork) {
|
|
@@ -2569,7 +2698,7 @@ app.get('/api/projects', authenticateToken, async (req, res) => {
|
|
|
2569
2698
|
const projects = await getProjects(broadcastProgress);
|
|
2570
2699
|
res.json(filterProjectsForUser(projects, req.user).map((project) => filterProjectSessionsForUser(project, req.user)));
|
|
2571
2700
|
} catch (error) {
|
|
2572
|
-
res.status(500).json({ error: error
|
|
2701
|
+
res.status(500).json({ error: "Internal server error" });
|
|
2573
2702
|
}
|
|
2574
2703
|
});
|
|
2575
2704
|
|
|
@@ -2583,7 +2712,7 @@ app.get('/api/projects/:projectName/sessions', authenticateToken, requireProject
|
|
|
2583
2712
|
applyCustomSessionNames(result.sessions, 'claude');
|
|
2584
2713
|
res.json(result);
|
|
2585
2714
|
} catch (error) {
|
|
2586
|
-
res.status(500).json({ error: error
|
|
2715
|
+
res.status(500).json({ error: "Internal server error" });
|
|
2587
2716
|
}
|
|
2588
2717
|
});
|
|
2589
2718
|
|
|
@@ -2594,7 +2723,7 @@ app.put('/api/projects/:projectName/rename', authenticateToken, requireProjectAc
|
|
|
2594
2723
|
await renameProject(req.params.projectName, displayName);
|
|
2595
2724
|
res.json({ success: true });
|
|
2596
2725
|
} catch (error) {
|
|
2597
|
-
res.status(500).json({ error: error
|
|
2726
|
+
res.status(500).json({ error: "Internal server error" });
|
|
2598
2727
|
}
|
|
2599
2728
|
});
|
|
2600
2729
|
|
|
@@ -2609,7 +2738,7 @@ app.delete('/api/projects/:projectName/sessions/:sessionId', authenticateToken,
|
|
|
2609
2738
|
res.json({ success: true });
|
|
2610
2739
|
} catch (error) {
|
|
2611
2740
|
console.error(`[API] Error deleting session ${req.params.sessionId}:`, error);
|
|
2612
|
-
res.status(500).json({ error: error
|
|
2741
|
+
res.status(500).json({ error: "Internal server error" });
|
|
2613
2742
|
}
|
|
2614
2743
|
});
|
|
2615
2744
|
|
|
@@ -2635,7 +2764,7 @@ app.put('/api/sessions/:sessionId/rename', authenticateToken, async (req, res) =
|
|
|
2635
2764
|
res.json({ success: true });
|
|
2636
2765
|
} catch (error) {
|
|
2637
2766
|
console.error(`[API] Error renaming session ${req.params.sessionId}:`, error);
|
|
2638
|
-
res.status(500).json({ error: error
|
|
2767
|
+
res.status(500).json({ error: "Internal server error" });
|
|
2639
2768
|
}
|
|
2640
2769
|
});
|
|
2641
2770
|
|
|
@@ -2655,7 +2784,7 @@ app.delete('/api/projects/:projectName', authenticateToken, requireProjectAccess
|
|
|
2655
2784
|
// catch it and prompt the user to pass `?force=true` (or clean
|
|
2656
2785
|
// sessions first) instead of treating it like a crash.
|
|
2657
2786
|
const conflict = typeof error?.message === 'string' && error.message.includes('existing sessions');
|
|
2658
|
-
res.status(conflict ? 409 : 500).json({ error: error.message });
|
|
2787
|
+
res.status(conflict ? 409 : 500).json({ error: conflict ? error.message : 'Internal server error' });
|
|
2659
2788
|
}
|
|
2660
2789
|
});
|
|
2661
2790
|
|
|
@@ -2892,7 +3021,7 @@ app.get('/api/projects/:projectName/file', authenticateToken, requireProjectAcce
|
|
|
2892
3021
|
} else if (error.code === 'EACCES') {
|
|
2893
3022
|
res.status(403).json({ error: 'Permission denied' });
|
|
2894
3023
|
} else {
|
|
2895
|
-
res.status(500).json({ error: error
|
|
3024
|
+
res.status(500).json({ error: "Internal server error" });
|
|
2896
3025
|
}
|
|
2897
3026
|
}
|
|
2898
3027
|
});
|
|
@@ -2952,7 +3081,7 @@ app.get('/api/projects/:projectName/files/content', authenticateToken, requirePr
|
|
|
2952
3081
|
} catch (error) {
|
|
2953
3082
|
console.error('Error serving binary file:', error);
|
|
2954
3083
|
if (!res.headersSent) {
|
|
2955
|
-
res.status(500).json({ error: error
|
|
3084
|
+
res.status(500).json({ error: "Internal server error" });
|
|
2956
3085
|
}
|
|
2957
3086
|
}
|
|
2958
3087
|
});
|
|
@@ -3005,7 +3134,7 @@ app.put('/api/projects/:projectName/file', authenticateToken, requireProjectAcce
|
|
|
3005
3134
|
} else if (error.code === 'EACCES') {
|
|
3006
3135
|
res.status(403).json({ error: 'Permission denied' });
|
|
3007
3136
|
} else {
|
|
3008
|
-
res.status(500).json({ error: error
|
|
3137
|
+
res.status(500).json({ error: "Internal server error" });
|
|
3009
3138
|
}
|
|
3010
3139
|
}
|
|
3011
3140
|
});
|
|
@@ -3045,7 +3174,7 @@ app.get('/api/projects/:projectName/files', authenticateToken, requireProjectAcc
|
|
|
3045
3174
|
}, 'viewFiles'));
|
|
3046
3175
|
} catch (error) {
|
|
3047
3176
|
console.error('[ERROR] File tree error:', error.message);
|
|
3048
|
-
res.status(500).json({ error: error
|
|
3177
|
+
res.status(500).json({ error: "Internal server error" });
|
|
3049
3178
|
}
|
|
3050
3179
|
});
|
|
3051
3180
|
|
|
@@ -3171,7 +3300,7 @@ app.post('/api/projects/:projectName/files/create', authenticateToken, requirePr
|
|
|
3171
3300
|
} else if (error.code === 'ENOENT') {
|
|
3172
3301
|
res.status(404).json({ error: 'Parent directory not found' });
|
|
3173
3302
|
} else {
|
|
3174
|
-
res.status(500).json({ error: error
|
|
3303
|
+
res.status(500).json({ error: "Internal server error" });
|
|
3175
3304
|
}
|
|
3176
3305
|
}
|
|
3177
3306
|
});
|
|
@@ -3254,7 +3383,7 @@ app.put('/api/projects/:projectName/files/rename', authenticateToken, requirePro
|
|
|
3254
3383
|
} else if (error.code === 'EXDEV') {
|
|
3255
3384
|
res.status(400).json({ error: 'Cannot move across different filesystems' });
|
|
3256
3385
|
} else {
|
|
3257
|
-
res.status(500).json({ error: error
|
|
3386
|
+
res.status(500).json({ error: "Internal server error" });
|
|
3258
3387
|
}
|
|
3259
3388
|
}
|
|
3260
3389
|
});
|
|
@@ -3322,7 +3451,7 @@ app.delete('/api/projects/:projectName/files', authenticateToken, requireProject
|
|
|
3322
3451
|
} else if (error.code === 'ENOTEMPTY') {
|
|
3323
3452
|
res.status(400).json({ error: 'Directory is not empty' });
|
|
3324
3453
|
} else {
|
|
3325
|
-
res.status(500).json({ error: error
|
|
3454
|
+
res.status(500).json({ error: "Internal server error" });
|
|
3326
3455
|
}
|
|
3327
3456
|
}
|
|
3328
3457
|
});
|
|
@@ -3341,7 +3470,7 @@ const uploadFilesHandler = async (req, res) => {
|
|
|
3341
3470
|
filename: (req, file, cb) => {
|
|
3342
3471
|
// Use a unique temp name, but preserve original name in file.originalname
|
|
3343
3472
|
// Note: file.originalname may contain path separators for folder uploads
|
|
3344
|
-
const uniqueSuffix = Date.now() + '-' +
|
|
3473
|
+
const uniqueSuffix = Date.now() + '-' + crypto.randomBytes(8).toString('hex');
|
|
3345
3474
|
// For temp file, just use a safe unique name without the path
|
|
3346
3475
|
cb(null, `upload-${uniqueSuffix}`);
|
|
3347
3476
|
}
|
|
@@ -3490,7 +3619,7 @@ const uploadFilesHandler = async (req, res) => {
|
|
|
3490
3619
|
if (error.code === 'EACCES') {
|
|
3491
3620
|
res.status(403).json({ error: 'Permission denied' });
|
|
3492
3621
|
} else {
|
|
3493
|
-
res.status(500).json({ error: error
|
|
3622
|
+
res.status(500).json({ error: "Internal server error" });
|
|
3494
3623
|
}
|
|
3495
3624
|
}
|
|
3496
3625
|
});
|
|
@@ -3831,10 +3960,15 @@ function handleChatConnection(ws, request) {
|
|
|
3831
3960
|
});
|
|
3832
3961
|
}
|
|
3833
3962
|
} catch (error) {
|
|
3834
|
-
console.error('[ERROR] Chat WebSocket error:', error
|
|
3963
|
+
console.error('[ERROR] Chat WebSocket error:', error?.message || error);
|
|
3964
|
+
securityLog('ws_chat_error', {
|
|
3965
|
+
userId: request?.user?.id,
|
|
3966
|
+
username: request?.user?.username,
|
|
3967
|
+
reason: error?.name || 'UnknownError',
|
|
3968
|
+
});
|
|
3835
3969
|
writer.send({
|
|
3836
3970
|
type: 'error',
|
|
3837
|
-
error: error.
|
|
3971
|
+
error: 'An error occurred while processing your request.'
|
|
3838
3972
|
});
|
|
3839
3973
|
}
|
|
3840
3974
|
});
|
|
@@ -3899,24 +4033,38 @@ function handleShellConnection(ws, request) {
|
|
|
3899
4033
|
const chunks = splitTerminalInput(data);
|
|
3900
4034
|
if (chunks.length === 0) return;
|
|
3901
4035
|
|
|
3902
|
-
let droppedBytes = 0;
|
|
3903
4036
|
for (const chunk of chunks) {
|
|
3904
|
-
|
|
4037
|
+
// Filter terminal-generated response sequences that xterm.js sends back
|
|
4038
|
+
// via onData. Without this, DA2/mouse/DSR responses get echoed by the PTY,
|
|
4039
|
+
// xterm.js interprets the echo as a new query, and an infinite loop starts.
|
|
4040
|
+
// This is the server-side guard complementing the frontend sanitizer.
|
|
4041
|
+
const filteredChunk = chunk
|
|
4042
|
+
.replace(/\x1b\[>\d+[;:\d]*c/g, '') // DA2 response (self-looping)
|
|
4043
|
+
.replace(/\x1b\[\?\d+[;:\d]*c/g, '') // DA1 response
|
|
4044
|
+
.replace(/\x1b\[\d+;\d+R/g, '') // DSR cursor
|
|
4045
|
+
.replace(/\x1b\[\?\d+;\d+R/g, '') // DSR private cursor
|
|
4046
|
+
.replace(/\x1b\[\d+n/g, '') // DSR status
|
|
4047
|
+
.replace(/\x1b\[M[\s\S]{3}/g, '') // Mouse report (default)
|
|
4048
|
+
.replace(/\x1b\[<\d+;\d+;\d+[Mm]/g, '') // Mouse report (SGR)
|
|
4049
|
+
.replace(/\x1b\[IO]/g, '') // Focus events
|
|
4050
|
+
.replace(/\x1b\[\d+;\d+;\d+t/g, '') // Window size report
|
|
4051
|
+
.replace(/\x1b\[\d+;\d+\$y/g, ''); // DECRQM response
|
|
4052
|
+
if (!filteredChunk) continue;
|
|
4053
|
+
|
|
4054
|
+
const chunkBytes = Buffer.byteLength(filteredChunk, 'utf8');
|
|
3905
4055
|
if (pendingInputBytes + chunkBytes > SHELL_PENDING_INPUT_MAX_BYTES) {
|
|
3906
|
-
|
|
3907
|
-
|
|
4056
|
+
// Drop only the oldest queued chunk to make room, not the new one.
|
|
4057
|
+
// This prevents the queue from growing unbounded without silently
|
|
4058
|
+
// discarding the user's latest paste.
|
|
4059
|
+
while (pendingInputQueue.length > 0 && pendingInputBytes + chunkBytes > SHELL_PENDING_INPUT_MAX_BYTES) {
|
|
4060
|
+
const dropped = pendingInputQueue.shift();
|
|
4061
|
+
pendingInputBytes = Math.max(0, pendingInputBytes - Buffer.byteLength(dropped, 'utf8'));
|
|
4062
|
+
}
|
|
3908
4063
|
}
|
|
3909
|
-
pendingInputQueue.push(
|
|
4064
|
+
pendingInputQueue.push(filteredChunk);
|
|
3910
4065
|
pendingInputBytes += chunkBytes;
|
|
3911
4066
|
}
|
|
3912
4067
|
|
|
3913
|
-
if (droppedBytes > 0 && ws.readyState === WebSocket.OPEN) {
|
|
3914
|
-
ws.send(JSON.stringify({
|
|
3915
|
-
type: 'output',
|
|
3916
|
-
data: `\r\n\x1b[33m[Pixcode] ${droppedBytes} bytes of pasted terminal input were not sent because the input queue limit was reached. Split very large pastes into smaller chunks.\x1b[0m\r\n`,
|
|
3917
|
-
}));
|
|
3918
|
-
}
|
|
3919
|
-
|
|
3920
4068
|
scheduleInputFlush();
|
|
3921
4069
|
}
|
|
3922
4070
|
|
|
@@ -4299,11 +4447,16 @@ function handleShellConnection(ws, request) {
|
|
|
4299
4447
|
const termCols = data.cols || 80;
|
|
4300
4448
|
const termRows = data.rows || 24;
|
|
4301
4449
|
console.log('📐 Using terminal dimensions:', termCols, 'x', termRows);
|
|
4450
|
+
const isRunningAsRoot = typeof process.getuid === 'function' && process.getuid() === 0;
|
|
4302
4451
|
const shellEnv = {
|
|
4303
4452
|
...process.env,
|
|
4304
4453
|
TERM: 'xterm-256color',
|
|
4305
4454
|
COLORTERM: 'truecolor',
|
|
4306
4455
|
FORCE_COLOR: '3',
|
|
4456
|
+
// When running as root, Claude CLI refuses --dangerously-skip-permissions
|
|
4457
|
+
// for security reasons. Setting IS_SANDBOX=1 tells it the environment is
|
|
4458
|
+
// already sandboxed, so the flag is safe to use.
|
|
4459
|
+
...(isRunningAsRoot ? { IS_SANDBOX: '1' } : {}),
|
|
4307
4460
|
};
|
|
4308
4461
|
|
|
4309
4462
|
shellProcess = pty.spawn(shell, shellArgs, {
|
|
@@ -4421,7 +4574,7 @@ function handleShellConnection(ws, request) {
|
|
|
4421
4574
|
console.error('[ERROR] Error spawning process:', spawnError);
|
|
4422
4575
|
ws.send(JSON.stringify({
|
|
4423
4576
|
type: 'output',
|
|
4424
|
-
data: `\r\n\x1b[31mError:
|
|
4577
|
+
data: `\r\n\x1b[31mError: Failed to start terminal process.\x1b[0m\r\n`
|
|
4425
4578
|
}));
|
|
4426
4579
|
}
|
|
4427
4580
|
|
|
@@ -4436,11 +4589,11 @@ function handleShellConnection(ws, request) {
|
|
|
4436
4589
|
}
|
|
4437
4590
|
}
|
|
4438
4591
|
} catch (error) {
|
|
4439
|
-
console.error('[ERROR] Shell WebSocket error:', error
|
|
4592
|
+
console.error('[ERROR] Shell WebSocket error:', error?.message || error);
|
|
4440
4593
|
if (ws.readyState === WebSocket.OPEN) {
|
|
4441
4594
|
ws.send(JSON.stringify({
|
|
4442
4595
|
type: 'output',
|
|
4443
|
-
data: `\r\n\x1b[31mError:
|
|
4596
|
+
data: `\r\n\x1b[31mError: An internal error occurred.\x1b[0m\r\n`
|
|
4444
4597
|
}));
|
|
4445
4598
|
}
|
|
4446
4599
|
}
|
|
@@ -4503,7 +4656,7 @@ app.post('/api/projects/:projectName/upload-images', authenticateToken, requireP
|
|
|
4503
4656
|
cb(null, uploadDir);
|
|
4504
4657
|
},
|
|
4505
4658
|
filename: (req, file, cb) => {
|
|
4506
|
-
const uniqueSuffix = Date.now() + '-' +
|
|
4659
|
+
const uniqueSuffix = Date.now() + '-' + crypto.randomBytes(8).toString('hex');
|
|
4507
4660
|
const sanitizedName = file.originalname.replace(/[^a-zA-Z0-9.-]/g, '_');
|
|
4508
4661
|
cb(null, uniqueSuffix + '-' + sanitizedName);
|
|
4509
4662
|
}
|
|
@@ -4868,8 +5021,16 @@ app.get(/.*/, (req, res) => {
|
|
|
4868
5021
|
});
|
|
4869
5022
|
|
|
4870
5023
|
// global error middleware must be last
|
|
4871
|
-
app.use((err, req, res,
|
|
5024
|
+
app.use((err, req, res, _next) => {
|
|
4872
5025
|
if (err instanceof AppError) {
|
|
5026
|
+
securityLog('app_error', {
|
|
5027
|
+
ip: getClientIp(req),
|
|
5028
|
+
endpoint: req.path,
|
|
5029
|
+
method: req.method,
|
|
5030
|
+
statusCode: err.statusCode,
|
|
5031
|
+
userId: req.user?.id,
|
|
5032
|
+
reason: err.code,
|
|
5033
|
+
});
|
|
4873
5034
|
return res.status(err.statusCode).json({
|
|
4874
5035
|
success: false,
|
|
4875
5036
|
error: {
|
|
@@ -4880,7 +5041,37 @@ app.use((err, req, res, next) => {
|
|
|
4880
5041
|
});
|
|
4881
5042
|
}
|
|
4882
5043
|
|
|
4883
|
-
|
|
5044
|
+
// Log the error internally but never expose stack traces, file paths,
|
|
5045
|
+
// or internal IPs to the client.
|
|
5046
|
+
if (err?.type === 'entity.too.large') {
|
|
5047
|
+
return res.status(413).json({
|
|
5048
|
+
success: false,
|
|
5049
|
+
error: {
|
|
5050
|
+
code: 'PAYLOAD_TOO_LARGE',
|
|
5051
|
+
message: 'Request payload exceeds size limit.',
|
|
5052
|
+
},
|
|
5053
|
+
});
|
|
5054
|
+
}
|
|
5055
|
+
|
|
5056
|
+
if (err?.type === 'entity.parse.failed' || err?.type === 'entity.parse.failed.utf8') {
|
|
5057
|
+
return res.status(400).json({
|
|
5058
|
+
success: false,
|
|
5059
|
+
error: {
|
|
5060
|
+
code: 'INVALID_JSON',
|
|
5061
|
+
message: 'Request body contains invalid JSON.',
|
|
5062
|
+
},
|
|
5063
|
+
});
|
|
5064
|
+
}
|
|
5065
|
+
|
|
5066
|
+
console.error('[UNHANDLED ERROR]', err?.message || err);
|
|
5067
|
+
securityLog('unhandled_error', {
|
|
5068
|
+
ip: getClientIp(req),
|
|
5069
|
+
endpoint: req.path,
|
|
5070
|
+
method: req.method,
|
|
5071
|
+
statusCode: 500,
|
|
5072
|
+
userId: req.user?.id,
|
|
5073
|
+
reason: err?.name || 'UnknownError',
|
|
5074
|
+
});
|
|
4884
5075
|
|
|
4885
5076
|
return res.status(500).json({
|
|
4886
5077
|
success: false,
|
|
@@ -5214,6 +5405,26 @@ async function maybeAutoDaemonBootstrapFromIndex() {
|
|
|
5214
5405
|
}
|
|
5215
5406
|
}
|
|
5216
5407
|
|
|
5408
|
+
// Process-level error handlers to prevent silent crashes and log security events.
|
|
5409
|
+
// These catch errors that escape Express's own error middleware (e.g. from
|
|
5410
|
+
// timers, WebSocket handlers, or un-awaited promises in route handlers).
|
|
5411
|
+
process.on('unhandledRejection', (reason, promise) => {
|
|
5412
|
+
console.error('[FATAL] Unhandled promise rejection:', reason);
|
|
5413
|
+
securityLog('unhandled_rejection', {
|
|
5414
|
+
reason: reason instanceof Error ? reason.name : String(reason).slice(0, 200),
|
|
5415
|
+
});
|
|
5416
|
+
});
|
|
5417
|
+
|
|
5418
|
+
process.on('uncaughtException', (error) => {
|
|
5419
|
+
console.error('[FATAL] Uncaught exception:', error?.message || error);
|
|
5420
|
+
securityLog('uncaught_exception', {
|
|
5421
|
+
reason: error?.name || 'UnknownError',
|
|
5422
|
+
});
|
|
5423
|
+
// Give the security log time to flush, then exit. A clean exit lets the
|
|
5424
|
+
// daemon manager (systemd/pm2) restart the server automatically.
|
|
5425
|
+
setTimeout(() => process.exit(1), 100);
|
|
5426
|
+
});
|
|
5427
|
+
|
|
5217
5428
|
// Initialize database and start server
|
|
5218
5429
|
async function startServer() {
|
|
5219
5430
|
try {
|
|
@@ -5293,6 +5504,27 @@ async function startServer() {
|
|
|
5293
5504
|
console.warn('[external-access] tunnel restore failed:', err?.message || err);
|
|
5294
5505
|
});
|
|
5295
5506
|
|
|
5507
|
+
// Auto-open browser unless explicitly disabled
|
|
5508
|
+
if (process.env.PIXCODE_NO_BROWSER !== '1') {
|
|
5509
|
+
const openUrl = `http://${DISPLAY_HOST}:${SERVER_PORT}`;
|
|
5510
|
+
try {
|
|
5511
|
+
// Use spawn with an argument array instead of exec with a
|
|
5512
|
+
// shell string to prevent command injection through the
|
|
5513
|
+
// host/port values (which come from env vars).
|
|
5514
|
+
const { spawn: spawnBrowser } = await import('node:child_process');
|
|
5515
|
+
const browserBin = process.platform === 'darwin' ? 'open'
|
|
5516
|
+
: process.platform === 'win32' ? 'cmd'
|
|
5517
|
+
: 'xdg-open';
|
|
5518
|
+
const browserArgs = process.platform === 'win32'
|
|
5519
|
+
? ['/c', 'start', '', openUrl]
|
|
5520
|
+
: [openUrl];
|
|
5521
|
+
spawnBrowser(browserBin, browserArgs, { stdio: 'ignore', timeout: 3000, detached: true, shell: false }).unref();
|
|
5522
|
+
console.log(`${c.ok('[OK]')} Opening browser at ${c.bright(openUrl)}`);
|
|
5523
|
+
} catch {
|
|
5524
|
+
console.log(`${c.tip('[TIP]')} Open ${c.bright(openUrl)} in your browser to start using Pixcode.`);
|
|
5525
|
+
}
|
|
5526
|
+
}
|
|
5527
|
+
|
|
5296
5528
|
console.log(`${c.tip('[TIP]')} Run "pixcode status" for full configuration details`);
|
|
5297
5529
|
console.log('');
|
|
5298
5530
|
|