claude-code-hub 0.39.0 → 0.41.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -0
- package/lib/apps.js +23 -0
- package/lib/net-guard.js +1 -1
- package/package.json +10 -6
- package/public/app.js +48 -53
- package/server.js +37 -24
package/README.md
CHANGED
|
@@ -99,6 +99,26 @@ The hub can run the tools against more than one Claude config dir (`CLAUDE_CONFI
|
|
|
99
99
|
- Each dir gets its own set of the four tools. When you switch, the hub keeps the old set running, so switching back is instant. `--pool-size` sets how many sets stay alive (default 3). When the pool is full, the hub stops the least recently used set, but not a set whose Kanban has open terminals.
|
|
100
100
|
- The list and the active dir are saved in `~/.claude-hub/config.json`.
|
|
101
101
|
|
|
102
|
+
## Choose the tools
|
|
103
|
+
|
|
104
|
+
By default the hub runs all four tools. To turn a tool off or change the tab order, add an `apps` list to `~/.claude-hub/config.json`:
|
|
105
|
+
|
|
106
|
+
```json
|
|
107
|
+
{
|
|
108
|
+
"apps": [
|
|
109
|
+
{ "id": "kanban" },
|
|
110
|
+
{ "id": "cost" },
|
|
111
|
+
{ "id": "marketplace", "enabled": false }
|
|
112
|
+
]
|
|
113
|
+
}
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
- The ids are `kanban`, `marketplace`, `cost` and `memory`.
|
|
117
|
+
- The tools you list come first, in list order. The tools you do not list follow in the default order.
|
|
118
|
+
- A tool with `"enabled": false` does not start and gets no tab. Links from other tools to it do nothing.
|
|
119
|
+
- With Kanban off, the embedded terminal is off, and the project palette has no project list. You can still type a path.
|
|
120
|
+
- Restart the hub after you edit the file.
|
|
121
|
+
|
|
102
122
|
## Embedded terminal
|
|
103
123
|
|
|
104
124
|
Under the hub, Kanban has an embedded terminal, on by default, so you can start or resume Claude Code sessions from the board. While a terminal is attached, the hub asks before it closes the window, because <kbd>Ctrl</kbd>+<kbd>W</kbd> meant for the terminal would close it.
|
|
@@ -126,6 +146,8 @@ The hub has no visible UI of its own. Use these shortcuts:
|
|
|
126
146
|
| <kbd>Ctrl</kbd>+<kbd>Alt</kbd>+<kbd>P</kbd> | Project palette: set the project for all tools |
|
|
127
147
|
| <kbd>Ctrl</kbd>+<kbd>Alt</kbd>+<kbd>W</kbd> | Config-dir palette: switch, add, or remove a config dir |
|
|
128
148
|
|
|
149
|
+
<kbd>Alt</kbd>+<kbd>1</kbd> to <kbd>Alt</kbd>+<kbd>4</kbd> follow the tab order, so they change when you [choose the tools](#choose-the-tools).
|
|
150
|
+
|
|
129
151
|
In a palette, use <kbd>↑</kbd>/<kbd>↓</kbd> or <kbd>Tab</kbd> to move, <kbd>Enter</kbd> to select, and <kbd>Esc</kbd> to close.
|
|
130
152
|
|
|
131
153
|
The shortcuts also work when focus is inside a tool. Kanban keeps <kbd>Ctrl</kbd>+<kbd>Alt</kbd>+<kbd>N</kbd> (new session), <kbd>Ctrl</kbd>+<kbd>Alt</kbd>+<kbd>R</kbd> (resume session), and <kbd>Ctrl</kbd>+<kbd>Alt</kbd>+<kbd>S</kbd> (previous session) for itself. From Kanban you can also jump to another tool for the selected session:
|
package/lib/apps.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Built-in apps in their default tab order.
|
|
4
|
+
const APPS = [
|
|
5
|
+
{ id: 'kanban', dir: 'cck', pkg: 'claude-code-kanban', name: 'Kanban', icon: 'columns', port: 3541 },
|
|
6
|
+
{ id: 'marketplace', dir: 'marketplace', pkg: 'claude-code-marketplace', name: 'Marketplace', icon: 'store', port: 3542 },
|
|
7
|
+
{ id: 'cost', dir: 'cost', pkg: 'claude-code-cost', name: 'Cost', icon: 'dollar-sign', port: 3543 },
|
|
8
|
+
{ id: 'memory', dir: 'memory', pkg: 'claude-code-memory-explorer', name: 'Memory Diagnoser', icon: 'database', port: 3544 },
|
|
9
|
+
];
|
|
10
|
+
|
|
11
|
+
// `entries` is the hand-edited `apps` list in config.json. Listed apps come first in list order and
|
|
12
|
+
// unlisted ones follow in default order, so an app added in a later release shows up without an edit.
|
|
13
|
+
function selectApps(entries = []) {
|
|
14
|
+
const listed = entries.filter((e) => typeof e?.id === 'string');
|
|
15
|
+
const byId = (id) => APPS.find((a) => a.id === id);
|
|
16
|
+
const unknown = listed.map((e) => e.id).filter((id) => !byId(id));
|
|
17
|
+
const off = new Set(listed.filter((e) => e.enabled === false).map((e) => e.id));
|
|
18
|
+
const order = new Set([...listed.map((e) => e.id), ...APPS.map((a) => a.id)]);
|
|
19
|
+
const apps = [...order].map(byId).filter((a) => a && !off.has(a.id));
|
|
20
|
+
return { apps, unknown };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
module.exports = { selectApps };
|
package/lib/net-guard.js
CHANGED
|
@@ -245,7 +245,7 @@ function createNetGuard(config = {}) {
|
|
|
245
245
|
* @param {{maxHeaderSize?: number, onUpgrade?: (req: any, socket: any, head: Buffer) => void}} [opts]
|
|
246
246
|
*/
|
|
247
247
|
function listenLoopback(app, port, onReady, opts = {}) {
|
|
248
|
-
const http = require('http');
|
|
248
|
+
const http = require('node:http');
|
|
249
249
|
const serverOpts = opts.maxHeaderSize ? { maxHeaderSize: opts.maxHeaderSize } : {};
|
|
250
250
|
const makeServer = (handler) => {
|
|
251
251
|
const s = http.createServer(serverOpts, handler);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-code-hub",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.41.0",
|
|
4
4
|
"description": "Unified hub for Claude Code tools — Marketplace + Kanban in one PWA",
|
|
5
5
|
"main": "server.js",
|
|
6
6
|
"bin": {
|
|
@@ -15,7 +15,11 @@
|
|
|
15
15
|
"lint:fix": "npx @biomejs/biome check --fix public/app.js server.js",
|
|
16
16
|
"prepare": "husky",
|
|
17
17
|
"check:escaping": "node scripts/check-escaping.mjs",
|
|
18
|
-
"check:security-lib": "bash scripts/sync-security-lib.sh --check"
|
|
18
|
+
"check:security-lib": "bash scripts/sync-security-lib.sh --check",
|
|
19
|
+
"perf": "node scripts/perf.mjs run",
|
|
20
|
+
"perf:compare": "node scripts/perf.mjs compare",
|
|
21
|
+
"perf:report": "node scripts/perf.mjs report",
|
|
22
|
+
"perf:world": "node scripts/perf.mjs gen-world"
|
|
19
23
|
},
|
|
20
24
|
"repository": {
|
|
21
25
|
"type": "git",
|
|
@@ -39,10 +43,10 @@
|
|
|
39
43
|
},
|
|
40
44
|
"homepage": "https://github.com/NikiforovAll/claude-code-hub#readme",
|
|
41
45
|
"dependencies": {
|
|
42
|
-
"claude-code-cost": "^0.
|
|
43
|
-
"claude-code-kanban": "^4.
|
|
44
|
-
"claude-code-marketplace": "^0.
|
|
45
|
-
"claude-code-memory-explorer": "^0.
|
|
46
|
+
"claude-code-cost": "^0.25.0",
|
|
47
|
+
"claude-code-kanban": "^4.30.0",
|
|
48
|
+
"claude-code-marketplace": "^0.21.0",
|
|
49
|
+
"claude-code-memory-explorer": "^0.16.0",
|
|
46
50
|
"express": "^4.21.0",
|
|
47
51
|
"open": "^10.1.0"
|
|
48
52
|
},
|
package/public/app.js
CHANGED
|
@@ -294,17 +294,17 @@ function onIframeLoad(appId) {
|
|
|
294
294
|
loadedApps.add(appId);
|
|
295
295
|
guardedApps.delete(appId);
|
|
296
296
|
if (appId === activeApp) hideLoading();
|
|
297
|
-
|
|
298
|
-
postProjectTo(appId);
|
|
299
|
-
postActiveTo(appId);
|
|
300
|
-
// Posted twice: the shims gate their origin check on window.__HUB__, which they populate from
|
|
301
|
-
// an async /hub-config fetch that resolves after this load event, so the first post can be
|
|
302
|
-
// dropped. Safe to repeat — every shim's apply is idempotent.
|
|
303
|
-
setTimeout(() => {
|
|
297
|
+
const postState = () => {
|
|
304
298
|
postTo(appId, themeMessage());
|
|
305
299
|
postProjectTo(appId);
|
|
306
300
|
postActiveTo(appId);
|
|
307
|
-
|
|
301
|
+
postTo(appId, { type: 'hub:keys', keys: Object.keys(bindings()) });
|
|
302
|
+
};
|
|
303
|
+
postState();
|
|
304
|
+
// Posted twice: the shims gate their origin check on window.__HUB__, which they populate from
|
|
305
|
+
// an async /hub-config fetch that resolves after this load event, so the first post can be
|
|
306
|
+
// dropped. Safe to repeat — every shim's apply is idempotent.
|
|
307
|
+
setTimeout(postState, 400);
|
|
308
308
|
}
|
|
309
309
|
|
|
310
310
|
function listenMessages() {
|
|
@@ -373,21 +373,40 @@ function handleForwardedKey(d) {
|
|
|
373
373
|
// The modifier fields are new. A sub-app running an older service-worker-cached bundle sends
|
|
374
374
|
// {key} only; it forwards nothing but the pre-existing bindings, and each of those is
|
|
375
375
|
// unambiguous from the key alone, so normalize instead of dispatching twice.
|
|
376
|
-
const
|
|
377
|
-
const
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
376
|
+
const legacy = typeof d.alt !== 'boolean';
|
|
377
|
+
const e = {
|
|
378
|
+
key: d.key,
|
|
379
|
+
code: d.code,
|
|
380
|
+
altKey: legacy || d.alt,
|
|
381
|
+
ctrlKey: legacy ? d.key.startsWith('Arrow') : d.ctrl,
|
|
382
|
+
shiftKey: d.shift === true,
|
|
383
|
+
};
|
|
384
|
+
bindings()[comboOf(e)]?.run();
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// The one keymap. Its combo names are what the hub:keys message lists for the shims.
|
|
388
|
+
// inPalette: the binding still fires while the palette is open.
|
|
389
|
+
function bindings() {
|
|
390
|
+
const map = {
|
|
391
|
+
'ctrl+alt+p': { run: () => togglePalette('project'), inPalette: true },
|
|
392
|
+
'ctrl+alt+w': { run: () => togglePalette('configDir'), inPalette: true },
|
|
393
|
+
'ctrl+alt+ArrowLeft': { run: () => cycleTab(-1) },
|
|
394
|
+
'ctrl+alt+ArrowRight': { run: () => cycleTab(1) },
|
|
395
|
+
};
|
|
396
|
+
Object.keys(apps)
|
|
397
|
+
.slice(0, 9)
|
|
398
|
+
.forEach((id, i) => {
|
|
399
|
+
map[`alt+${i + 1}`] = { run: () => switchTab(id) };
|
|
400
|
+
});
|
|
401
|
+
return map;
|
|
387
402
|
}
|
|
388
403
|
|
|
389
|
-
//
|
|
390
|
-
|
|
404
|
+
// Each shim's hubCombo() is a copy of this: modifiers in ctrl, alt, shift, meta order, joined by
|
|
405
|
+
// '+' to the key as bindingKey() names it.
|
|
406
|
+
function comboOf(e) {
|
|
407
|
+
const mods = [e.ctrlKey && 'ctrl', e.altKey && 'alt', e.shiftKey && 'shift', e.metaKey && 'meta'];
|
|
408
|
+
return [...mods, bindingKey(e)].filter(Boolean).join('+');
|
|
409
|
+
}
|
|
391
410
|
|
|
392
411
|
// macOS composes Option+<key> into a character — Option+1 is '¡', Option+P is 'π' — and holding
|
|
393
412
|
// Control does not undo it, so e.key alone cannot carry these bindings there. e.code is the
|
|
@@ -407,20 +426,13 @@ function cycleTab(delta) {
|
|
|
407
426
|
switchTab(next);
|
|
408
427
|
}
|
|
409
428
|
|
|
410
|
-
function switchByIndex(idx) {
|
|
411
|
-
const ids = Object.keys(apps);
|
|
412
|
-
if (idx < ids.length) switchTab(ids[idx]);
|
|
413
|
-
}
|
|
414
|
-
|
|
415
429
|
function listenKeys() {
|
|
416
430
|
document.addEventListener('keydown', (e) => {
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
return;
|
|
423
|
-
}
|
|
431
|
+
const binding = bindings()[comboOf(e)];
|
|
432
|
+
if (binding?.inPalette) {
|
|
433
|
+
e.preventDefault();
|
|
434
|
+
binding.run();
|
|
435
|
+
return;
|
|
424
436
|
}
|
|
425
437
|
// While the palette is open the input owns the keyboard — don't let tab shortcuts fire
|
|
426
438
|
// mid-path (Alt+digit especially, since Windows paths contain digits). Escape still closes:
|
|
@@ -432,26 +444,9 @@ function listenKeys() {
|
|
|
432
444
|
}
|
|
433
445
|
return;
|
|
434
446
|
}
|
|
435
|
-
if (
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
e.preventDefault();
|
|
439
|
-
switchByIndex(Number(k) - 1);
|
|
440
|
-
return;
|
|
441
|
-
}
|
|
442
|
-
}
|
|
443
|
-
if (e.ctrlKey && e.altKey && !e.shiftKey && !e.metaKey) {
|
|
444
|
-
if (e.key === 'ArrowLeft') {
|
|
445
|
-
e.preventDefault();
|
|
446
|
-
cycleTab(-1);
|
|
447
|
-
return;
|
|
448
|
-
}
|
|
449
|
-
if (e.key === 'ArrowRight') {
|
|
450
|
-
e.preventDefault();
|
|
451
|
-
cycleTab(1);
|
|
452
|
-
return;
|
|
453
|
-
}
|
|
454
|
-
}
|
|
447
|
+
if (!binding) return;
|
|
448
|
+
e.preventDefault();
|
|
449
|
+
binding.run();
|
|
455
450
|
});
|
|
456
451
|
}
|
|
457
452
|
|
package/server.js
CHANGED
|
@@ -8,6 +8,7 @@ const http = require('http');
|
|
|
8
8
|
const tcp = require('net');
|
|
9
9
|
const os = require('os');
|
|
10
10
|
const path = require('path');
|
|
11
|
+
const { selectApps } = require('./lib/apps');
|
|
11
12
|
const { createNetGuard } = require('./lib/net-guard');
|
|
12
13
|
|
|
13
14
|
function getArg(name) {
|
|
@@ -19,13 +20,8 @@ function getArg(name) {
|
|
|
19
20
|
}
|
|
20
21
|
|
|
21
22
|
const HUB_PORT = parseInt(getArg('port') || process.env.PORT || '3540', 10);
|
|
22
|
-
const MARKETPLACE_PORT = parseInt(getArg('marketplace-port') || '3542', 10);
|
|
23
|
-
const KANBAN_PORT = parseInt(getArg('kanban-port') || '3541', 10);
|
|
24
|
-
const COST_PORT = parseInt(getArg('cost-port') || '3543', 10);
|
|
25
|
-
const MEMORY_PORT = parseInt(getArg('memory-port') || '3544', 10);
|
|
26
23
|
|
|
27
24
|
const POOL_SIZE = Math.max(1, parseInt(getArg('pool-size') || '3', 10));
|
|
28
|
-
const DEFAULT_PORTS = { marketplace: MARKETPLACE_PORT, kanban: KANBAN_PORT, cost: COST_PORT, memory: MEMORY_PORT };
|
|
29
25
|
const RESTART_DELAY_MS = 500;
|
|
30
26
|
// A rate window, not a total: a child may die and recover all session, but one that cannot stay up
|
|
31
27
|
// stops being restarted. A consecutive counter cannot express that — anything that starts at all
|
|
@@ -87,6 +83,7 @@ function loadHubConfig() {
|
|
|
87
83
|
const config = { configDirs: dirs, activeConfigDir: active };
|
|
88
84
|
// Hand-edited only; kept verbatim so a rewrite of the file does not drop it.
|
|
89
85
|
if (saved.terminal && typeof saved.terminal === 'object') config.terminal = saved.terminal;
|
|
86
|
+
if (Array.isArray(saved.apps)) config.apps = saved.apps;
|
|
90
87
|
// Configs saved before canonicalization can hold one dir under two spellings; persist the merge.
|
|
91
88
|
if (serializeConfig(config) !== raw) writeHubConfig(config);
|
|
92
89
|
return config;
|
|
@@ -107,6 +104,25 @@ function saveHubConfig() {
|
|
|
107
104
|
|
|
108
105
|
const hubConfig = loadHubConfig();
|
|
109
106
|
|
|
107
|
+
const { apps: ENABLED_APPS, unknown: unknownApps } = selectApps(hubConfig.apps);
|
|
108
|
+
for (const id of unknownApps) console.log(`Unknown app "${id}" in ${HUB_CONFIG_FILE}, ignored`);
|
|
109
|
+
if (!ENABLED_APPS.length) {
|
|
110
|
+
console.error(`Every app is disabled in ${HUB_CONFIG_FILE}. Enable at least one.`);
|
|
111
|
+
process.exit(1);
|
|
112
|
+
}
|
|
113
|
+
const appEnabled = (id) => ENABLED_APPS.some((a) => a.id === id);
|
|
114
|
+
|
|
115
|
+
// Every keystroke into cck's terminal passes through the upgrade proxy below. Windows only, for the
|
|
116
|
+
// reasons in cck/lib/priority.js; the children spawned after this still start at normal.
|
|
117
|
+
if (process.platform === 'win32' && process.env.CCK_PRIORITY_BOOST !== '0') {
|
|
118
|
+
try {
|
|
119
|
+
if (os.getPriority() > os.constants.priority.PRIORITY_ABOVE_NORMAL)
|
|
120
|
+
os.setPriority(os.constants.priority.PRIORITY_ABOVE_NORMAL);
|
|
121
|
+
} catch {
|
|
122
|
+
/* keep the default priority */
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
110
126
|
// Created before spawnApp so the same host/allowed-hosts decision reaches the
|
|
111
127
|
// children. Without that propagation, `--host 0.0.0.0` would expose the hub shell
|
|
112
128
|
// while every iframe 403'd on its own Host check.
|
|
@@ -118,7 +134,8 @@ const net = createNetGuard({ appName: 'Claude Code Hub' });
|
|
|
118
134
|
// the WebSocket. cck applies the rest of its policy (exposure refusal, Origin, session cap) itself.
|
|
119
135
|
const TERMINAL = {
|
|
120
136
|
...(hubConfig.terminal || {}),
|
|
121
|
-
enabled:
|
|
137
|
+
enabled:
|
|
138
|
+
appEnabled('kanban') && !process.argv.includes('--disable-terminal') && hubConfig.terminal?.enabled !== false,
|
|
122
139
|
};
|
|
123
140
|
const TERMINAL_TOKEN = TERMINAL.enabled ? crypto.randomBytes(32).toString('hex') : null;
|
|
124
141
|
|
|
@@ -356,10 +373,7 @@ function resolveApp(submoduleDir, npmPackage) {
|
|
|
356
373
|
return require.resolve(`${npmPackage}/server.js`);
|
|
357
374
|
}
|
|
358
375
|
|
|
359
|
-
const
|
|
360
|
-
const kanbanPath = resolveApp('cck', 'claude-code-kanban');
|
|
361
|
-
const costPath = resolveApp('cost', 'claude-code-cost');
|
|
362
|
-
const memoryPath = resolveApp('memory', 'claude-code-memory-explorer');
|
|
376
|
+
const appPaths = Object.fromEntries(ENABLED_APPS.map((a) => [a.id, resolveApp(a.dir, a.pkg)]));
|
|
363
377
|
|
|
364
378
|
// Raise header size limit to 64KB — localhost cookies from sibling apps can pile up and
|
|
365
379
|
// trip Node's default 16KB limit, breaking iframes with HTTP 431.
|
|
@@ -370,13 +384,12 @@ const NODE_HDR = `--max-http-header-size=${HDR_BYTES}`;
|
|
|
370
384
|
// localStorage is keyed by origin, so the sub-app origin has to stay put across switches or the
|
|
371
385
|
// user loses pins and filters every time the active set changes.
|
|
372
386
|
function spawnChildren(pool) {
|
|
373
|
-
spawnApp(pool,
|
|
374
|
-
spawnApp(pool, 'kanban', process.execPath, [NODE_HDR, kanbanPath]);
|
|
375
|
-
spawnApp(pool, 'cost', process.execPath, [NODE_HDR, costPath]);
|
|
376
|
-
spawnApp(pool, 'memory', process.execPath, [NODE_HDR, memoryPath]);
|
|
387
|
+
for (const a of ENABLED_APPS) spawnApp(pool, a.id, process.execPath, [NODE_HDR, appPaths[a.id]]);
|
|
377
388
|
}
|
|
378
389
|
|
|
379
|
-
const publicPorts =
|
|
390
|
+
const publicPorts = Object.fromEntries(
|
|
391
|
+
ENABLED_APPS.map((a) => [a.id, parseInt(getArg(`${a.id}-port`) || String(a.port), 10)]),
|
|
392
|
+
);
|
|
380
393
|
|
|
381
394
|
function rewriteOrigin(origin, publicPort, childPort) {
|
|
382
395
|
try {
|
|
@@ -421,6 +434,7 @@ async function waitForPort(name, deadline) {
|
|
|
421
434
|
const pool = activePool();
|
|
422
435
|
const port = pool?.ports[name];
|
|
423
436
|
if (port) return port;
|
|
437
|
+
if (pool && !pool.children.has(name)) return null;
|
|
424
438
|
const remaining = deadline - Date.now();
|
|
425
439
|
if (remaining <= 0) return null;
|
|
426
440
|
await Promise.race([pool?.children.get(name)?.ready, delay(Math.min(remaining, 250))]);
|
|
@@ -595,7 +609,7 @@ function listenWithFallback(handler, port, onReady, label, onUpgrade) {
|
|
|
595
609
|
return server;
|
|
596
610
|
}
|
|
597
611
|
|
|
598
|
-
for (const [name, port] of Object.entries(
|
|
612
|
+
for (const [name, port] of Object.entries(publicPorts)) {
|
|
599
613
|
listenWithFallback(
|
|
600
614
|
proxyHandler(name),
|
|
601
615
|
port,
|
|
@@ -644,14 +658,13 @@ const themePalettes = (() => {
|
|
|
644
658
|
})();
|
|
645
659
|
|
|
646
660
|
function appsConfig() {
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
};
|
|
661
|
+
return Object.fromEntries(
|
|
662
|
+
ENABLED_APPS.map((a) => {
|
|
663
|
+
const entry = { name: a.name, url: `http://localhost:${publicPorts[a.id]}`, icon: a.icon };
|
|
664
|
+
if (a.id === 'kanban' && TERMINAL_TOKEN) entry.terminalToken = TERMINAL_TOKEN;
|
|
665
|
+
return [a.id, entry];
|
|
666
|
+
}),
|
|
667
|
+
);
|
|
655
668
|
}
|
|
656
669
|
|
|
657
670
|
app.get('/api/config', (_req, res) => {
|