index.js 122 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802280328042805280628072808280928102811281228132814281528162817281828192820282128222823282428252826282728282829283028312832283328342835283628372838283928402841284228432844284528462847284828492850285128522853285428552856285728582859286028612862286328642865286628672868286928702871287228732874287528762877287828792880288128822883288428852886288728882889289028912892289328942895289628972898289929002901290229032904290529062907290829092910291129122913291429152916291729182919292029212922292329242925292629272928292929302931293229332934293529362937293829392940294129422943294429452946294729482949295029512952295329542955295629572958295929602961296229632964296529662967296829692970297129722973297429752976297729782979298029812982298329842985298629872988298929902991299229932994299529962997299829993000300130023003300430053006300730083009301030113012301330143015301630173018301930203021302230233024302530263027302830293030303130323033303430353036303730383039304030413042304330443045304630473048304930503051305230533054305530563057305830593060306130623063306430653066306730683069307030713072307330743075307630773078307930803081308230833084308530863087308830893090309130923093309430953096309730983099310031013102310331043105310631073108310931103111311231133114311531163117311831193120
  1. #!/usr/bin/env node
  2. // Load environment variables before other imports execute
  3. import { assertRequiredPilotDeckEnv } from './load-env.js';
  4. // Install global fetch proxy (PILOTDECK_PROXY / HTTPS_PROXY) before any network calls
  5. import { installGlobalProxy } from './utils/proxy.js';
  6. installGlobalProxy();
  7. import fs from 'fs';
  8. import path from 'path';
  9. import { fileURLToPath } from 'url';
  10. import { dirname } from 'path';
  11. import net from 'net';
  12. const __filename = fileURLToPath(import.meta.url);
  13. const __dirname = dirname(__filename);
  14. const installMode = fs.existsSync(path.join(__dirname, '..', '.git')) ? 'git' : 'npm';
  15. // ANSI color codes for terminal output
  16. const colors = {
  17. reset: '\x1b[0m',
  18. bright: '\x1b[1m',
  19. cyan: '\x1b[36m',
  20. green: '\x1b[32m',
  21. yellow: '\x1b[33m',
  22. blue: '\x1b[34m',
  23. dim: '\x1b[2m',
  24. };
  25. const c = {
  26. info: (text) => `${colors.cyan}${text}${colors.reset}`,
  27. ok: (text) => `${colors.green}${text}${colors.reset}`,
  28. warn: (text) => `${colors.yellow}${text}${colors.reset}`,
  29. tip: (text) => `${colors.blue}${text}${colors.reset}`,
  30. bright: (text) => `${colors.bright}${text}${colors.reset}`,
  31. dim: (text) => `${colors.dim}${text}${colors.reset}`,
  32. };
  33. assertRequiredPilotDeckEnv();
  34. console.log('SERVER_PORT from runtime config:', process.env.SERVER_PORT);
  35. import express from 'express';
  36. import { WebSocketServer, WebSocket } from 'ws';
  37. import bcrypt from 'bcrypt';
  38. import crypto from 'crypto';
  39. import os from 'os';
  40. import http from 'http';
  41. import cors from 'cors';
  42. import { promises as fsPromises } from 'fs';
  43. import { spawn, exec } from 'child_process';
  44. import pty from 'node-pty';
  45. import fetch from 'node-fetch';
  46. import mime from 'mime-types';
  47. import JSZip from 'jszip';
  48. import { readPermissionSettings } from './services/permissionSettings.js';
  49. import { getProjects, getProjectCronJobsOverview, getSessions, renameProject, deleteSession, deleteProject, addProjectManually, extractProjectDirectory, clearProjectDirectoryCache, searchConversations } from './projects.js';
  50. import {
  51. runChatViaGateway,
  52. abortViaGateway,
  53. decidePermissionViaGateway,
  54. grantSessionPermissionViaGateway,
  55. isSessionActiveViaGateway,
  56. getActiveTurnSnapshotFramesViaGateway,
  57. getActiveSessionIdsViaGateway,
  58. elicitationRespondViaGateway,
  59. getRouterDashboardData,
  60. getRouterSessionStats,
  61. getRouterStatsSummary,
  62. getPilotDeckGateway,
  63. registerAlwaysOnNotificationForwarding,
  64. getSessionTokenBudget,
  65. } from './pilotdeck-bridge.js';
  66. import sessionManager from './sessionManager.js';
  67. import gitRoutes from './routes/git.js';
  68. import authRoutes from './routes/auth.js';
  69. import mcpRoutes from './routes/mcp.js';
  70. import taskmasterRoutes from './routes/taskmaster.js';
  71. import memoryRoutes, { MEMORY_DASHBOARD_DIR } from './routes/memory.js';
  72. import mcpUtilsRoutes from './routes/mcp-utils.js';
  73. import commandsRoutes from './routes/commands.js';
  74. import skillsRoutes from './routes/skills.js';
  75. import settingsRoutes from './routes/settings.js';
  76. import configRoutes from './routes/config.js';
  77. import { startPilotDeckConfigWatcher, stopPilotDeckConfigWatcher } from './services/pilotdeckConfigWatcher.js';
  78. import { getAlwaysOnDashboardEvents } from './services/always-on-events.js';
  79. import agentRoutes from './routes/agent.js';
  80. import projectsRoutes, { WORKSPACES_ROOT, validateWorkspacePath } from './routes/projects.js';
  81. import userRoutes from './routes/user.js';
  82. import pluginsRoutes from './routes/plugins.js';
  83. import messagesRoutes from './routes/messages.js';
  84. import { closeMemoryServices, startMemoryScheduler, stopMemoryScheduler } from './services/memoryService.js';
  85. import { createNormalizedMessage } from './pilotdeck-message.js';
  86. import { startEnabledPluginServers, stopAllPlugins, getPluginPort } from './utils/plugin-process-manager.js';
  87. import { initializeDatabase, sessionNamesDb, applyCustomSessionNames, userDb } from './database/db.js';
  88. import { configureWebPush } from './services/vapid-keys.js';
  89. import { sendCronDaemonRequest } from './services/cron-daemon-owner.js';
  90. import { createAlwaysOnHeartbeatManager } from './always-on-heartbeat.js';
  91. import { runServerStartupBeforeListen, startServerAfterStartup } from './services/server-startup.js';
  92. import { validateApiKey, authenticateToken, authenticateWebSocket } from './middleware/auth.js';
  93. import { DISABLE_LOCAL_AUTH, IS_PLATFORM } from './constants/config.js';
  94. import { getConnectableHost } from '../shared/networkHosts.js';
  95. import { contentDispositionAttachment } from './utils/downloadHeaders.js';
  96. // PilotDeck-only mode: chat execution always goes through src/gateway via
  97. // cursor-cli, openai-codex, gemini-cli) has been removed.
  98. const VALID_PROVIDERS = ['pilotdeck'];
  99. // File-system watchers for the chat transcript root maintained by
  100. // PilotDeck. Provider-specific watchers (.pilotdeck) were dropped along with the four provider adapters.
  101. // .gemini) were dropped along with the four provider adapters.
  102. const PROVIDER_WATCH_PATHS = [
  103. {
  104. provider: 'pilotdeck',
  105. rootPath: path.join(
  106. process.env.PILOT_HOME || path.join(os.homedir(), '.pilotdeck'),
  107. 'projects',
  108. ),
  109. },
  110. ];
  111. const WATCHER_IGNORED_PATTERNS = [
  112. '**/node_modules/**',
  113. '**/.git/**',
  114. '**/dist/**',
  115. '**/build/**',
  116. '**/*.tmp',
  117. '**/*.swp',
  118. '**/.DS_Store'
  119. ];
  120. const WATCHER_DEBOUNCE_MS = 300;
  121. let projectsWatchers = [];
  122. let projectsWatcherDebounceTimer = null;
  123. const connectedClients = new Set();
  124. const alwaysOnHeartbeat = createAlwaysOnHeartbeatManager({
  125. // Legacy four-provider session details have been removed; PilotDeck
  126. // gateway sessions are tracked by `pilotdeck-bridge.js` instead.
  127. getActivePilotDeckSessions: () => []
  128. });
  129. registerAlwaysOnNotificationForwarding(connectedClients);
  130. let isGetProjectsRunning = false; // Flag to prevent reentrant calls
  131. let pilotDeckProxyProcess = null;
  132. function resolveBunExecutable() {
  133. const candidates = [
  134. process.env.BUN_BIN,
  135. process.env.BUN,
  136. process.env.BUN_INSTALL ? path.join(process.env.BUN_INSTALL, 'bin', 'bun') : null,
  137. path.join(os.homedir(), '.bun', 'bin', 'bun'),
  138. '/opt/homebrew/bin/bun',
  139. '/usr/local/bin/bun',
  140. 'bun',
  141. ].filter(Boolean);
  142. for (const candidate of candidates) {
  143. if (candidate === 'bun' || fs.existsSync(candidate)) {
  144. return candidate;
  145. }
  146. }
  147. return 'bun';
  148. }
  149. function isLocalPortListening(port, host = '127.0.0.1', timeoutMs = 400) {
  150. return new Promise(resolve => {
  151. const socket = net.createConnection({ port, host });
  152. const finalize = (isOpen) => {
  153. socket.destroy();
  154. resolve(isOpen);
  155. };
  156. socket.setTimeout(timeoutMs);
  157. socket.once('connect', () => finalize(true));
  158. socket.once('timeout', () => finalize(false));
  159. socket.once('error', () => finalize(false));
  160. });
  161. }
  162. async function waitForLocalPort(port, host = '127.0.0.1', timeoutMs = 4000) {
  163. const deadline = Date.now() + timeoutMs;
  164. while (Date.now() < deadline) {
  165. if (await isLocalPortListening(port, host)) {
  166. return true;
  167. }
  168. await new Promise(resolve => setTimeout(resolve, 120));
  169. }
  170. return false;
  171. }
  172. async function ensurePilotDeckProxyRunning() {
  173. // The legacy in-process proxy bootstrap was tied to a bundled CCR pipeline
  174. // that we removed during the PilotDeck-only migration.
  175. // Model traffic now flows through `src/gateway` directly. Returning
  176. // immediately keeps any callers happy without touching dead code.
  177. return;
  178. // The unreachable body below is left as historical scaffolding.
  179. // eslint-disable-next-line no-unreachable
  180. const proxyPort = parseInt(process.env.PROXY_PORT || process.env.PILOTDECK_PROXY_PORT || '18080', 10);
  181. if (!proxyPort) return;
  182. if (await isLocalPortListening(proxyPort)) {
  183. console.log(`${c.info('[INFO]')} Reusing existing PilotDeck-friendly proxy on http://127.0.0.1:${proxyPort}`);
  184. return;
  185. }
  186. console.error(`[ERROR] PilotDeck proxy did not become ready on http://127.0.0.1:${proxyPort}`);
  187. }
  188. async function stopPilotDeckProxy() {
  189. if (!pilotDeckProxyProcess) {
  190. return;
  191. }
  192. const proxyProcess = pilotDeckProxyProcess;
  193. pilotDeckProxyProcess = null;
  194. if (proxyProcess.exitCode !== null || proxyProcess.signalCode !== null) {
  195. return;
  196. }
  197. await new Promise(resolve => {
  198. const timeout = setTimeout(() => {
  199. proxyProcess.kill('SIGKILL');
  200. }, 2000);
  201. proxyProcess.once('exit', () => {
  202. clearTimeout(timeout);
  203. resolve();
  204. });
  205. proxyProcess.kill('SIGTERM');
  206. });
  207. }
  208. process.on('pilotdeck:restart-proxy', async (done) => {
  209. try {
  210. await stopPilotDeckProxy();
  211. await ensurePilotDeckProxyRunning();
  212. if (typeof done === 'function') {
  213. done(null);
  214. }
  215. } catch (error) {
  216. if (typeof done === 'function') {
  217. done(error);
  218. }
  219. }
  220. });
  221. // Broadcast progress to all connected WebSocket clients
  222. function broadcastProgress(progress) {
  223. const message = JSON.stringify({
  224. type: 'loading_progress',
  225. ...progress
  226. });
  227. connectedClients.forEach(client => {
  228. if (client.readyState === WebSocket.OPEN) {
  229. client.send(message);
  230. }
  231. });
  232. }
  233. // Broadcasts ~/.pilotdeck/pilotdeck.yaml reload events (from UI saves or external file edits)
  234. // to every connected WebSocket client so open Settings tabs refresh instantly.
  235. function broadcastConfigReloaded(payload) {
  236. const message = JSON.stringify({ type: 'config:reloaded', ...payload });
  237. connectedClients.forEach((client) => {
  238. if (client.readyState === WebSocket.OPEN) {
  239. client.send(message);
  240. }
  241. });
  242. }
  243. process.on('pilotdeck:config-broadcast', broadcastConfigReloaded);
  244. async function setupProjectsWatcher() {
  245. const chokidar = (await import('chokidar')).default;
  246. if (projectsWatcherDebounceTimer) {
  247. clearTimeout(projectsWatcherDebounceTimer);
  248. projectsWatcherDebounceTimer = null;
  249. }
  250. await Promise.all(
  251. projectsWatchers.map(async (watcher) => {
  252. try {
  253. await watcher.close();
  254. } catch (error) {
  255. console.error('[WARN] Failed to close watcher:', error);
  256. }
  257. })
  258. );
  259. projectsWatchers = [];
  260. const debouncedUpdate = (eventType, filePath, provider, rootPath) => {
  261. if (projectsWatcherDebounceTimer) {
  262. clearTimeout(projectsWatcherDebounceTimer);
  263. }
  264. projectsWatcherDebounceTimer = setTimeout(async () => {
  265. // Prevent reentrant calls
  266. if (isGetProjectsRunning) {
  267. return;
  268. }
  269. try {
  270. isGetProjectsRunning = true;
  271. // Clear project directory cache when files change
  272. clearProjectDirectoryCache();
  273. // Get updated projects list
  274. const updatedProjects = await getProjects(broadcastProgress);
  275. // Notify all connected clients about the project changes
  276. const updateMessage = JSON.stringify({
  277. type: 'projects_updated',
  278. projects: updatedProjects,
  279. timestamp: new Date().toISOString(),
  280. changeType: eventType,
  281. changedFile: path.relative(rootPath, filePath),
  282. watchProvider: provider
  283. });
  284. connectedClients.forEach(client => {
  285. if (client.readyState === WebSocket.OPEN) {
  286. client.send(updateMessage);
  287. }
  288. });
  289. } catch (error) {
  290. console.error('[ERROR] Error handling project changes:', error);
  291. } finally {
  292. isGetProjectsRunning = false;
  293. }
  294. }, WATCHER_DEBOUNCE_MS);
  295. };
  296. for (const { provider, rootPath } of PROVIDER_WATCH_PATHS) {
  297. try {
  298. // chokidar v4 emits ENOENT via the "error" event for missing roots and will not auto-recover.
  299. // Ensure provider folders exist before creating the watcher so watching stays active.
  300. await fsPromises.mkdir(rootPath, { recursive: true });
  301. // Initialize chokidar watcher with optimized settings
  302. const watcher = chokidar.watch(rootPath, {
  303. ignored: WATCHER_IGNORED_PATTERNS,
  304. persistent: true,
  305. ignoreInitial: true, // Don't fire events for existing files on startup
  306. followSymlinks: false,
  307. depth: 10, // Reasonable depth limit
  308. awaitWriteFinish: {
  309. stabilityThreshold: 100, // Wait 100ms for file to stabilize
  310. pollInterval: 50
  311. }
  312. });
  313. // Set up event listeners
  314. watcher
  315. .on('add', (filePath) => debouncedUpdate('add', filePath, provider, rootPath))
  316. .on('change', (filePath) => debouncedUpdate('change', filePath, provider, rootPath))
  317. .on('unlink', (filePath) => debouncedUpdate('unlink', filePath, provider, rootPath))
  318. .on('addDir', (dirPath) => debouncedUpdate('addDir', dirPath, provider, rootPath))
  319. .on('unlinkDir', (dirPath) => debouncedUpdate('unlinkDir', dirPath, provider, rootPath))
  320. .on('error', (error) => {
  321. console.error(`[ERROR] ${provider} watcher error:`, error);
  322. })
  323. .on('ready', () => {
  324. });
  325. projectsWatchers.push(watcher);
  326. } catch (error) {
  327. console.error(`[ERROR] Failed to setup ${provider} watcher for ${rootPath}:`, error);
  328. }
  329. }
  330. if (projectsWatchers.length === 0) {
  331. console.error('[ERROR] Failed to setup any provider watchers');
  332. }
  333. }
  334. const app = express();
  335. const server = http.createServer(app);
  336. const ptySessionsMap = new Map();
  337. const PTY_SESSION_TIMEOUT = 30 * 60 * 1000;
  338. const SHELL_URL_PARSE_BUFFER_LIMIT = 32768;
  339. const ANSI_ESCAPE_SEQUENCE_REGEX = /\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1B\\))/g;
  340. const TRAILING_URL_PUNCTUATION_REGEX = /[)\]}>.,;:!?]+$/;
  341. function stripAnsiSequences(value = '') {
  342. return value.replace(ANSI_ESCAPE_SEQUENCE_REGEX, '');
  343. }
  344. function normalizeDetectedUrl(url) {
  345. if (!url || typeof url !== 'string') return null;
  346. const cleaned = url.trim().replace(TRAILING_URL_PUNCTUATION_REGEX, '');
  347. if (!cleaned) return null;
  348. try {
  349. const parsed = new URL(cleaned);
  350. if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
  351. return null;
  352. }
  353. return parsed.toString();
  354. } catch {
  355. return null;
  356. }
  357. }
  358. function extractUrlsFromText(value = '') {
  359. const directMatches = value.match(/https?:\/\/[^\s<>"'`\\\x1b\x07]+/gi) || [];
  360. // Handle wrapped terminal URLs split across lines by terminal width.
  361. const wrappedMatches = [];
  362. const continuationRegex = /^[A-Za-z0-9\-._~:/?#\[\]@!$&'()*+,;=%]+$/;
  363. const lines = value.split(/\r?\n/);
  364. for (let i = 0; i < lines.length; i++) {
  365. const line = lines[i].trim();
  366. const startMatch = line.match(/https?:\/\/[^\s<>"'`\\\x1b\x07]+/i);
  367. if (!startMatch) continue;
  368. let combined = startMatch[0];
  369. let j = i + 1;
  370. while (j < lines.length) {
  371. const continuation = lines[j].trim();
  372. if (!continuation) break;
  373. if (!continuationRegex.test(continuation)) break;
  374. combined += continuation;
  375. j++;
  376. }
  377. wrappedMatches.push(combined.replace(/\r?\n\s*/g, ''));
  378. }
  379. return Array.from(new Set([...directMatches, ...wrappedMatches]));
  380. }
  381. function shouldAutoOpenUrlFromOutput(value = '') {
  382. const normalized = value.toLowerCase();
  383. return (
  384. normalized.includes('browser didn\'t open') ||
  385. normalized.includes('open this url') ||
  386. normalized.includes('continue in your browser') ||
  387. normalized.includes('press enter to open') ||
  388. normalized.includes('open_url:')
  389. );
  390. }
  391. // Single WebSocket server that handles both paths
  392. const wss = new WebSocketServer({
  393. server,
  394. verifyClient: (info) => {
  395. console.log('WebSocket connection attempt to:', info.req.url);
  396. // Platform / no-login mode: allow connection without token
  397. if (IS_PLATFORM || DISABLE_LOCAL_AUTH) {
  398. const user = authenticateWebSocket(null); // Returns first DB user
  399. if (!user) {
  400. console.log('[WARN] WebSocket auth bypass: No user found in database');
  401. return false;
  402. }
  403. info.req.user = user;
  404. console.log('[OK] WebSocket authenticated (bypass) for user:', user.username);
  405. return true;
  406. }
  407. // Normal mode: verify token
  408. // Extract token from query parameters or headers
  409. const url = new URL(info.req.url, 'http://localhost');
  410. const token = url.searchParams.get('token') ||
  411. info.req.headers.authorization?.split(' ')[1];
  412. // Verify token
  413. const user = authenticateWebSocket(token);
  414. if (!user) {
  415. console.log('[WARN] WebSocket authentication failed');
  416. return false;
  417. }
  418. // Store user info in the request for later use
  419. info.req.user = user;
  420. console.log('[OK] WebSocket authenticated for user:', user.username);
  421. return true;
  422. }
  423. });
  424. // Make WebSocket server available to routes
  425. app.locals.wss = wss;
  426. app.use(cors({ exposedHeaders: ['X-Refreshed-Token'] }));
  427. app.use(express.json({
  428. limit: '50mb',
  429. type: (req) => {
  430. // Skip multipart/form-data requests (for file uploads like images)
  431. const contentType = req.headers['content-type'] || '';
  432. if (contentType.includes('multipart/form-data')) {
  433. return false;
  434. }
  435. return contentType.includes('json');
  436. }
  437. }));
  438. app.use(express.urlencoded({ limit: '50mb', extended: true }));
  439. // Public health check endpoint (no authentication required)
  440. app.get('/health', (req, res) => {
  441. res.json({
  442. status: 'ok',
  443. timestamp: new Date().toISOString(),
  444. installMode
  445. });
  446. });
  447. // Optional API key validation (if configured)
  448. app.use('/api', validateApiKey);
  449. // Authentication routes (public)
  450. app.use('/api/auth', authRoutes);
  451. // Projects API Routes (protected)
  452. app.use('/api/projects', authenticateToken, projectsRoutes);
  453. // Git API Routes (protected)
  454. app.use('/api/git', authenticateToken, gitRoutes);
  455. // MCP API Routes (protected)
  456. app.use('/api/mcp', authenticateToken, mcpRoutes);
  457. // TaskMaster API Routes (protected)
  458. app.use('/api/taskmaster', authenticateToken, taskmasterRoutes);
  459. // Memory API Routes (protected)
  460. app.use('/api/memory', authenticateToken, memoryRoutes);
  461. // MCP utilities
  462. app.use('/api/mcp-utils', authenticateToken, mcpUtilsRoutes);
  463. // Commands API Routes (protected)
  464. app.use('/api/commands', authenticateToken, commandsRoutes);
  465. // Skills API Routes (protected) — list/edit/install skills surfaced in the
  466. // top-right Skills tab. Backed by ~/.pilotdeck/skills/ and project-level
  467. // .pilotdeck/skills/ via PilotDeck plugin runtime.
  468. app.use('/api/skills', authenticateToken, skillsRoutes);
  469. // Settings API Routes (protected)
  470. app.use('/api/settings', authenticateToken, settingsRoutes);
  471. // PilotDeck unified YAML config routes (protected)
  472. app.use('/api/config', authenticateToken, configRoutes);
  473. // User API Routes (protected)
  474. app.use('/api/user', authenticateToken, userRoutes);
  475. // Plugins API Routes (protected)
  476. app.use('/api/plugins', authenticateToken, pluginsRoutes);
  477. // Unified session messages route (protected) — PilotDeck-only.
  478. app.use('/api/sessions', authenticateToken, messagesRoutes);
  479. // Agent API Routes (uses API key authentication)
  480. app.use('/api/agent', agentRoutes);
  481. // Legacy four-provider config endpoints have been removed. The runtime
  482. // model is read from PilotDeck config; fall back to a static stub so any
  483. // older frontend code paths render without crashing.
  484. app.get('/api/agents/runtime-config', authenticateToken, (_req, res) => {
  485. const permSettings = readPermissionSettings();
  486. res.json({
  487. pilotdeck: { provider: 'pilotdeck' },
  488. permissions: {
  489. skipPermissions: permSettings.skipPermissions,
  490. effectiveMode: permSettings.skipPermissions ? 'bypassPermissions' : 'default',
  491. },
  492. });
  493. });
  494. // Provider-specific endpoints removed by the PilotDeck-only migration.
  495. // Returning a structured error keeps any stragglers in the UI from
  496. // hanging on an unanswered fetch.
  497. const PROVIDER_REMOVED_PATHS = ['/api/cursor', '/api/codex', '/api/gemini', '/api/cli'];
  498. for (const removedPrefix of PROVIDER_REMOVED_PATHS) {
  499. app.use(removedPrefix, (_req, res) => {
  500. res.status(410).json({
  501. error: 'endpoint_removed',
  502. message: `Provider endpoint ${removedPrefix} was removed during the PilotDeck-only migration.`,
  503. });
  504. });
  505. }
  506. // PilotDeck routing dashboard. The `/api/ccr/*` URL family was kept for
  507. // frontend back-compat (Dashboard tab + useRouterSettings) but the data
  508. // now comes from `src/router/stats/TokenStatsCollector` via the
  509. app.get('/api/ccr/dashboard', authenticateToken, (_req, res) => {
  510. try {
  511. res.json(getRouterDashboardData());
  512. } catch (error) {
  513. console.error('[router-dashboard] failed:', error);
  514. res.status(500).json({ error: error?.message || 'router-dashboard failed' });
  515. }
  516. });
  517. app.get('/api/always-on/events', authenticateToken, async (req, res) => {
  518. try {
  519. const limit = Number.parseInt(req.query?.limit || '', 10);
  520. const since = req.query?.since || undefined;
  521. const result = await getAlwaysOnDashboardEvents({
  522. limit: Number.isFinite(limit) ? limit : 200,
  523. since: typeof since === 'string' ? since : undefined,
  524. });
  525. res.json(result);
  526. } catch (error) {
  527. console.error('[always-on-events] failed:', error);
  528. res.status(500).json({ error: error?.message || 'always-on-events failed' });
  529. }
  530. });
  531. app.get('/api/always-on/cron-jobs', authenticateToken, async (_req, res) => {
  532. try {
  533. const result = await getProjectCronJobsOverview();
  534. res.json(result);
  535. } catch (error) {
  536. console.error('[always-on-cron-jobs] failed:', error);
  537. res.status(500).json({ error: error?.message || 'always-on-cron-jobs failed' });
  538. }
  539. });
  540. app.post('/api/always-on/cron-jobs/:taskId/run-now', authenticateToken, async (req, res) => {
  541. try {
  542. const gateway = await getPilotDeckGateway();
  543. const result = await gateway.cronRunNow({ taskId: req.params.taskId });
  544. res.json(result);
  545. } catch (error) {
  546. console.error('[always-on-cron-run-now] failed:', error);
  547. res.status(500).json({ error: error?.message || 'cron run-now failed' });
  548. }
  549. });
  550. app.post('/api/always-on/cron-jobs/:taskId/stop', authenticateToken, async (req, res) => {
  551. try {
  552. const gateway = await getPilotDeckGateway();
  553. const result = await gateway.cronStop({ taskId: req.params.taskId });
  554. res.json(result);
  555. } catch (error) {
  556. console.error('[always-on-cron-stop] failed:', error);
  557. res.status(500).json({ error: error?.message || 'cron stop failed' });
  558. }
  559. });
  560. app.delete('/api/always-on/cron-jobs/:taskId', authenticateToken, async (req, res) => {
  561. try {
  562. const gateway = await getPilotDeckGateway();
  563. const result = await gateway.cronDelete({ taskId: req.params.taskId, stopRunning: true });
  564. res.json(result);
  565. } catch (error) {
  566. console.error('[always-on-cron-delete] failed:', error);
  567. res.status(500).json({ error: error?.message || 'cron delete failed' });
  568. }
  569. });
  570. app.get('/api/ccr/health', authenticateToken, (_req, res) => {
  571. res.json({
  572. status: 'ok',
  573. timestamp: new Date().toISOString(),
  574. port: null,
  575. embedded: true,
  576. backend: 'pilotdeck-router',
  577. });
  578. });
  579. app.get('/api/ccr/config', authenticateToken, (_req, res) => {
  580. // The legacy CCR YAML schema is no longer the source of truth for
  581. // model routing — that lives in PilotDeck config now. Return null so
  582. // the legacy useRouterSettings hook simply renders the "no config"
  583. // empty state instead of a config editor.
  584. res.json(null);
  585. });
  586. app.get('/api/ccr/stats/summary', authenticateToken, (_req, res) => {
  587. try {
  588. res.json(getRouterStatsSummary());
  589. } catch (error) {
  590. res.status(500).json({ error: error?.message || 'router-stats-summary failed' });
  591. }
  592. });
  593. app.get('/api/ccr/stats/sessions/:sessionId', authenticateToken, (req, res) => {
  594. try {
  595. const stats = getRouterSessionStats(req.params.sessionId);
  596. if (!stats) {
  597. return res.status(404).json({ error: 'session_not_found' });
  598. }
  599. res.json(stats);
  600. } catch (error) {
  601. res.status(500).json({ error: error?.message || 'router-stats-session failed' });
  602. }
  603. });
  604. app.post('/api/ccr/stats/reset', authenticateToken, (_req, res) => {
  605. // Reset would require reaching into per-project TokenStatsCollector
  606. // instances; that is not exposed today. Surface a clear hint instead
  607. // of silently no-oping.
  608. res.status(501).json({
  609. error: 'not_implemented',
  610. message: 'Per-project router stats reset is not exposed yet; restart the PilotDeck server to clear in-memory state.',
  611. });
  612. });
  613. app.put('/api/ccr/config', authenticateToken, (_req, res) => {
  614. res.status(501).json({
  615. error: 'not_implemented',
  616. message: 'Routing configuration is owned by PilotDeck config (~/.pilotdeck/pilotdeck.yaml). Edit it directly via /api/config.',
  617. });
  618. });
  619. app.get('/memory-dashboard', authenticateToken, (req, res) => {
  620. const indexPath = path.join(MEMORY_DASHBOARD_DIR, 'index.html');
  621. if (!fs.existsSync(indexPath)) {
  622. res.status(404).type('text/plain').send('Memory dashboard assets not bundled.');
  623. return;
  624. }
  625. res.sendFile(indexPath);
  626. });
  627. app.use('/memory-dashboard', authenticateToken, express.static(MEMORY_DASHBOARD_DIR, {
  628. setHeaders: (res, filePath) => {
  629. if (filePath.endsWith('.html')) {
  630. res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
  631. res.setHeader('Pragma', 'no-cache');
  632. res.setHeader('Expires', '0');
  633. }
  634. }
  635. }));
  636. // Hard 404 boundary: anything still asking for /memory-dashboard/* after the
  637. // static middleware is a missing asset. Without this, the request would fall
  638. // through to the SPA wildcard below and return the PilotDeck shell index.html,
  639. // which the MemoryPanel iframe then renders — recursively nesting the entire
  640. // app inside itself (see bug: "嵌套显示 + general memory 多次出现").
  641. app.use('/memory-dashboard', (_req, res) => {
  642. res.status(404).type('text/plain').send('Not found in memory-dashboard.');
  643. });
  644. // Serve public files (like api-docs.html)
  645. app.use(express.static(path.join(__dirname, '../public')));
  646. // Static files served after API routes
  647. // Add cache control: HTML files should not be cached, but assets can be cached
  648. app.use(express.static(path.join(__dirname, '../dist'), {
  649. setHeaders: (res, filePath) => {
  650. if (filePath.endsWith('.html')) {
  651. // Prevent HTML caching to avoid service worker issues after builds
  652. res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
  653. res.setHeader('Pragma', 'no-cache');
  654. res.setHeader('Expires', '0');
  655. } else if (filePath.match(/\.(js|css|woff2?|ttf|eot|svg|png|jpg|jpeg|gif|ico)$/)) {
  656. // Cache static assets for 1 year (they have hashed names)
  657. res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
  658. }
  659. }
  660. }));
  661. // API Routes (protected)
  662. // /api/config endpoint removed - no longer needed
  663. // Frontend now uses window.location for WebSocket URLs.
  664. // /api/system/update was the V1 "Update available" banner backend; the
  665. // VersionUpgradeModal that consumed it was removed during the V1 cleanup.
  666. app.get('/api/projects', authenticateToken, async (req, res) => {
  667. try {
  668. const projects = await getProjects(broadcastProgress);
  669. res.json(projects);
  670. } catch (error) {
  671. res.status(500).json({ error: error.message });
  672. }
  673. });
  674. app.get('/api/projects/:projectName/sessions', authenticateToken, async (req, res) => {
  675. try {
  676. const { limit = 5, offset = 0 } = req.query;
  677. const result = await getSessions(req.params.projectName, parseInt(limit), parseInt(offset));
  678. applyCustomSessionNames(result.sessions, 'pilotdeck');
  679. res.json(result);
  680. } catch (error) {
  681. res.status(500).json({ error: error.message });
  682. }
  683. });
  684. // Rename project endpoint
  685. app.put('/api/projects/:projectName/rename', authenticateToken, async (req, res) => {
  686. try {
  687. const { displayName } = req.body;
  688. await renameProject(req.params.projectName, displayName);
  689. res.json({ success: true });
  690. } catch (error) {
  691. res.status(500).json({ error: error.message });
  692. }
  693. });
  694. // Delete session endpoint
  695. app.delete('/api/projects/:projectName/sessions/:sessionId', authenticateToken, async (req, res) => {
  696. try {
  697. const { projectName, sessionId } = req.params;
  698. console.log(`[API] Deleting session: ${sessionId} from project: ${projectName}`);
  699. await deleteSession(projectName, sessionId, {
  700. sessionKind: req.query.sessionKind || null,
  701. parentSessionId: req.query.parentSessionId || null,
  702. relativeTranscriptPath: req.query.relativeTranscriptPath || null,
  703. });
  704. sessionNamesDb.deleteName(sessionId, 'pilotdeck');
  705. console.log(`[API] Session ${sessionId} deleted successfully`);
  706. res.json({ success: true });
  707. } catch (error) {
  708. console.error(`[API] Error deleting session ${req.params.sessionId}:`, error);
  709. res.status(500).json({ error: error.message });
  710. }
  711. });
  712. // Rename session endpoint
  713. app.put('/api/sessions/:sessionId/rename', authenticateToken, async (req, res) => {
  714. try {
  715. const { sessionId } = req.params;
  716. const safeSessionId = String(sessionId).replace(/[^a-zA-Z0-9._-]/g, '');
  717. if (!safeSessionId || safeSessionId !== String(sessionId)) {
  718. return res.status(400).json({ error: 'Invalid sessionId' });
  719. }
  720. const { summary, provider } = req.body;
  721. if (!summary || typeof summary !== 'string' || summary.trim() === '') {
  722. return res.status(400).json({ error: 'Summary is required' });
  723. }
  724. if (summary.trim().length > 500) {
  725. return res.status(400).json({ error: 'Summary must not exceed 500 characters' });
  726. }
  727. if (!provider || !VALID_PROVIDERS.includes(provider)) {
  728. return res.status(400).json({ error: `Provider must be one of: ${VALID_PROVIDERS.join(', ')}` });
  729. }
  730. sessionNamesDb.setName(safeSessionId, provider, summary.trim());
  731. res.json({ success: true });
  732. } catch (error) {
  733. console.error(`[API] Error renaming session ${req.params.sessionId}:`, error);
  734. res.status(500).json({ error: error.message });
  735. }
  736. });
  737. // Delete project endpoint (force=true to delete with sessions)
  738. app.delete('/api/projects/:projectName', authenticateToken, async (req, res) => {
  739. try {
  740. const { projectName } = req.params;
  741. const force = req.query.force === 'true';
  742. await deleteProject(projectName, force);
  743. res.json({ success: true });
  744. } catch (error) {
  745. res.status(500).json({ error: error.message });
  746. }
  747. });
  748. // Create project endpoint
  749. app.post('/api/projects/create', authenticateToken, async (req, res) => {
  750. try {
  751. const { path: projectPath } = req.body;
  752. if (!projectPath || !projectPath.trim()) {
  753. return res.status(400).json({ error: 'Project path is required' });
  754. }
  755. const project = await addProjectManually(projectPath.trim());
  756. res.json({ success: true, project });
  757. } catch (error) {
  758. console.error('Error creating project:', error);
  759. res.status(500).json({ error: error.message });
  760. }
  761. });
  762. // Search conversations content (SSE streaming)
  763. app.get('/api/search/conversations', authenticateToken, async (req, res) => {
  764. const query = typeof req.query.q === 'string' ? req.query.q.trim() : '';
  765. const parsedLimit = Number.parseInt(String(req.query.limit), 10);
  766. const limit = Number.isNaN(parsedLimit) ? 50 : Math.max(1, Math.min(parsedLimit, 100));
  767. if (query.length < 2) {
  768. return res.status(400).json({ error: 'Query must be at least 2 characters' });
  769. }
  770. res.writeHead(200, {
  771. 'Content-Type': 'text/event-stream',
  772. 'Cache-Control': 'no-cache',
  773. 'Connection': 'keep-alive',
  774. 'X-Accel-Buffering': 'no',
  775. });
  776. let closed = false;
  777. const abortController = new AbortController();
  778. req.on('close', () => { closed = true; abortController.abort(); });
  779. try {
  780. await searchConversations(query, limit, ({ projectResult, totalMatches, scannedProjects, totalProjects }) => {
  781. if (closed) return;
  782. if (projectResult) {
  783. res.write(`event: result\ndata: ${JSON.stringify({ projectResult, totalMatches, scannedProjects, totalProjects })}\n\n`);
  784. } else {
  785. res.write(`event: progress\ndata: ${JSON.stringify({ totalMatches, scannedProjects, totalProjects })}\n\n`);
  786. }
  787. }, abortController.signal);
  788. if (!closed) {
  789. res.write(`event: done\ndata: {}\n\n`);
  790. }
  791. } catch (error) {
  792. console.error('Error searching conversations:', error);
  793. if (!closed) {
  794. res.write(`event: error\ndata: ${JSON.stringify({ error: 'Search failed' })}\n\n`);
  795. }
  796. } finally {
  797. if (!closed) {
  798. res.end();
  799. }
  800. }
  801. });
  802. const expandWorkspacePath = (inputPath) => {
  803. if (!inputPath) return inputPath;
  804. if (inputPath === '~') {
  805. return WORKSPACES_ROOT;
  806. }
  807. if (inputPath.startsWith('~/') || inputPath.startsWith('~\\')) {
  808. return path.join(WORKSPACES_ROOT, inputPath.slice(2));
  809. }
  810. return inputPath;
  811. };
  812. function resolvePathInProject(projectRoot, targetPath = '') {
  813. const resolved = path.isAbsolute(targetPath)
  814. ? path.resolve(targetPath)
  815. : path.resolve(projectRoot, targetPath);
  816. const normalizedRoot = path.resolve(projectRoot);
  817. if (resolved !== normalizedRoot && !resolved.startsWith(normalizedRoot + path.sep)) {
  818. return { valid: false, error: 'Path must be under project root' };
  819. }
  820. return { valid: true, resolved };
  821. }
  822. function setPreviewContentType(res, filePath) {
  823. const mimeType = mime.lookup(filePath) || 'application/octet-stream';
  824. const charset = mimeType.startsWith('text/') || mimeType === 'application/javascript' || mimeType === 'application/json'
  825. ? '; charset=utf-8'
  826. : '';
  827. res.setHeader('Content-Type', `${mimeType}${charset}`);
  828. }
  829. async function addDirectoryToZip(zip, directoryPath, rootPath) {
  830. const entries = await fsPromises.readdir(directoryPath, { withFileTypes: true });
  831. for (const entry of entries) {
  832. const absolutePath = path.join(directoryPath, entry.name);
  833. const relativePath = path.relative(rootPath, absolutePath).split(path.sep).join('/');
  834. if (!relativePath) {
  835. continue;
  836. }
  837. if (entry.isDirectory()) {
  838. zip.folder(relativePath);
  839. await addDirectoryToZip(zip, absolutePath, rootPath);
  840. continue;
  841. }
  842. if (entry.isFile()) {
  843. const [content, stats] = await Promise.all([
  844. fsPromises.readFile(absolutePath),
  845. fsPromises.stat(absolutePath),
  846. ]);
  847. zip.file(relativePath, content, { date: stats.mtime });
  848. }
  849. }
  850. }
  851. function getSafeZipFilename(projectName) {
  852. const safeName = String(projectName || 'project')
  853. .replace(/[\\/:*?"<>|\x00-\x1f]/g, '-')
  854. .replace(/^\.+$/, 'project')
  855. .trim() || 'project';
  856. return `${safeName}.zip`;
  857. }
  858. // Browse filesystem endpoint for project suggestions - uses existing getFileTree
  859. app.get('/api/browse-filesystem', authenticateToken, async (req, res) => {
  860. try {
  861. const { path: dirPath } = req.query;
  862. console.log('[API] Browse filesystem request for path:', dirPath);
  863. console.log('[API] WORKSPACES_ROOT is:', WORKSPACES_ROOT);
  864. // Default to home directory if no path provided
  865. const defaultRoot = WORKSPACES_ROOT;
  866. let targetPath = dirPath ? expandWorkspacePath(dirPath) : defaultRoot;
  867. // Resolve and normalize the path
  868. targetPath = path.resolve(targetPath);
  869. // Browsing a directory is read-only — we only list its children.
  870. // The actual workspace-selection validation happens in the
  871. // create-workspace / clone-progress endpoints, so we don't gate
  872. // browsing with validateWorkspacePath (which would block navigating
  873. // through forbidden directories like "/" to reach valid children).
  874. const resolvedPath = targetPath;
  875. // Security check - ensure path is accessible
  876. try {
  877. await fs.promises.access(resolvedPath);
  878. const stats = await fs.promises.stat(resolvedPath);
  879. if (!stats.isDirectory()) {
  880. return res.status(400).json({ error: 'Path is not a directory' });
  881. }
  882. } catch (err) {
  883. return res.status(404).json({ error: 'Directory not accessible' });
  884. }
  885. // Use existing getFileTree function with shallow depth (only direct children)
  886. const fileTree = await getFileTree(resolvedPath, 1, 0, false); // maxDepth=1, showHidden=false
  887. // Filter only directories and format for suggestions
  888. const directories = fileTree
  889. .filter(item => item.type === 'directory')
  890. .map(item => ({
  891. path: item.path,
  892. name: item.name,
  893. type: 'directory'
  894. }))
  895. .sort((a, b) => {
  896. const aHidden = a.name.startsWith('.');
  897. const bHidden = b.name.startsWith('.');
  898. if (aHidden && !bHidden) return 1;
  899. if (!aHidden && bHidden) return -1;
  900. return a.name.localeCompare(b.name);
  901. });
  902. // Add common directories if browsing home directory
  903. const suggestions = [];
  904. let resolvedWorkspaceRoot = defaultRoot;
  905. try {
  906. resolvedWorkspaceRoot = await fsPromises.realpath(defaultRoot);
  907. } catch (error) {
  908. // Use default root as-is if realpath fails
  909. }
  910. if (resolvedPath === resolvedWorkspaceRoot) {
  911. const commonDirs = ['Desktop', 'Documents', 'Projects', 'Development', 'Dev', 'Code', 'workspace'];
  912. const existingCommon = directories.filter(dir => commonDirs.includes(dir.name));
  913. const otherDirs = directories.filter(dir => !commonDirs.includes(dir.name));
  914. suggestions.push(...existingCommon, ...otherDirs);
  915. } else {
  916. suggestions.push(...directories);
  917. }
  918. res.json({
  919. path: resolvedPath,
  920. suggestions: suggestions
  921. });
  922. } catch (error) {
  923. console.error('Error browsing filesystem:', error);
  924. res.status(500).json({ error: 'Failed to browse filesystem' });
  925. }
  926. });
  927. app.post('/api/create-folder', authenticateToken, async (req, res) => {
  928. try {
  929. const { path: folderPath } = req.body;
  930. if (!folderPath) {
  931. return res.status(400).json({ error: 'Path is required' });
  932. }
  933. const expandedPath = expandWorkspacePath(folderPath);
  934. const resolvedInput = path.resolve(expandedPath);
  935. const validation = await validateWorkspacePath(resolvedInput);
  936. if (!validation.valid) {
  937. return res.status(403).json({ error: validation.error });
  938. }
  939. const targetPath = validation.resolvedPath || resolvedInput;
  940. const parentDir = path.dirname(targetPath);
  941. try {
  942. await fs.promises.access(parentDir);
  943. } catch (err) {
  944. return res.status(404).json({ error: 'Parent directory does not exist' });
  945. }
  946. try {
  947. await fs.promises.access(targetPath);
  948. return res.status(409).json({ error: 'Folder already exists' });
  949. } catch (err) {
  950. // Folder doesn't exist, which is what we want
  951. }
  952. try {
  953. await fs.promises.mkdir(targetPath, { recursive: false });
  954. res.json({ success: true, path: targetPath });
  955. } catch (mkdirError) {
  956. if (mkdirError.code === 'EEXIST') {
  957. return res.status(409).json({ error: 'Folder already exists' });
  958. }
  959. throw mkdirError;
  960. }
  961. } catch (error) {
  962. console.error('Error creating folder:', error);
  963. res.status(500).json({ error: 'Failed to create folder' });
  964. }
  965. });
  966. // Read file content endpoint
  967. app.get('/api/projects/:projectName/file', authenticateToken, async (req, res) => {
  968. try {
  969. const { projectName } = req.params;
  970. const { filePath } = req.query;
  971. // Security: ensure the requested path is inside the project root
  972. if (!filePath) {
  973. return res.status(400).json({ error: 'Invalid file path' });
  974. }
  975. const projectRoot = await extractProjectDirectory(projectName).catch(() => null);
  976. if (!projectRoot) {
  977. return res.status(404).json({ error: 'Project not found' });
  978. }
  979. // Handle both absolute and relative paths
  980. const resolved = path.isAbsolute(filePath)
  981. ? path.resolve(filePath)
  982. : path.resolve(projectRoot, filePath);
  983. const normalizedRoot = path.resolve(projectRoot) + path.sep;
  984. if (!resolved.startsWith(normalizedRoot)) {
  985. return res.status(403).json({ error: 'Path must be under project root' });
  986. }
  987. const content = await fsPromises.readFile(resolved, 'utf8');
  988. res.json({ content, path: resolved });
  989. } catch (error) {
  990. console.error('Error reading file:', error);
  991. if (error.code === 'ENOENT') {
  992. res.status(404).json({ error: 'File not found' });
  993. } else if (error.code === 'EACCES') {
  994. res.status(403).json({ error: 'Permission denied' });
  995. } else {
  996. res.status(500).json({ error: error.message });
  997. }
  998. }
  999. });
  1000. // Serve raw file bytes for previews and downloads.
  1001. app.get('/api/projects/:projectName/files/content', authenticateToken, async (req, res) => {
  1002. try {
  1003. const { projectName } = req.params;
  1004. const { path: filePath } = req.query;
  1005. // Security: ensure the requested path is inside the project root
  1006. if (!filePath) {
  1007. return res.status(400).json({ error: 'Invalid file path' });
  1008. }
  1009. const projectRoot = await extractProjectDirectory(projectName).catch(() => null);
  1010. if (!projectRoot) {
  1011. return res.status(404).json({ error: 'Project not found' });
  1012. }
  1013. // Match the text reader endpoint so callers can pass either project-relative
  1014. // or absolute paths without changing how the bytes are served.
  1015. const resolved = path.isAbsolute(filePath)
  1016. ? path.resolve(filePath)
  1017. : path.resolve(projectRoot, filePath);
  1018. const normalizedRoot = path.resolve(projectRoot) + path.sep;
  1019. if (!resolved.startsWith(normalizedRoot)) {
  1020. return res.status(403).json({ error: 'Path must be under project root' });
  1021. }
  1022. // Check if file exists
  1023. try {
  1024. await fsPromises.access(resolved);
  1025. } catch (error) {
  1026. return res.status(404).json({ error: 'File not found' });
  1027. }
  1028. // Get file extension and set appropriate content type
  1029. const mimeType = mime.lookup(resolved) || 'application/octet-stream';
  1030. res.setHeader('Content-Type', mimeType);
  1031. if (req.query.download) {
  1032. const basename = path.basename(resolved);
  1033. res.setHeader('Content-Disposition', contentDispositionAttachment(basename));
  1034. }
  1035. // Stream the file
  1036. const fileStream = fs.createReadStream(resolved);
  1037. fileStream.pipe(res);
  1038. fileStream.on('error', (error) => {
  1039. console.error('Error streaming file:', error);
  1040. if (!res.headersSent) {
  1041. res.status(500).json({ error: 'Error reading file' });
  1042. }
  1043. });
  1044. } catch (error) {
  1045. console.error('Error serving binary file:', error);
  1046. if (!res.headersSent) {
  1047. res.status(500).json({ error: error.message });
  1048. }
  1049. }
  1050. });
  1051. // Serve project files through a stable project-root URL so generated HTML can
  1052. // load sibling CSS, JS and image assets with normal relative paths.
  1053. app.get('/api/projects/:projectName/preview/*', authenticateToken, async (req, res) => {
  1054. try {
  1055. const { projectName } = req.params;
  1056. const relativeFilePath = req.params[0] || 'index.html';
  1057. const projectRoot = await extractProjectDirectory(projectName).catch(() => null);
  1058. if (!projectRoot) {
  1059. return res.status(404).json({ error: 'Project not found' });
  1060. }
  1061. const resolvedResult = resolvePathInProject(projectRoot, relativeFilePath);
  1062. if (!resolvedResult.valid) {
  1063. return res.status(403).json({ error: resolvedResult.error });
  1064. }
  1065. let resolved = resolvedResult.resolved;
  1066. let stats = await fsPromises.stat(resolved).catch(() => null);
  1067. if (stats?.isDirectory()) {
  1068. resolved = path.join(resolved, 'index.html');
  1069. stats = await fsPromises.stat(resolved).catch(() => null);
  1070. }
  1071. if (!stats || !stats.isFile()) {
  1072. return res.status(404).type('text/plain').send('Preview file not found.');
  1073. }
  1074. res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
  1075. setPreviewContentType(res, resolved);
  1076. fs.createReadStream(resolved).pipe(res);
  1077. } catch (error) {
  1078. console.error('Error serving project preview:', error);
  1079. res.status(500).json({ error: error.message });
  1080. }
  1081. });
  1082. // Download the complete project as a zip archive.
  1083. app.get('/api/projects/:projectName/download', authenticateToken, async (req, res) => {
  1084. try {
  1085. const { projectName } = req.params;
  1086. const projectRoot = await extractProjectDirectory(projectName).catch(() => null);
  1087. if (!projectRoot) {
  1088. return res.status(404).json({ error: 'Project not found' });
  1089. }
  1090. const rootStats = await fsPromises.stat(projectRoot).catch(() => null);
  1091. if (!rootStats?.isDirectory()) {
  1092. return res.status(404).json({ error: 'Project directory not found' });
  1093. }
  1094. const zip = new JSZip();
  1095. await addDirectoryToZip(zip, projectRoot, projectRoot);
  1096. const filename = getSafeZipFilename(projectName);
  1097. res.setHeader('Content-Type', 'application/zip');
  1098. res.setHeader('Content-Disposition', contentDispositionAttachment(filename));
  1099. const zipStream = zip.generateNodeStream({
  1100. type: 'nodebuffer',
  1101. compression: 'DEFLATE',
  1102. compressionOptions: { level: 6 },
  1103. });
  1104. zipStream.on('error', (error) => {
  1105. console.error('Error streaming project zip:', error);
  1106. if (!res.headersSent) {
  1107. res.status(500).json({ error: 'Failed to generate project archive' });
  1108. } else {
  1109. res.end();
  1110. }
  1111. });
  1112. zipStream.pipe(res);
  1113. } catch (error) {
  1114. console.error('Error downloading project archive:', error);
  1115. if (!res.headersSent) {
  1116. res.status(500).json({ error: error.message });
  1117. }
  1118. }
  1119. });
  1120. // Save file content endpoint
  1121. app.put('/api/projects/:projectName/file', authenticateToken, async (req, res) => {
  1122. try {
  1123. const { projectName } = req.params;
  1124. const { filePath, content } = req.body;
  1125. // Security: ensure the requested path is inside the project root
  1126. if (!filePath) {
  1127. return res.status(400).json({ error: 'Invalid file path' });
  1128. }
  1129. if (content === undefined) {
  1130. return res.status(400).json({ error: 'Content is required' });
  1131. }
  1132. const projectRoot = await extractProjectDirectory(projectName).catch(() => null);
  1133. if (!projectRoot) {
  1134. return res.status(404).json({ error: 'Project not found' });
  1135. }
  1136. // Handle both absolute and relative paths
  1137. const resolved = path.isAbsolute(filePath)
  1138. ? path.resolve(filePath)
  1139. : path.resolve(projectRoot, filePath);
  1140. const normalizedRoot = path.resolve(projectRoot) + path.sep;
  1141. if (!resolved.startsWith(normalizedRoot)) {
  1142. return res.status(403).json({ error: 'Path must be under project root' });
  1143. }
  1144. // Write the new content
  1145. await fsPromises.writeFile(resolved, content, 'utf8');
  1146. res.json({
  1147. success: true,
  1148. path: resolved,
  1149. message: 'File saved successfully'
  1150. });
  1151. } catch (error) {
  1152. console.error('Error saving file:', error);
  1153. if (error.code === 'ENOENT') {
  1154. res.status(404).json({ error: 'File or directory not found' });
  1155. } else if (error.code === 'EACCES') {
  1156. res.status(403).json({ error: 'Permission denied' });
  1157. } else {
  1158. res.status(500).json({ error: error.message });
  1159. }
  1160. }
  1161. });
  1162. app.get('/api/projects/:projectName/files', authenticateToken, async (req, res) => {
  1163. try {
  1164. // Using fsPromises from import
  1165. // Use extractProjectDirectory to get the actual project path
  1166. let actualPath;
  1167. try {
  1168. actualPath = await extractProjectDirectory(req.params.projectName);
  1169. } catch (error) {
  1170. console.error('Error extracting project directory:', error);
  1171. // Fallback to simple dash replacement
  1172. actualPath = req.params.projectName.replace(/-/g, '/');
  1173. }
  1174. // Check if path exists
  1175. try {
  1176. await fsPromises.access(actualPath);
  1177. } catch (e) {
  1178. return res.status(404).json({ error: `Project path not found: ${actualPath}` });
  1179. }
  1180. const files = await getFileTree(actualPath, 10, 0, true);
  1181. res.json(files);
  1182. } catch (error) {
  1183. console.error('[ERROR] File tree error:', error.message);
  1184. res.status(500).json({ error: error.message });
  1185. }
  1186. });
  1187. // ============================================================================
  1188. // FILE OPERATIONS API ENDPOINTS
  1189. // ============================================================================
  1190. /**
  1191. * Validate that a path is within the project root
  1192. * @param {string} projectRoot - The project root path
  1193. * @param {string} targetPath - The path to validate
  1194. * @returns {{ valid: boolean, resolved?: string, error?: string }}
  1195. */
  1196. function validatePathInProject(projectRoot, targetPath) {
  1197. const resolved = path.isAbsolute(targetPath)
  1198. ? path.resolve(targetPath)
  1199. : path.resolve(projectRoot, targetPath);
  1200. const normalizedRoot = path.resolve(projectRoot) + path.sep;
  1201. if (!resolved.startsWith(normalizedRoot)) {
  1202. return { valid: false, error: 'Path must be under project root' };
  1203. }
  1204. return { valid: true, resolved };
  1205. }
  1206. /**
  1207. * Validate filename - check for invalid characters
  1208. * @param {string} name - The filename to validate
  1209. * @returns {{ valid: boolean, error?: string }}
  1210. */
  1211. function validateFilename(name) {
  1212. if (!name || !name.trim()) {
  1213. return { valid: false, error: 'Filename cannot be empty' };
  1214. }
  1215. // Check for invalid characters (Windows + Unix)
  1216. const invalidChars = /[<>:"/\\|?*\x00-\x1f]/;
  1217. if (invalidChars.test(name)) {
  1218. return { valid: false, error: 'Filename contains invalid characters' };
  1219. }
  1220. // Check for reserved names (Windows)
  1221. const reserved = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$/i;
  1222. if (reserved.test(name)) {
  1223. return { valid: false, error: 'Filename is a reserved name' };
  1224. }
  1225. // Check for dots only
  1226. if (/^\.+$/.test(name)) {
  1227. return { valid: false, error: 'Filename cannot be only dots' };
  1228. }
  1229. return { valid: true };
  1230. }
  1231. // POST /api/projects/:projectName/files/create - Create new file or directory
  1232. app.post('/api/projects/:projectName/files/create', authenticateToken, async (req, res) => {
  1233. try {
  1234. const { projectName } = req.params;
  1235. const { path: parentPath, type, name } = req.body;
  1236. // Validate input
  1237. if (!name || !type) {
  1238. return res.status(400).json({ error: 'Name and type are required' });
  1239. }
  1240. if (!['file', 'directory'].includes(type)) {
  1241. return res.status(400).json({ error: 'Type must be "file" or "directory"' });
  1242. }
  1243. const nameValidation = validateFilename(name);
  1244. if (!nameValidation.valid) {
  1245. return res.status(400).json({ error: nameValidation.error });
  1246. }
  1247. // Get project root
  1248. const projectRoot = await extractProjectDirectory(projectName).catch(() => null);
  1249. if (!projectRoot) {
  1250. return res.status(404).json({ error: 'Project not found' });
  1251. }
  1252. // Build and validate target path
  1253. const targetDir = parentPath || '';
  1254. const targetPath = targetDir ? path.join(targetDir, name) : name;
  1255. const validation = validatePathInProject(projectRoot, targetPath);
  1256. if (!validation.valid) {
  1257. return res.status(403).json({ error: validation.error });
  1258. }
  1259. const resolvedPath = validation.resolved;
  1260. // Check if already exists
  1261. try {
  1262. await fsPromises.access(resolvedPath);
  1263. return res.status(409).json({ error: `${type === 'file' ? 'File' : 'Directory'} already exists` });
  1264. } catch {
  1265. // Doesn't exist, which is what we want
  1266. }
  1267. // Create file or directory
  1268. if (type === 'directory') {
  1269. await fsPromises.mkdir(resolvedPath, { recursive: false });
  1270. } else {
  1271. // Ensure parent directory exists
  1272. const parentDir = path.dirname(resolvedPath);
  1273. try {
  1274. await fsPromises.access(parentDir);
  1275. } catch {
  1276. await fsPromises.mkdir(parentDir, { recursive: true });
  1277. }
  1278. await fsPromises.writeFile(resolvedPath, '', 'utf8');
  1279. }
  1280. res.json({
  1281. success: true,
  1282. path: resolvedPath,
  1283. name,
  1284. type,
  1285. message: `${type === 'file' ? 'File' : 'Directory'} created successfully`
  1286. });
  1287. } catch (error) {
  1288. console.error('Error creating file/directory:', error);
  1289. if (error.code === 'EACCES') {
  1290. res.status(403).json({ error: 'Permission denied' });
  1291. } else if (error.code === 'ENOENT') {
  1292. res.status(404).json({ error: 'Parent directory not found' });
  1293. } else {
  1294. res.status(500).json({ error: error.message });
  1295. }
  1296. }
  1297. });
  1298. // PUT /api/projects/:projectName/files/rename - Rename file or directory
  1299. app.put('/api/projects/:projectName/files/rename', authenticateToken, async (req, res) => {
  1300. try {
  1301. const { projectName } = req.params;
  1302. const { oldPath, newName } = req.body;
  1303. // Validate input
  1304. if (!oldPath || !newName) {
  1305. return res.status(400).json({ error: 'oldPath and newName are required' });
  1306. }
  1307. const nameValidation = validateFilename(newName);
  1308. if (!nameValidation.valid) {
  1309. return res.status(400).json({ error: nameValidation.error });
  1310. }
  1311. // Get project root
  1312. const projectRoot = await extractProjectDirectory(projectName).catch(() => null);
  1313. if (!projectRoot) {
  1314. return res.status(404).json({ error: 'Project not found' });
  1315. }
  1316. // Validate old path
  1317. const oldValidation = validatePathInProject(projectRoot, oldPath);
  1318. if (!oldValidation.valid) {
  1319. return res.status(403).json({ error: oldValidation.error });
  1320. }
  1321. const resolvedOldPath = oldValidation.resolved;
  1322. // Check if old path exists
  1323. try {
  1324. await fsPromises.access(resolvedOldPath);
  1325. } catch {
  1326. return res.status(404).json({ error: 'File or directory not found' });
  1327. }
  1328. // Build and validate new path
  1329. const parentDir = path.dirname(resolvedOldPath);
  1330. const resolvedNewPath = path.join(parentDir, newName);
  1331. const newValidation = validatePathInProject(projectRoot, resolvedNewPath);
  1332. if (!newValidation.valid) {
  1333. return res.status(403).json({ error: newValidation.error });
  1334. }
  1335. // Check if new path already exists
  1336. try {
  1337. await fsPromises.access(resolvedNewPath);
  1338. return res.status(409).json({ error: 'A file or directory with this name already exists' });
  1339. } catch {
  1340. // Doesn't exist, which is what we want
  1341. }
  1342. // Rename
  1343. await fsPromises.rename(resolvedOldPath, resolvedNewPath);
  1344. res.json({
  1345. success: true,
  1346. oldPath: resolvedOldPath,
  1347. newPath: resolvedNewPath,
  1348. newName,
  1349. message: 'Renamed successfully'
  1350. });
  1351. } catch (error) {
  1352. console.error('Error renaming file/directory:', error);
  1353. if (error.code === 'EACCES') {
  1354. res.status(403).json({ error: 'Permission denied' });
  1355. } else if (error.code === 'ENOENT') {
  1356. res.status(404).json({ error: 'File or directory not found' });
  1357. } else if (error.code === 'EXDEV') {
  1358. res.status(400).json({ error: 'Cannot move across different filesystems' });
  1359. } else {
  1360. res.status(500).json({ error: error.message });
  1361. }
  1362. }
  1363. });
  1364. // DELETE /api/projects/:projectName/files - Delete file or directory
  1365. app.delete('/api/projects/:projectName/files', authenticateToken, async (req, res) => {
  1366. try {
  1367. const { projectName } = req.params;
  1368. const { path: targetPath, type } = req.body;
  1369. // Validate input
  1370. if (!targetPath) {
  1371. return res.status(400).json({ error: 'Path is required' });
  1372. }
  1373. // Get project root
  1374. const projectRoot = await extractProjectDirectory(projectName).catch(() => null);
  1375. if (!projectRoot) {
  1376. return res.status(404).json({ error: 'Project not found' });
  1377. }
  1378. // Validate path
  1379. const validation = validatePathInProject(projectRoot, targetPath);
  1380. if (!validation.valid) {
  1381. return res.status(403).json({ error: validation.error });
  1382. }
  1383. const resolvedPath = validation.resolved;
  1384. // Check if path exists and get stats
  1385. let stats;
  1386. try {
  1387. stats = await fsPromises.stat(resolvedPath);
  1388. } catch {
  1389. return res.status(404).json({ error: 'File or directory not found' });
  1390. }
  1391. // Prevent deleting the project root itself
  1392. if (resolvedPath === path.resolve(projectRoot)) {
  1393. return res.status(403).json({ error: 'Cannot delete project root directory' });
  1394. }
  1395. // Delete based on type
  1396. if (stats.isDirectory()) {
  1397. await fsPromises.rm(resolvedPath, { recursive: true, force: true });
  1398. } else {
  1399. await fsPromises.unlink(resolvedPath);
  1400. }
  1401. res.json({
  1402. success: true,
  1403. path: resolvedPath,
  1404. type: stats.isDirectory() ? 'directory' : 'file',
  1405. message: 'Deleted successfully'
  1406. });
  1407. } catch (error) {
  1408. console.error('Error deleting file/directory:', error);
  1409. if (error.code === 'EACCES') {
  1410. res.status(403).json({ error: 'Permission denied' });
  1411. } else if (error.code === 'ENOENT') {
  1412. res.status(404).json({ error: 'File or directory not found' });
  1413. } else if (error.code === 'ENOTEMPTY') {
  1414. res.status(400).json({ error: 'Directory is not empty' });
  1415. } else {
  1416. res.status(500).json({ error: error.message });
  1417. }
  1418. }
  1419. });
  1420. // POST /api/projects/:projectName/files/upload - Upload files
  1421. // Dynamic import of multer for file uploads
  1422. const uploadFilesHandler = async (req, res) => {
  1423. // Dynamic import of multer
  1424. const multer = (await import('multer')).default;
  1425. const uploadMiddleware = multer({
  1426. storage: multer.diskStorage({
  1427. destination: (req, file, cb) => {
  1428. cb(null, os.tmpdir());
  1429. },
  1430. filename: (req, file, cb) => {
  1431. // Use a unique temp name, but preserve original name in file.originalname
  1432. // Note: file.originalname may contain path separators for folder uploads
  1433. const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
  1434. // For temp file, just use a safe unique name without the path
  1435. cb(null, `upload-${uniqueSuffix}`);
  1436. }
  1437. }),
  1438. limits: {
  1439. fileSize: 50 * 1024 * 1024, // 50MB limit
  1440. files: 20 // Max 20 files at once
  1441. }
  1442. });
  1443. // Use multer middleware
  1444. uploadMiddleware.array('files', 20)(req, res, async (err) => {
  1445. if (err) {
  1446. console.error('Multer error:', err);
  1447. if (err.code === 'LIMIT_FILE_SIZE') {
  1448. return res.status(400).json({ error: 'File too large. Maximum size is 50MB.' });
  1449. }
  1450. if (err.code === 'LIMIT_FILE_COUNT') {
  1451. return res.status(400).json({ error: 'Too many files. Maximum is 20 files.' });
  1452. }
  1453. return res.status(500).json({ error: err.message });
  1454. }
  1455. try {
  1456. const { projectName } = req.params;
  1457. const { targetPath, relativePaths } = req.body;
  1458. // Parse relative paths if provided (for folder uploads)
  1459. let filePaths = [];
  1460. if (relativePaths) {
  1461. try {
  1462. filePaths = JSON.parse(relativePaths);
  1463. } catch (e) {
  1464. console.log('[DEBUG] Failed to parse relativePaths:', relativePaths);
  1465. }
  1466. }
  1467. console.log('[DEBUG] File upload request:', {
  1468. projectName,
  1469. targetPath: JSON.stringify(targetPath),
  1470. targetPathType: typeof targetPath,
  1471. filesCount: req.files?.length,
  1472. relativePaths: filePaths
  1473. });
  1474. if (!req.files || req.files.length === 0) {
  1475. return res.status(400).json({ error: 'No files provided' });
  1476. }
  1477. // Get project root
  1478. const projectRoot = await extractProjectDirectory(projectName).catch(() => null);
  1479. if (!projectRoot) {
  1480. return res.status(404).json({ error: 'Project not found' });
  1481. }
  1482. console.log('[DEBUG] Project root:', projectRoot);
  1483. // Validate and resolve target path
  1484. // If targetPath is empty or '.', use project root directly
  1485. const targetDir = targetPath || '';
  1486. let resolvedTargetDir;
  1487. console.log('[DEBUG] Target dir:', JSON.stringify(targetDir));
  1488. if (!targetDir || targetDir === '.' || targetDir === './') {
  1489. // Empty path means upload to project root
  1490. resolvedTargetDir = path.resolve(projectRoot);
  1491. console.log('[DEBUG] Using project root as target:', resolvedTargetDir);
  1492. } else {
  1493. const validation = validatePathInProject(projectRoot, targetDir);
  1494. if (!validation.valid) {
  1495. console.log('[DEBUG] Path validation failed:', validation.error);
  1496. return res.status(403).json({ error: validation.error });
  1497. }
  1498. resolvedTargetDir = validation.resolved;
  1499. console.log('[DEBUG] Resolved target dir:', resolvedTargetDir);
  1500. }
  1501. // Ensure target directory exists
  1502. try {
  1503. await fsPromises.access(resolvedTargetDir);
  1504. } catch {
  1505. await fsPromises.mkdir(resolvedTargetDir, { recursive: true });
  1506. }
  1507. // Move uploaded files from temp to target directory
  1508. const uploadedFiles = [];
  1509. console.log('[DEBUG] Processing files:', req.files.map(f => ({ originalname: f.originalname, path: f.path })));
  1510. for (let i = 0; i < req.files.length; i++) {
  1511. const file = req.files[i];
  1512. // Use relative path if provided (for folder uploads), otherwise use originalname
  1513. const fileName = (filePaths && filePaths[i]) ? filePaths[i] : file.originalname;
  1514. console.log('[DEBUG] Processing file:', fileName, '(originalname:', file.originalname + ')');
  1515. const destPath = path.join(resolvedTargetDir, fileName);
  1516. // Validate destination path
  1517. const destValidation = validatePathInProject(projectRoot, destPath);
  1518. if (!destValidation.valid) {
  1519. console.log('[DEBUG] Destination validation failed for:', destPath);
  1520. // Clean up temp file
  1521. await fsPromises.unlink(file.path).catch(() => {});
  1522. continue;
  1523. }
  1524. // Ensure parent directory exists (for nested files from folder upload)
  1525. const parentDir = path.dirname(destPath);
  1526. try {
  1527. await fsPromises.access(parentDir);
  1528. } catch {
  1529. await fsPromises.mkdir(parentDir, { recursive: true });
  1530. }
  1531. // Move file (copy + unlink to handle cross-device scenarios)
  1532. await fsPromises.copyFile(file.path, destPath);
  1533. await fsPromises.unlink(file.path);
  1534. uploadedFiles.push({
  1535. name: fileName,
  1536. path: destPath,
  1537. size: file.size,
  1538. mimeType: file.mimetype
  1539. });
  1540. }
  1541. res.json({
  1542. success: true,
  1543. files: uploadedFiles,
  1544. targetPath: resolvedTargetDir,
  1545. message: `Uploaded ${uploadedFiles.length} file(s) successfully`
  1546. });
  1547. } catch (error) {
  1548. console.error('Error uploading files:', error);
  1549. // Clean up any remaining temp files
  1550. if (req.files) {
  1551. for (const file of req.files) {
  1552. await fsPromises.unlink(file.path).catch(() => {});
  1553. }
  1554. }
  1555. if (error.code === 'EACCES') {
  1556. res.status(403).json({ error: 'Permission denied' });
  1557. } else {
  1558. res.status(500).json({ error: error.message });
  1559. }
  1560. }
  1561. });
  1562. };
  1563. app.post('/api/projects/:projectName/files/upload', authenticateToken, uploadFilesHandler);
  1564. /**
  1565. * Proxy an authenticated client WebSocket to a plugin's internal WS server.
  1566. * Auth is enforced by verifyClient before this function is reached.
  1567. */
  1568. function handlePluginWsProxy(clientWs, pathname) {
  1569. const pluginName = pathname.replace('/plugin-ws/', '');
  1570. if (!pluginName || /[^a-zA-Z0-9_-]/.test(pluginName)) {
  1571. clientWs.close(4400, 'Invalid plugin name');
  1572. return;
  1573. }
  1574. const port = getPluginPort(pluginName);
  1575. if (!port) {
  1576. clientWs.close(4404, 'Plugin not running');
  1577. return;
  1578. }
  1579. const upstream = new WebSocket(`ws://127.0.0.1:${port}/ws`);
  1580. upstream.on('open', () => {
  1581. console.log(`[Plugins] WS proxy connected to "${pluginName}" on port ${port}`);
  1582. });
  1583. // Relay messages bidirectionally
  1584. upstream.on('message', (data) => {
  1585. if (clientWs.readyState === WebSocket.OPEN) clientWs.send(data);
  1586. });
  1587. clientWs.on('message', (data) => {
  1588. if (upstream.readyState === WebSocket.OPEN) upstream.send(data);
  1589. });
  1590. // Propagate close in both directions
  1591. upstream.on('close', () => { if (clientWs.readyState === WebSocket.OPEN) clientWs.close(); });
  1592. clientWs.on('close', () => { if (upstream.readyState === WebSocket.OPEN) upstream.close(); });
  1593. upstream.on('error', (err) => {
  1594. console.error(`[Plugins] WS proxy error for "${pluginName}":`, err.message);
  1595. if (clientWs.readyState === WebSocket.OPEN) clientWs.close(4502, 'Upstream error');
  1596. });
  1597. clientWs.on('error', () => {
  1598. if (upstream.readyState === WebSocket.OPEN) upstream.close();
  1599. });
  1600. }
  1601. // WebSocket connection handler that routes based on URL path
  1602. wss.on('connection', (ws, request) => {
  1603. const url = request.url;
  1604. console.log('[INFO] Client connected to:', url);
  1605. // Parse URL to get pathname without query parameters
  1606. const urlObj = new URL(url, 'http://localhost');
  1607. const pathname = urlObj.pathname;
  1608. if (pathname === '/shell') {
  1609. handleShellConnection(ws);
  1610. } else if (pathname === '/ws') {
  1611. handleChatConnection(ws, request);
  1612. } else if (pathname.startsWith('/plugin-ws/')) {
  1613. handlePluginWsProxy(ws, pathname);
  1614. } else {
  1615. console.log('[WARN] Unknown WebSocket path:', pathname);
  1616. ws.close();
  1617. }
  1618. });
  1619. /**
  1620. * WebSocket Writer - Wrapper for WebSocket to match SSEStreamWriter interface
  1621. *
  1622. * Provider files use `createNormalizedMessage()` from `providers/types.js` and
  1623. * adapter `normalizeMessage()` to produce unified NormalizedMessage events.
  1624. * The writer simply serialises and sends.
  1625. */
  1626. class WebSocketWriter {
  1627. constructor(ws, userId = null) {
  1628. this.ws = ws;
  1629. this.sessionId = null;
  1630. this.userId = userId;
  1631. this.isWebSocketWriter = true; // Marker for transport detection
  1632. }
  1633. send(data) {
  1634. const message = JSON.stringify(data);
  1635. if (this.ws.readyState === 1) { // WebSocket.OPEN
  1636. this.ws.send(message);
  1637. return;
  1638. }
  1639. // A chat turn can outlive the browser WebSocket that submitted it
  1640. // (refresh, reconnect, dev-client hiccup). Keep the gateway stream live
  1641. // by handing subsequent frames to the user's replacement connection.
  1642. connectedClients.forEach((client) => {
  1643. if (client.readyState !== 1) return; // WebSocket.OPEN
  1644. if (client.__pilotdeckUserId !== this.userId) return;
  1645. client.send(message);
  1646. });
  1647. }
  1648. updateWebSocket(newRawWs) {
  1649. this.ws = newRawWs;
  1650. }
  1651. setSessionId(sessionId) {
  1652. this.sessionId = sessionId;
  1653. }
  1654. getSessionId() {
  1655. return this.sessionId;
  1656. }
  1657. }
  1658. // Handle chat WebSocket connections
  1659. function handleChatConnection(ws, request) {
  1660. console.log('[INFO] Chat WebSocket connected');
  1661. // Add to connected clients for project updates
  1662. const userId = request?.user?.id ?? request?.user?.userId ?? null;
  1663. ws.__pilotdeckUserId = userId;
  1664. connectedClients.add(ws);
  1665. // PilotDeck's cron runtime lives inside `pilotdeck server`
  1666. // (src/cron via createCronRuntime); no legacy daemon lease needed.
  1667. let cleanedUp = false;
  1668. // Wrap WebSocket with writer for consistent interface with SSEStreamWriter
  1669. const writer = new WebSocketWriter(ws, userId);
  1670. ws.on('message', async (message) => {
  1671. try {
  1672. const data = JSON.parse(message);
  1673. if (data.type === 'always-on-presence') {
  1674. await alwaysOnHeartbeat.handlePresence(ws, data);
  1675. } else if (data.type === 'always-on-presence-clear') {
  1676. await alwaysOnHeartbeat.clearPresence(ws);
  1677. } else if (
  1678. data.type === 'pilotdeck-command' ||
  1679. // Deprecated: legacy per-provider frame types kept for back-compat.
  1680. data.type === 'claude-command' ||
  1681. data.type === 'cursor-command' ||
  1682. data.type === 'codex-command' ||
  1683. data.type === 'gemini-command'
  1684. ) {
  1685. console.log('[DEBUG] User message:', data.command || '[Continue/Resume]');
  1686. console.log('📁 Project:', data.options?.projectPath || data.options?.cwd || 'Unknown');
  1687. console.log('🔄 Session:', data.options?.sessionId ? 'Resume' : 'New');
  1688. const providerHint = data.options?.providerHint || data.type.replace('-command', '');
  1689. await runChatViaGateway(data.command, data.options, writer, providerHint);
  1690. } else if (data.type === 'abort-session') {
  1691. console.log('[DEBUG] Abort session request:', data.sessionId);
  1692. const provider = data.provider || 'pilotdeck';
  1693. const success = await abortViaGateway(data.sessionId, provider);
  1694. writer.send(createNormalizedMessage({ kind: 'complete', exitCode: success ? 0 : 1, aborted: true, success, sessionId: data.sessionId, provider }));
  1695. } else if (
  1696. data.type === 'claude-permission-response' ||
  1697. data.type === 'permission-response'
  1698. ) {
  1699. if (data.requestId) {
  1700. await decidePermissionViaGateway(
  1701. data.requestId,
  1702. data.allow ? 'allow' : 'deny',
  1703. {
  1704. remember: Boolean(data.rememberEntry),
  1705. reason: data.message,
  1706. },
  1707. );
  1708. }
  1709. } else if (data.type === 'session-permission-grant') {
  1710. await grantSessionPermissionViaGateway(data.sessionId, data.entry);
  1711. } else if (data.type === 'elicitation-response') {
  1712. if (data.requestId) {
  1713. await elicitationRespondViaGateway(data.requestId, data.answer);
  1714. }
  1715. } else if (data.type === 'check-session-status') {
  1716. const sessionId = data.sessionId;
  1717. const isProcessing = isSessionActiveViaGateway(sessionId);
  1718. const activeTurnMessages = isProcessing
  1719. ? await getActiveTurnSnapshotFramesViaGateway(sessionId, data.provider || 'pilotdeck')
  1720. : [];
  1721. writer.send({
  1722. type: 'session-status',
  1723. sessionId,
  1724. provider: data.provider || 'pilotdeck',
  1725. isProcessing,
  1726. activeTurnMessages,
  1727. tokenBudget: getSessionTokenBudget(sessionId),
  1728. });
  1729. } else if (data.type === 'get-pending-permissions') {
  1730. // Pending-permission introspection is gateway-internal. The
  1731. // permission_request event already contains everything the
  1732. // UI needs, so the response is now an empty stub.
  1733. writer.send({
  1734. type: 'pending-permissions-response',
  1735. sessionId: data.sessionId,
  1736. data: [],
  1737. });
  1738. } else if (data.type === 'get-active-sessions') {
  1739. const ids = getActiveSessionIdsViaGateway();
  1740. // Keep the four-provider keys so the legacy UI store does
  1741. // not need to change shape; everything routes through
  1742. // PilotDeck under the hood.
  1743. writer.send({
  1744. type: 'active-sessions',
  1745. sessions: { claude: ids, cursor: [], codex: [], gemini: [], pilotdeck: ids },
  1746. });
  1747. }
  1748. } catch (error) {
  1749. console.error('[ERROR] Chat WebSocket error:', error.message);
  1750. writer.send({
  1751. type: 'error',
  1752. error: error.message
  1753. });
  1754. }
  1755. });
  1756. const cleanup = () => {
  1757. if (cleanedUp) return;
  1758. cleanedUp = true;
  1759. // Remove from connected clients
  1760. connectedClients.delete(ws);
  1761. void alwaysOnHeartbeat.clearPresence(ws);
  1762. };
  1763. ws.on('close', (code, reason) => {
  1764. const reasonText = reason?.toString?.() || '';
  1765. console.log(`🔌 Chat client disconnected code=${code}${reasonText ? ` reason=${reasonText}` : ''}`);
  1766. cleanup();
  1767. });
  1768. ws.on('error', () => {
  1769. cleanup();
  1770. });
  1771. }
  1772. // Handle shell WebSocket connections
  1773. function handleShellConnection(ws) {
  1774. console.log('🐚 Shell client connected');
  1775. let shellProcess = null;
  1776. let ptySessionKey = null;
  1777. let urlDetectionBuffer = '';
  1778. const announcedAuthUrls = new Set();
  1779. ws.on('message', async (message) => {
  1780. try {
  1781. const data = JSON.parse(message);
  1782. console.log('📨 Shell message received:', data.type);
  1783. if (data.type === 'init') {
  1784. const projectPath = data.projectPath || process.cwd();
  1785. const sessionId = data.sessionId;
  1786. const hasSession = data.hasSession;
  1787. const provider = data.provider || 'pilotdeck';
  1788. const initialCommand = data.initialCommand;
  1789. const isPlainShell = data.isPlainShell || (!!initialCommand && !hasSession) || provider === 'plain-shell';
  1790. urlDetectionBuffer = '';
  1791. announcedAuthUrls.clear();
  1792. const isLoginCommand = initialCommand && (
  1793. initialCommand.includes('setup-token') ||
  1794. initialCommand.includes('cursor-agent login') ||
  1795. initialCommand.includes('auth login')
  1796. );
  1797. // Include command hash in session key so different commands get separate sessions
  1798. const commandSuffix = isPlainShell && initialCommand
  1799. ? `_cmd_${Buffer.from(initialCommand).toString('base64').slice(0, 16)}`
  1800. : '';
  1801. ptySessionKey = `${projectPath}_${sessionId || 'default'}${commandSuffix}`;
  1802. // Kill any existing login session before starting fresh
  1803. if (isLoginCommand) {
  1804. const oldSession = ptySessionsMap.get(ptySessionKey);
  1805. if (oldSession) {
  1806. console.log('🧹 Cleaning up existing login session:', ptySessionKey);
  1807. if (oldSession.timeoutId) clearTimeout(oldSession.timeoutId);
  1808. if (oldSession.pty && oldSession.pty.kill) oldSession.pty.kill();
  1809. ptySessionsMap.delete(ptySessionKey);
  1810. }
  1811. }
  1812. const existingSession = isLoginCommand ? null : ptySessionsMap.get(ptySessionKey);
  1813. if (existingSession) {
  1814. console.log('♻️ Reconnecting to existing PTY session:', ptySessionKey);
  1815. shellProcess = existingSession.pty;
  1816. clearTimeout(existingSession.timeoutId);
  1817. ws.send(JSON.stringify({
  1818. type: 'output',
  1819. data: `\x1b[36m[Reconnected to existing session]\x1b[0m\r\n`
  1820. }));
  1821. if (existingSession.buffer && existingSession.buffer.length > 0) {
  1822. console.log(`📜 Sending ${existingSession.buffer.length} buffered messages`);
  1823. existingSession.buffer.forEach(bufferedData => {
  1824. ws.send(JSON.stringify({
  1825. type: 'output',
  1826. data: bufferedData
  1827. }));
  1828. });
  1829. }
  1830. existingSession.ws = ws;
  1831. return;
  1832. }
  1833. console.log('[INFO] Starting shell in:', projectPath);
  1834. console.log('📋 Session info:', hasSession ? `Resume session ${sessionId}` : (isPlainShell ? 'Plain shell mode' : 'New session'));
  1835. console.log('🤖 Provider:', isPlainShell ? 'plain-shell' : provider);
  1836. if (initialCommand) {
  1837. console.log('⚡ Initial command:', initialCommand);
  1838. }
  1839. // First send a welcome message
  1840. let welcomeMsg;
  1841. if (isPlainShell) {
  1842. welcomeMsg = `\x1b[36mStarting terminal in: ${projectPath}\x1b[0m\r\n`;
  1843. } else {
  1844. const providerName = provider === 'pilotdeck' ? 'PilotDeck' : (provider === 'cursor' ? 'Cursor' : (provider === 'codex' ? 'Codex' : (provider === 'gemini' ? 'Gemini' : 'Claude')));
  1845. welcomeMsg = hasSession ?
  1846. `\x1b[36mResuming ${providerName} session ${sessionId} in: ${projectPath}\x1b[0m\r\n` :
  1847. `\x1b[36mStarting new ${providerName} session in: ${projectPath}\x1b[0m\r\n`;
  1848. }
  1849. ws.send(JSON.stringify({
  1850. type: 'output',
  1851. data: welcomeMsg
  1852. }));
  1853. try {
  1854. // Validate projectPath — resolve to absolute and verify it exists
  1855. const resolvedProjectPath = path.resolve(projectPath);
  1856. try {
  1857. const stats = fs.statSync(resolvedProjectPath);
  1858. if (!stats.isDirectory()) {
  1859. throw new Error('Not a directory');
  1860. }
  1861. } catch (pathErr) {
  1862. ws.send(JSON.stringify({ type: 'error', message: 'Invalid project path' }));
  1863. return;
  1864. }
  1865. // Validate sessionId — only allow safe characters
  1866. const safeSessionIdPattern = /^[a-zA-Z0-9_.\-:]+$/;
  1867. if (sessionId && !safeSessionIdPattern.test(sessionId)) {
  1868. ws.send(JSON.stringify({ type: 'error', message: 'Invalid session ID' }));
  1869. return;
  1870. }
  1871. // Build shell command — use cwd for project path (never interpolate into shell string)
  1872. let shellCommand;
  1873. if (isPlainShell) {
  1874. // Plain shell mode - run the initial command in the project directory
  1875. shellCommand = initialCommand;
  1876. } else if (provider === 'cursor') {
  1877. if (hasSession && sessionId) {
  1878. shellCommand = `cursor-agent --resume="${sessionId}"`;
  1879. } else {
  1880. shellCommand = 'cursor-agent';
  1881. }
  1882. } else if (provider === 'codex') {
  1883. // Use codex command; attempt to resume and fall back to a new session when the resume fails.
  1884. if (hasSession && sessionId) {
  1885. if (os.platform() === 'win32') {
  1886. // PowerShell syntax for fallback
  1887. shellCommand = `codex resume "${sessionId}"; if ($LASTEXITCODE -ne 0) { codex }`;
  1888. } else {
  1889. shellCommand = `codex resume "${sessionId}" || codex`;
  1890. }
  1891. } else {
  1892. shellCommand = 'codex';
  1893. }
  1894. } else if (provider === 'gemini') {
  1895. const command = initialCommand || 'gemini';
  1896. let resumeId = sessionId;
  1897. if (hasSession && sessionId) {
  1898. try {
  1899. // Gemini CLI enforces its own native session IDs, unlike other agents that accept arbitrary string names.
  1900. // The UI only knows about its internal generated `sessionId` (e.g. gemini_1234).
  1901. // We must fetch the mapping from the backend session manager to pass the native `cliSessionId` to the shell.
  1902. const sess = sessionManager.getSession(sessionId);
  1903. if (sess && sess.cliSessionId) {
  1904. resumeId = sess.cliSessionId;
  1905. // Validate the looked-up CLI session ID too
  1906. if (!safeSessionIdPattern.test(resumeId)) {
  1907. resumeId = null;
  1908. }
  1909. }
  1910. } catch (err) {
  1911. console.error('Failed to get Gemini CLI session ID:', err);
  1912. }
  1913. }
  1914. if (hasSession && resumeId) {
  1915. shellCommand = `${command} --resume "${resumeId}"`;
  1916. } else {
  1917. shellCommand = command;
  1918. }
  1919. } else if (provider === 'pilotdeck') {
  1920. const command = initialCommand || 'pilotdeck';
  1921. if (hasSession && sessionId) {
  1922. if (os.platform() === 'win32') {
  1923. shellCommand = `pilotdeck --resume "${sessionId}"; if ($LASTEXITCODE -ne 0) { pilotdeck }`;
  1924. } else {
  1925. shellCommand = `pilotdeck --resume "${sessionId}" || pilotdeck`;
  1926. }
  1927. } else {
  1928. shellCommand = command;
  1929. }
  1930. } else {
  1931. const command = initialCommand || 'claude';
  1932. if (hasSession && sessionId) {
  1933. if (os.platform() === 'win32') {
  1934. shellCommand = `claude --resume "${sessionId}"; if ($LASTEXITCODE -ne 0) { claude }`;
  1935. } else {
  1936. shellCommand = `claude --resume "${sessionId}" || claude`;
  1937. }
  1938. } else {
  1939. shellCommand = command;
  1940. }
  1941. }
  1942. console.log('🔧 Executing shell command:', shellCommand);
  1943. // Use appropriate shell based on platform
  1944. const shell = os.platform() === 'win32' ? 'powershell.exe' : 'bash';
  1945. const shellArgs = os.platform() === 'win32' ? ['-Command', shellCommand] : ['-c', shellCommand];
  1946. // Use terminal dimensions from client if provided, otherwise use defaults
  1947. const termCols = data.cols || 80;
  1948. const termRows = data.rows || 24;
  1949. console.log('📐 Using terminal dimensions:', termCols, 'x', termRows);
  1950. shellProcess = pty.spawn(shell, shellArgs, {
  1951. name: 'xterm-256color',
  1952. cols: termCols,
  1953. rows: termRows,
  1954. cwd: resolvedProjectPath,
  1955. env: {
  1956. ...process.env,
  1957. TERM: 'xterm-256color',
  1958. COLORTERM: 'truecolor',
  1959. FORCE_COLOR: '3'
  1960. }
  1961. });
  1962. console.log('🟢 Shell process started with PTY, PID:', shellProcess.pid);
  1963. ptySessionsMap.set(ptySessionKey, {
  1964. pty: shellProcess,
  1965. ws: ws,
  1966. buffer: [],
  1967. timeoutId: null,
  1968. projectPath,
  1969. sessionId
  1970. });
  1971. // Handle data output
  1972. shellProcess.onData((data) => {
  1973. const session = ptySessionsMap.get(ptySessionKey);
  1974. if (!session) return;
  1975. if (session.buffer.length < 5000) {
  1976. session.buffer.push(data);
  1977. } else {
  1978. session.buffer.shift();
  1979. session.buffer.push(data);
  1980. }
  1981. if (session.ws && session.ws.readyState === WebSocket.OPEN) {
  1982. let outputData = data;
  1983. const cleanChunk = stripAnsiSequences(data);
  1984. urlDetectionBuffer = `${urlDetectionBuffer}${cleanChunk}`.slice(-SHELL_URL_PARSE_BUFFER_LIMIT);
  1985. outputData = outputData.replace(
  1986. /OPEN_URL:\s*(https?:\/\/[^\s\x1b\x07]+)/g,
  1987. '[INFO] Opening in browser: $1'
  1988. );
  1989. const emitAuthUrl = (detectedUrl, autoOpen = false) => {
  1990. const normalizedUrl = normalizeDetectedUrl(detectedUrl);
  1991. if (!normalizedUrl) return;
  1992. const isNewUrl = !announcedAuthUrls.has(normalizedUrl);
  1993. if (isNewUrl) {
  1994. announcedAuthUrls.add(normalizedUrl);
  1995. session.ws.send(JSON.stringify({
  1996. type: 'auth_url',
  1997. url: normalizedUrl,
  1998. autoOpen
  1999. }));
  2000. }
  2001. };
  2002. const normalizedDetectedUrls = extractUrlsFromText(urlDetectionBuffer)
  2003. .map((url) => normalizeDetectedUrl(url))
  2004. .filter(Boolean);
  2005. // Prefer the most complete URL if shorter prefix variants are also present.
  2006. const dedupedDetectedUrls = Array.from(new Set(normalizedDetectedUrls)).filter((url, _, urls) =>
  2007. !urls.some((otherUrl) => otherUrl !== url && otherUrl.startsWith(url))
  2008. );
  2009. dedupedDetectedUrls.forEach((url) => emitAuthUrl(url, false));
  2010. if (shouldAutoOpenUrlFromOutput(cleanChunk) && dedupedDetectedUrls.length > 0) {
  2011. const bestUrl = dedupedDetectedUrls.reduce((longest, current) =>
  2012. current.length > longest.length ? current : longest
  2013. );
  2014. emitAuthUrl(bestUrl, true);
  2015. }
  2016. // Send regular output
  2017. session.ws.send(JSON.stringify({
  2018. type: 'output',
  2019. data: outputData
  2020. }));
  2021. }
  2022. });
  2023. // Handle process exit
  2024. shellProcess.onExit((exitCode) => {
  2025. console.log('🔚 Shell process exited with code:', exitCode.exitCode, 'signal:', exitCode.signal);
  2026. const session = ptySessionsMap.get(ptySessionKey);
  2027. if (session && session.ws && session.ws.readyState === WebSocket.OPEN) {
  2028. session.ws.send(JSON.stringify({
  2029. type: 'output',
  2030. data: `\r\n\x1b[33mProcess exited with code ${exitCode.exitCode}${exitCode.signal ? ` (${exitCode.signal})` : ''}\x1b[0m\r\n`
  2031. }));
  2032. }
  2033. if (session && session.timeoutId) {
  2034. clearTimeout(session.timeoutId);
  2035. }
  2036. ptySessionsMap.delete(ptySessionKey);
  2037. shellProcess = null;
  2038. });
  2039. } catch (spawnError) {
  2040. console.error('[ERROR] Error spawning process:', spawnError);
  2041. ws.send(JSON.stringify({
  2042. type: 'output',
  2043. data: `\r\n\x1b[31mError: ${spawnError.message}\x1b[0m\r\n`
  2044. }));
  2045. }
  2046. } else if (data.type === 'input') {
  2047. // Send input to shell process
  2048. if (shellProcess && shellProcess.write) {
  2049. try {
  2050. shellProcess.write(data.data);
  2051. } catch (error) {
  2052. console.error('Error writing to shell:', error);
  2053. }
  2054. } else {
  2055. console.warn('No active shell process to send input to');
  2056. }
  2057. } else if (data.type === 'resize') {
  2058. // Handle terminal resize
  2059. if (shellProcess && shellProcess.resize) {
  2060. console.log('Terminal resize requested:', data.cols, 'x', data.rows);
  2061. shellProcess.resize(data.cols, data.rows);
  2062. }
  2063. }
  2064. } catch (error) {
  2065. console.error('[ERROR] Shell WebSocket error:', error.message);
  2066. if (ws.readyState === WebSocket.OPEN) {
  2067. ws.send(JSON.stringify({
  2068. type: 'output',
  2069. data: `\r\n\x1b[31mError: ${error.message}\x1b[0m\r\n`
  2070. }));
  2071. }
  2072. }
  2073. });
  2074. ws.on('close', () => {
  2075. console.log('🔌 Shell client disconnected');
  2076. if (ptySessionKey) {
  2077. const session = ptySessionsMap.get(ptySessionKey);
  2078. if (session) {
  2079. console.log('⏳ PTY session kept alive, will timeout in 30 minutes:', ptySessionKey);
  2080. session.ws = null;
  2081. session.timeoutId = setTimeout(() => {
  2082. console.log('⏰ PTY session timeout, killing process:', ptySessionKey);
  2083. if (session.pty && session.pty.kill) {
  2084. session.pty.kill();
  2085. }
  2086. ptySessionsMap.delete(ptySessionKey);
  2087. }, PTY_SESSION_TIMEOUT);
  2088. }
  2089. }
  2090. });
  2091. ws.on('error', (error) => {
  2092. console.error('[ERROR] Shell WebSocket error:', error);
  2093. });
  2094. }
  2095. const CHAT_ATTACHMENT_IMAGE_MIMES = new Set([
  2096. 'image/jpeg',
  2097. 'image/png',
  2098. 'image/gif',
  2099. 'image/webp',
  2100. 'image/svg+xml',
  2101. ]);
  2102. function sanitizeAttachmentFilename(name, fallback = 'attachment') {
  2103. const baseName = path.basename(String(name || fallback));
  2104. const sanitized = baseName
  2105. .replace(/[<>:"/\\|?*\x00-\x1f]/g, '_')
  2106. .replace(/^\.+$/, fallback)
  2107. .slice(0, 180)
  2108. .trim();
  2109. return sanitized || fallback;
  2110. }
  2111. function normalizeUploadedFilename(name, fallback = 'attachment') {
  2112. const original = String(name || fallback);
  2113. try {
  2114. const decoded = Buffer.from(original, 'latin1').toString('utf8');
  2115. const looksMojibake = /[ÃÂÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞßàáâãäåæçèéêëìíîïðñòóôõöøùûüýþÿ]/.test(original);
  2116. if (looksMojibake && decoded && !decoded.includes('�')) {
  2117. return decoded;
  2118. }
  2119. } catch {
  2120. // Keep the browser-provided name when transcoding is not applicable.
  2121. }
  2122. return original;
  2123. }
  2124. async function moveUploadedAttachment(file, attachmentDir, index) {
  2125. const originalName = normalizeUploadedFilename(file.originalname, `attachment-${index + 1}`);
  2126. file.originalname = originalName;
  2127. const safeName = sanitizeAttachmentFilename(originalName, `attachment-${index + 1}`);
  2128. const ext = path.extname(safeName);
  2129. const stem = ext ? safeName.slice(0, -ext.length) : safeName;
  2130. let candidate = `${index + 1}-${safeName}`;
  2131. let destination = path.join(attachmentDir, candidate);
  2132. let suffix = 1;
  2133. while (true) {
  2134. try {
  2135. await fsPromises.access(destination);
  2136. candidate = `${index + 1}-${stem}-${suffix}${ext}`;
  2137. destination = path.join(attachmentDir, candidate);
  2138. suffix += 1;
  2139. } catch {
  2140. break;
  2141. }
  2142. }
  2143. await fsPromises.copyFile(file.path, destination);
  2144. await fsPromises.unlink(file.path);
  2145. return {
  2146. name: originalName,
  2147. path: destination,
  2148. size: file.size,
  2149. mimeType: file.mimetype || mime.lookup(originalName) || 'application/octet-stream',
  2150. };
  2151. }
  2152. // Mixed chat attachment upload endpoint. Images are returned as data URLs for
  2153. // multimodal input and previews; other files are staged under the project so
  2154. // the gateway can resolve them by path.
  2155. app.post('/api/projects/:projectName/upload-attachments', authenticateToken, async (req, res) => {
  2156. let multerUpload;
  2157. try {
  2158. const multer = (await import('multer')).default;
  2159. const uploadRoot = path.join(os.tmpdir(), 'pilotdeck-chat-attachments', String(req.user.id));
  2160. const storage = multer.diskStorage({
  2161. destination: async (_req, _file, cb) => {
  2162. try {
  2163. await fsPromises.mkdir(uploadRoot, { recursive: true });
  2164. cb(null, uploadRoot);
  2165. } catch (error) {
  2166. cb(error);
  2167. }
  2168. },
  2169. filename: (_req, file, cb) => {
  2170. const uniqueSuffix = `${Date.now()}-${Math.round(Math.random() * 1E9)}`;
  2171. file.originalname = normalizeUploadedFilename(file.originalname);
  2172. cb(null, `${uniqueSuffix}-${sanitizeAttachmentFilename(file.originalname)}`);
  2173. },
  2174. });
  2175. multerUpload = multer({
  2176. storage,
  2177. limits: {
  2178. fileSize: 20 * 1024 * 1024,
  2179. files: 10,
  2180. },
  2181. }).array('attachments', 10);
  2182. } catch (error) {
  2183. console.error('Error configuring attachment upload:', error);
  2184. return res.status(500).json({ error: 'Internal server error' });
  2185. }
  2186. multerUpload(req, res, async (err) => {
  2187. if (err) {
  2188. return res.status(400).json({ error: err.message });
  2189. }
  2190. if (!req.files || req.files.length === 0) {
  2191. return res.status(400).json({ error: 'No attachments provided' });
  2192. }
  2193. let attachmentDir = null;
  2194. try {
  2195. const projectRoot = await extractProjectDirectory(req.params.projectName);
  2196. const targetDir = path.join(projectRoot, '.tmp', 'chat-attachments', `${Date.now()}-${crypto.randomBytes(4).toString('hex')}`);
  2197. const validation = validatePathInProject(projectRoot, targetDir);
  2198. if (!validation.valid) {
  2199. throw new Error(validation.error || 'Invalid attachment target');
  2200. }
  2201. attachmentDir = validation.resolved;
  2202. const images = [];
  2203. const files = [];
  2204. await fsPromises.mkdir(attachmentDir, { recursive: true });
  2205. for (const [index, file] of req.files.entries()) {
  2206. if (CHAT_ATTACHMENT_IMAGE_MIMES.has(file.mimetype)) {
  2207. const originalName = normalizeUploadedFilename(file.originalname);
  2208. const buffer = await fsPromises.readFile(file.path);
  2209. await fsPromises.unlink(file.path).catch(() => { });
  2210. images.push({
  2211. name: originalName,
  2212. data: `data:${file.mimetype};base64,${buffer.toString('base64')}`,
  2213. size: file.size,
  2214. mimeType: file.mimetype,
  2215. });
  2216. continue;
  2217. }
  2218. files.push(await moveUploadedAttachment(file, attachmentDir, index));
  2219. }
  2220. if (files.length === 0 && attachmentDir) {
  2221. await fsPromises.rm(attachmentDir, { recursive: true, force: true }).catch(() => { });
  2222. }
  2223. res.json({ images, files });
  2224. } catch (error) {
  2225. console.error('Error processing attachments:', error);
  2226. await Promise.all((req.files || []).map(file => fsPromises.unlink(file.path).catch(() => { })));
  2227. if (attachmentDir) {
  2228. await fsPromises.rm(attachmentDir, { recursive: true, force: true }).catch(() => { });
  2229. }
  2230. res.status(500).json({ error: 'Failed to process attachments' });
  2231. }
  2232. });
  2233. });
  2234. // Image upload endpoint
  2235. app.post('/api/projects/:projectName/upload-images', authenticateToken, async (req, res) => {
  2236. try {
  2237. const multer = (await import('multer')).default;
  2238. const path = (await import('path')).default;
  2239. const fs = (await import('fs')).promises;
  2240. const os = (await import('os')).default;
  2241. // Configure multer for image uploads
  2242. const storage = multer.diskStorage({
  2243. destination: async (req, file, cb) => {
  2244. const uploadDir = path.join(os.tmpdir(), 'pilotdeck-image-uploads', String(req.user.id));
  2245. await fs.mkdir(uploadDir, { recursive: true });
  2246. cb(null, uploadDir);
  2247. },
  2248. filename: (req, file, cb) => {
  2249. const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
  2250. const sanitizedName = file.originalname.replace(/[^a-zA-Z0-9.-]/g, '_');
  2251. cb(null, uniqueSuffix + '-' + sanitizedName);
  2252. }
  2253. });
  2254. const fileFilter = (req, file, cb) => {
  2255. const allowedMimes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml'];
  2256. if (allowedMimes.includes(file.mimetype)) {
  2257. cb(null, true);
  2258. } else {
  2259. cb(new Error('Invalid file type. Only JPEG, PNG, GIF, WebP, and SVG are allowed.'));
  2260. }
  2261. };
  2262. const upload = multer({
  2263. storage,
  2264. fileFilter,
  2265. limits: {
  2266. fileSize: 5 * 1024 * 1024, // 5MB
  2267. files: 5
  2268. }
  2269. });
  2270. // Handle multipart form data
  2271. upload.array('images', 5)(req, res, async (err) => {
  2272. if (err) {
  2273. return res.status(400).json({ error: err.message });
  2274. }
  2275. if (!req.files || req.files.length === 0) {
  2276. return res.status(400).json({ error: 'No image files provided' });
  2277. }
  2278. try {
  2279. // Process uploaded images
  2280. const processedImages = await Promise.all(
  2281. req.files.map(async (file) => {
  2282. // Read file and convert to base64
  2283. const buffer = await fs.readFile(file.path);
  2284. const base64 = buffer.toString('base64');
  2285. const mimeType = file.mimetype;
  2286. // Clean up temp file immediately
  2287. await fs.unlink(file.path);
  2288. return {
  2289. name: file.originalname,
  2290. data: `data:${mimeType};base64,${base64}`,
  2291. size: file.size,
  2292. mimeType: mimeType
  2293. };
  2294. })
  2295. );
  2296. res.json({ images: processedImages });
  2297. } catch (error) {
  2298. console.error('Error processing images:', error);
  2299. // Clean up any remaining files
  2300. await Promise.all(req.files.map(f => fs.unlink(f.path).catch(() => { })));
  2301. res.status(500).json({ error: 'Failed to process images' });
  2302. }
  2303. });
  2304. } catch (error) {
  2305. console.error('Error in image upload endpoint:', error);
  2306. res.status(500).json({ error: 'Internal server error' });
  2307. }
  2308. });
  2309. // Get token usage for a specific session
  2310. app.get('/api/projects/:projectName/sessions/:sessionId/token-usage', authenticateToken, async (req, res) => {
  2311. try {
  2312. const { projectName, sessionId } = req.params;
  2313. const { provider = 'pilotdeck' } = req.query;
  2314. const homeDir = os.homedir();
  2315. // PilotDeck sessions use `web:s_<uuid>` keys; Windows-safe sessions
  2316. // may use `web-s_<uuid>` because ':' is illegal in Windows filenames.
  2317. if (provider === 'pilotdeck' || /^web[:_-]s_/.test(sessionId)) {
  2318. return res.json(getSessionTokenBudget(sessionId));
  2319. }
  2320. // Allow only safe characters in sessionId
  2321. const safeSessionId = String(sessionId).replace(/[^a-zA-Z0-9._-]/g, '');
  2322. if (!safeSessionId || safeSessionId !== String(sessionId)) {
  2323. return res.status(400).json({ error: 'Invalid sessionId' });
  2324. }
  2325. // Handle Cursor sessions - they use SQLite and don't have token usage info
  2326. if (provider === 'cursor') {
  2327. return res.json({
  2328. used: 0,
  2329. total: 0,
  2330. breakdown: { input: 0, cacheCreation: 0, cacheRead: 0 },
  2331. unsupported: true,
  2332. message: 'Token usage tracking not available for Cursor sessions'
  2333. });
  2334. }
  2335. // Handle Gemini sessions - they are raw logs in our current setup
  2336. if (provider === 'gemini') {
  2337. return res.json({
  2338. used: 0,
  2339. total: 0,
  2340. breakdown: { input: 0, cacheCreation: 0, cacheRead: 0 },
  2341. unsupported: true,
  2342. message: 'Token usage tracking not available for Gemini sessions'
  2343. });
  2344. }
  2345. // Handle Codex sessions
  2346. if (provider === 'codex') {
  2347. const codexSessionsDir = path.join(homeDir, '.codex', 'sessions');
  2348. // Find the session file by searching for the session ID
  2349. const findSessionFile = async (dir) => {
  2350. try {
  2351. const entries = await fsPromises.readdir(dir, { withFileTypes: true });
  2352. for (const entry of entries) {
  2353. const fullPath = path.join(dir, entry.name);
  2354. if (entry.isDirectory()) {
  2355. const found = await findSessionFile(fullPath);
  2356. if (found) return found;
  2357. } else if (entry.name.includes(safeSessionId) && entry.name.endsWith('.jsonl')) {
  2358. return fullPath;
  2359. }
  2360. }
  2361. } catch (error) {
  2362. // Skip directories we can't read
  2363. }
  2364. return null;
  2365. };
  2366. const sessionFilePath = await findSessionFile(codexSessionsDir);
  2367. if (!sessionFilePath) {
  2368. return res.status(404).json({ error: 'Codex session file not found', sessionId: safeSessionId });
  2369. }
  2370. // Read and parse the Codex JSONL file
  2371. let fileContent;
  2372. try {
  2373. fileContent = await fsPromises.readFile(sessionFilePath, 'utf8');
  2374. } catch (error) {
  2375. if (error.code === 'ENOENT') {
  2376. return res.status(404).json({ error: 'Session file not found', path: sessionFilePath });
  2377. }
  2378. throw error;
  2379. }
  2380. const lines = fileContent.trim().split('\n');
  2381. let totalTokens = 0;
  2382. let contextWindow = 200000; // Default for Codex/OpenAI
  2383. // Find the latest token_count event with info (scan from end)
  2384. for (let i = lines.length - 1; i >= 0; i--) {
  2385. try {
  2386. const entry = JSON.parse(lines[i]);
  2387. // Codex stores token info in event_msg with type: "token_count"
  2388. if (entry.type === 'event_msg' && entry.payload?.type === 'token_count' && entry.payload?.info) {
  2389. const tokenInfo = entry.payload.info;
  2390. if (tokenInfo.total_token_usage) {
  2391. totalTokens = tokenInfo.total_token_usage.total_tokens || 0;
  2392. }
  2393. if (tokenInfo.model_context_window) {
  2394. contextWindow = tokenInfo.model_context_window;
  2395. }
  2396. break; // Stop after finding the latest token count
  2397. }
  2398. } catch (parseError) {
  2399. // Skip lines that can't be parsed
  2400. continue;
  2401. }
  2402. }
  2403. return res.json({
  2404. used: totalTokens,
  2405. total: contextWindow
  2406. });
  2407. }
  2408. // Extract actual project path
  2409. let projectPath;
  2410. try {
  2411. projectPath = await extractProjectDirectory(projectName);
  2412. } catch (error) {
  2413. console.error('Error extracting project directory:', error);
  2414. return res.status(500).json({ error: 'Failed to determine project path' });
  2415. }
  2416. const encodedPath = projectPath.replace(/[^a-zA-Z0-9-]/g, '-');
  2417. const projectDir = path.join(homeDir, '.pilotdeck', 'projects', encodedPath);
  2418. const jsonlPath = path.join(projectDir, `${safeSessionId}.jsonl`);
  2419. // Constrain to projectDir
  2420. const rel = path.relative(path.resolve(projectDir), path.resolve(jsonlPath));
  2421. if (rel.startsWith('..') || path.isAbsolute(rel)) {
  2422. return res.status(400).json({ error: 'Invalid path' });
  2423. }
  2424. // Read and parse the JSONL file
  2425. let fileContent;
  2426. try {
  2427. fileContent = await fsPromises.readFile(jsonlPath, 'utf8');
  2428. } catch (error) {
  2429. if (error.code === 'ENOENT') {
  2430. return res.status(404).json({ error: 'Session file not found', path: jsonlPath });
  2431. }
  2432. throw error; // Re-throw other errors to be caught by outer try-catch
  2433. }
  2434. const lines = fileContent.trim().split('\n');
  2435. const parsedContextWindow = parseInt(process.env.CONTEXT_WINDOW, 10);
  2436. const contextWindow = Number.isFinite(parsedContextWindow) ? parsedContextWindow : 160000;
  2437. let inputTokens = 0;
  2438. let cacheCreationTokens = 0;
  2439. let cacheReadTokens = 0;
  2440. // Find the latest assistant message with usage data (scan from end)
  2441. for (let i = lines.length - 1; i >= 0; i--) {
  2442. try {
  2443. const entry = JSON.parse(lines[i]);
  2444. // Only count assistant messages which have usage data
  2445. if (entry.type === 'assistant' && entry.message?.usage) {
  2446. const usage = entry.message.usage;
  2447. // Use token counts from latest assistant message only
  2448. inputTokens = usage.input_tokens || 0;
  2449. cacheCreationTokens = usage.cache_creation_input_tokens || 0;
  2450. cacheReadTokens = usage.cache_read_input_tokens || 0;
  2451. break; // Stop after finding the latest assistant message
  2452. }
  2453. } catch (parseError) {
  2454. // Skip lines that can't be parsed
  2455. continue;
  2456. }
  2457. }
  2458. // Calculate total context usage (excluding output_tokens, as per ccusage)
  2459. const totalUsed = inputTokens + cacheCreationTokens + cacheReadTokens;
  2460. res.json({
  2461. used: totalUsed,
  2462. total: contextWindow,
  2463. breakdown: {
  2464. input: inputTokens,
  2465. cacheCreation: cacheCreationTokens,
  2466. cacheRead: cacheReadTokens
  2467. }
  2468. });
  2469. } catch (error) {
  2470. console.error('Error reading session token usage:', error);
  2471. res.status(500).json({ error: 'Failed to read session token usage' });
  2472. }
  2473. });
  2474. // Serve React app for all other routes (excluding static files)
  2475. app.get('*', (req, res) => {
  2476. // Skip requests for actual static asset extensions only
  2477. const ext = path.extname(req.path);
  2478. if (ext && /^\.(js|css|map|json|ico|png|jpg|jpeg|gif|svg|webp|woff2?|ttf|eot|mp4|webm)$/.test(ext)) {
  2479. return res.status(404).send('Not found');
  2480. }
  2481. // Only serve index.html for HTML routes, not for static assets
  2482. // Static assets should already be handled by express.static middleware above
  2483. const indexPath = path.join(__dirname, '../dist/index.html');
  2484. // Check if dist/index.html exists (production build available)
  2485. if (fs.existsSync(indexPath)) {
  2486. // Set no-cache headers for HTML to prevent service worker issues
  2487. res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
  2488. res.setHeader('Pragma', 'no-cache');
  2489. res.setHeader('Expires', '0');
  2490. res.sendFile(indexPath);
  2491. } else {
  2492. // In development, redirect to Vite dev server only if dist doesn't exist
  2493. const redirectHost = getConnectableHost(req.hostname);
  2494. res.redirect(`${req.protocol}://${redirectHost}:${VITE_PORT}`);
  2495. }
  2496. });
  2497. // Helper function to convert permissions to rwx format
  2498. function permToRwx(perm) {
  2499. const r = perm & 4 ? 'r' : '-';
  2500. const w = perm & 2 ? 'w' : '-';
  2501. const x = perm & 1 ? 'x' : '-';
  2502. return r + w + x;
  2503. }
  2504. async function getFileTree(dirPath, maxDepth = 3, currentDepth = 0, showHidden = true) {
  2505. // Using fsPromises from import
  2506. const items = [];
  2507. try {
  2508. const entries = await fsPromises.readdir(dirPath, { withFileTypes: true });
  2509. for (const entry of entries) {
  2510. // Debug: log all entries including hidden files
  2511. // Skip heavy build directories and VCS directories
  2512. if (entry.name === 'node_modules' ||
  2513. entry.name === 'dist' ||
  2514. entry.name === 'build' ||
  2515. entry.name === '.git' ||
  2516. entry.name === '.svn' ||
  2517. entry.name === '.hg') continue;
  2518. const itemPath = path.join(dirPath, entry.name);
  2519. const item = {
  2520. name: entry.name,
  2521. path: itemPath,
  2522. type: entry.isDirectory() ? 'directory' : 'file'
  2523. };
  2524. // Get file stats for additional metadata
  2525. try {
  2526. const stats = await fsPromises.stat(itemPath);
  2527. item.size = stats.size;
  2528. item.modified = stats.mtime.toISOString();
  2529. // Convert permissions to rwx format
  2530. const mode = stats.mode;
  2531. const ownerPerm = (mode >> 6) & 7;
  2532. const groupPerm = (mode >> 3) & 7;
  2533. const otherPerm = mode & 7;
  2534. item.permissions = ((mode >> 6) & 7).toString() + ((mode >> 3) & 7).toString() + (mode & 7).toString();
  2535. item.permissionsRwx = permToRwx(ownerPerm) + permToRwx(groupPerm) + permToRwx(otherPerm);
  2536. } catch (statError) {
  2537. // If stat fails, provide default values
  2538. item.size = 0;
  2539. item.modified = null;
  2540. item.permissions = '000';
  2541. item.permissionsRwx = '---------';
  2542. }
  2543. if (entry.isDirectory() && currentDepth < maxDepth) {
  2544. // Recursively get subdirectories but limit depth
  2545. try {
  2546. // Check if we can access the directory before trying to read it
  2547. await fsPromises.access(item.path, fs.constants.R_OK);
  2548. item.children = await getFileTree(item.path, maxDepth, currentDepth + 1, showHidden);
  2549. } catch (e) {
  2550. // Silently skip directories we can't access (permission denied, etc.)
  2551. item.children = [];
  2552. }
  2553. }
  2554. items.push(item);
  2555. }
  2556. } catch (error) {
  2557. // Only log non-permission errors to avoid spam
  2558. if (error.code !== 'EACCES' && error.code !== 'EPERM') {
  2559. console.error('Error reading directory:', error);
  2560. }
  2561. }
  2562. return items.sort((a, b) => {
  2563. if (a.type !== b.type) {
  2564. return a.type === 'directory' ? -1 : 1;
  2565. }
  2566. return a.name.localeCompare(b.name);
  2567. });
  2568. }
  2569. const SERVER_PORT = process.env.SERVER_PORT || 3001;
  2570. const HOST = process.env.HOST || '0.0.0.0';
  2571. const DISPLAY_HOST = getConnectableHost(HOST);
  2572. const VITE_PORT = process.env.VITE_PORT || 5173;
  2573. const PORT_FALLBACK_ATTEMPTS = 5;
  2574. // Pick a random high port in the 20000–59999 range. Random (rather than the
  2575. // preferred port + 1) because adjacent ports are frequently held by the same
  2576. // multi-port app that already took the preferred one.
  2577. function pickRandomHighPort() {
  2578. return 20000 + Math.floor(Math.random() * 40000);
  2579. }
  2580. // Listen on `preferredPort`; on EADDRINUSE retry on random high ports up to
  2581. // PORT_FALLBACK_ATTEMPTS times. Resolves with the actually-bound port, or null
  2582. // if every attempt was in use. Non-EADDRINUSE errors reject — real failures
  2583. // (bad host, permissions) must not be silently retried.
  2584. function listenWithPortFallback(srv, preferredPort, host) {
  2585. let port = preferredPort;
  2586. let attempt = 0;
  2587. return new Promise((resolve, reject) => {
  2588. const tryListen = () => {
  2589. attempt += 1;
  2590. const onError = (err) => {
  2591. srv.removeListener('listening', onListening);
  2592. if (err && err.code === 'EADDRINUSE') {
  2593. if (attempt >= PORT_FALLBACK_ATTEMPTS) {
  2594. resolve(null);
  2595. return;
  2596. }
  2597. const nextPort = pickRandomHighPort();
  2598. console.log(`${c.warn('[WARN]')} Port ${port} is in use; retrying on random port ${nextPort} (attempt ${attempt}/${PORT_FALLBACK_ATTEMPTS})...`);
  2599. port = nextPort;
  2600. setImmediate(tryListen);
  2601. return;
  2602. }
  2603. reject(err);
  2604. };
  2605. const onListening = () => {
  2606. srv.removeListener('error', onError);
  2607. resolve(srv.address().port);
  2608. };
  2609. srv.once('error', onError);
  2610. srv.once('listening', onListening);
  2611. srv.listen(port, host);
  2612. };
  2613. tryListen();
  2614. });
  2615. }
  2616. async function ensureLocalUserWhenAuthDisabled() {
  2617. if (!DISABLE_LOCAL_AUTH || userDb.hasUsers()) {
  2618. return;
  2619. }
  2620. const passwordHash = await bcrypt.hash(crypto.randomBytes(32).toString('hex'), 12);
  2621. userDb.createUser('local', passwordHash);
  2622. console.log(`${c.info('[INFO]')} Web UI login is disabled (default). Using built-in user. Set PILOTDECK_DISABLE_LOCAL_AUTH=0 to require username/password.`);
  2623. }
  2624. // Initialize database and start server
  2625. async function startServer() {
  2626. try {
  2627. await startServerAfterStartup({
  2628. startupFn: async () => {
  2629. await runServerStartupBeforeListen({
  2630. initializeDatabaseFn: initializeDatabase,
  2631. ensureLocalUserWhenAuthDisabledFn: ensureLocalUserWhenAuthDisabled,
  2632. configureWebPushFn: configureWebPush
  2633. });
  2634. },
  2635. listenFn: async () => {
  2636. // Check if running in production mode (dist folder exists)
  2637. const distIndexPath = path.join(__dirname, '../dist/index.html');
  2638. const isProduction = fs.existsSync(distIndexPath);
  2639. console.log(`${c.info('[INFO]')} Chat execution routed through PilotDeck gateway (src/gateway).`);
  2640. console.log('');
  2641. if (isProduction) {
  2642. console.log(`${c.info('[INFO]')} Starting in production mode...`);
  2643. } else {
  2644. console.log(`${c.info('[INFO]')} No production frontend build found; development mode expects Vite at http://${DISPLAY_HOST}:${VITE_PORT}`);
  2645. }
  2646. const boundPort = await listenWithPortFallback(server, Number(SERVER_PORT), HOST);
  2647. if (boundPort === null) {
  2648. console.error(`${c.warn('[ERROR]')} Could not bind a port after ${PORT_FALLBACK_ATTEMPTS} attempts (preferred ${SERVER_PORT}). All tried ports were in use. Set SERVER_PORT to a free port and retry.`);
  2649. process.exit(1);
  2650. }
  2651. // Sync the actually-bound port back to the env so other modules
  2652. // that self-reference SERVER_PORT (e.g. routes/taskmaster.js) hit
  2653. // the right port after a fallback.
  2654. process.env.SERVER_PORT = String(boundPort);
  2655. {
  2656. const appInstallPath = path.join(__dirname, '..');
  2657. console.log('');
  2658. console.log(c.dim('═'.repeat(63)));
  2659. console.log(` ${c.bright('PilotDeck Server - Ready')}`);
  2660. console.log(c.dim('═'.repeat(63)));
  2661. console.log('');
  2662. console.log(`${c.info('[INFO]')} Server URL: ${c.bright('http://' + DISPLAY_HOST + ':' + boundPort)}`);
  2663. console.log(`${c.info('[INFO]')} Installed at: ${c.dim(appInstallPath)}`);
  2664. console.log(`${c.tip('[TIP]')} Run "pilotdeck status" for full configuration details`);
  2665. console.log('');
  2666. // Desktop shell loads the UI inside Electron; CLI/dev can opt in to
  2667. // auto-open. PILOTDECK_DESKTOP=1 is set by apps/desktop server-manager.
  2668. const skipAutoOpen =
  2669. process.env.PILOTDECK_DESKTOP === '1'
  2670. || process.env.PILOTDECK_SKIP_BROWSER_OPEN === '1';
  2671. if (!skipAutoOpen) {
  2672. const serverUrl = `http://${DISPLAY_HOST === '0.0.0.0' ? 'localhost' : DISPLAY_HOST}:${boundPort}`;
  2673. const openCmd = process.platform === 'darwin' ? 'open'
  2674. : process.platform === 'win32' ? 'start'
  2675. : 'xdg-open';
  2676. exec(`${openCmd} "${serverUrl}"`, () => {});
  2677. }
  2678. // Start watching the projects folder for changes
  2679. await setupProjectsWatcher();
  2680. await ensurePilotDeckProxyRunning();
  2681. // Start background memory scheduler for auto index/dream.
  2682. startMemoryScheduler();
  2683. // Start server-side plugin processes for enabled plugins
  2684. startEnabledPluginServers().catch(err => {
  2685. console.error('[Plugins] Error during startup:', err.message);
  2686. });
  2687. // Hot-reload watcher: external edits to ~/.pilotdeck/pilotdeck.yaml
  2688. // (vim, Cursor, another process) trigger a validate+reload and push
  2689. // a "config:reloaded" event to every connected WebSocket client.
  2690. await startPilotDeckConfigWatcher({
  2691. onEvent: (payload) => {
  2692. process.emit('pilotdeck:config-broadcast', payload);
  2693. },
  2694. });
  2695. }
  2696. }
  2697. });
  2698. let shutdownPromise = null;
  2699. const gracefulShutdown = async () => {
  2700. if (shutdownPromise) {
  2701. return shutdownPromise;
  2702. }
  2703. shutdownPromise = (async () => {
  2704. try {
  2705. stopMemoryScheduler();
  2706. closeMemoryServices();
  2707. stopPilotDeckConfigWatcher();
  2708. await stopPilotDeckProxy();
  2709. await stopAllPlugins();
  2710. // helpers were retired with the four-provider runtime.
  2711. try {
  2712. const { shutdownGlobalChrome, stopChromeHealthCheck } = await import('./utils/globalChrome.js');
  2713. stopChromeHealthCheck();
  2714. shutdownGlobalChrome();
  2715. } catch { /* Chrome may not have been started */ }
  2716. // PilotDeck cron is owned by `pilotdeck server` and shuts
  2717. // down with it; ui/server never spawns its own daemon.
  2718. } finally {
  2719. process.exit(0);
  2720. }
  2721. })();
  2722. return shutdownPromise;
  2723. };
  2724. process.on('SIGTERM', () => void gracefulShutdown());
  2725. process.on('SIGINT', () => void gracefulShutdown());
  2726. } catch (error) {
  2727. console.error('[ERROR] Failed to start server:', error);
  2728. process.exit(1);
  2729. }
  2730. }
  2731. startServer();