@openchamber/web 1.4.2 → 1.4.4
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/bin/cli.js +44 -9
- package/dist/assets/{ToolOutputDialog-zILn8Ed6.js → ToolOutputDialog-BXPi0SDL.js} +3 -3
- package/dist/assets/{index-5Ree9Z_i.js → index-CqQbtUxU.js} +2 -2
- package/dist/assets/index-DR2OFuzB.css +1 -0
- package/dist/assets/main-j7ViaNnX.js +127 -0
- package/dist/assets/{vendor-.bun-COPXsM7o.js → vendor-.bun-BEzqubWg.js} +418 -414
- package/dist/index.html +3 -3
- package/package.json +3 -2
- package/server/index.js +880 -90
- package/server/lib/git-service.js +30 -1
- package/server/lib/opencode-config.js +306 -29
- package/dist/assets/index-msMCcGYO.css +0 -1
- package/dist/assets/main-DF2J5RAs.js +0 -122
package/server/index.js
CHANGED
|
@@ -6,6 +6,7 @@ import fs from 'fs';
|
|
|
6
6
|
import http from 'http';
|
|
7
7
|
import { fileURLToPath } from 'url';
|
|
8
8
|
import os from 'os';
|
|
9
|
+
import crypto from 'crypto';
|
|
9
10
|
import { createUiAuth } from './lib/ui-auth.js';
|
|
10
11
|
import { startCloudflareTunnel, printTunnelWarning, checkCloudflaredAvailable } from './lib/cloudflare-tunnel.js';
|
|
11
12
|
|
|
@@ -315,6 +316,76 @@ const writeSettingsToDisk = async (settings) => {
|
|
|
315
316
|
}
|
|
316
317
|
};
|
|
317
318
|
|
|
319
|
+
const resolveDirectoryCandidate = (value) => {
|
|
320
|
+
if (typeof value !== 'string') {
|
|
321
|
+
return null;
|
|
322
|
+
}
|
|
323
|
+
const trimmed = value.trim();
|
|
324
|
+
if (!trimmed) {
|
|
325
|
+
return null;
|
|
326
|
+
}
|
|
327
|
+
const normalized = normalizeDirectoryPath(trimmed);
|
|
328
|
+
return path.resolve(normalized);
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
const validateDirectoryPath = async (candidate) => {
|
|
332
|
+
const resolved = resolveDirectoryCandidate(candidate);
|
|
333
|
+
if (!resolved) {
|
|
334
|
+
return { ok: false, error: 'Directory parameter is required' };
|
|
335
|
+
}
|
|
336
|
+
try {
|
|
337
|
+
const stats = await fsPromises.stat(resolved);
|
|
338
|
+
if (!stats.isDirectory()) {
|
|
339
|
+
return { ok: false, error: 'Specified path is not a directory' };
|
|
340
|
+
}
|
|
341
|
+
return { ok: true, directory: resolved };
|
|
342
|
+
} catch (error) {
|
|
343
|
+
const err = error;
|
|
344
|
+
if (err && typeof err === 'object' && err.code === 'ENOENT') {
|
|
345
|
+
return { ok: false, error: 'Directory not found' };
|
|
346
|
+
}
|
|
347
|
+
if (err && typeof err === 'object' && err.code === 'EACCES') {
|
|
348
|
+
return { ok: false, error: 'Access to directory denied' };
|
|
349
|
+
}
|
|
350
|
+
return { ok: false, error: 'Failed to validate directory' };
|
|
351
|
+
}
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
const resolveProjectDirectory = async (req) => {
|
|
355
|
+
const headerDirectory = typeof req.get === 'function' ? req.get('x-opencode-directory') : null;
|
|
356
|
+
const queryDirectory = Array.isArray(req.query?.directory)
|
|
357
|
+
? req.query.directory[0]
|
|
358
|
+
: req.query?.directory;
|
|
359
|
+
const requested = headerDirectory || queryDirectory || null;
|
|
360
|
+
|
|
361
|
+
if (requested) {
|
|
362
|
+
const validated = await validateDirectoryPath(requested);
|
|
363
|
+
if (!validated.ok) {
|
|
364
|
+
return { directory: null, error: validated.error };
|
|
365
|
+
}
|
|
366
|
+
return { directory: validated.directory, error: null };
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const settings = await readSettingsFromDiskMigrated();
|
|
370
|
+
const projects = sanitizeProjects(settings.projects) || [];
|
|
371
|
+
if (projects.length === 0) {
|
|
372
|
+
return { directory: null, error: 'Directory parameter or active project is required' };
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const activeId = typeof settings.activeProjectId === 'string' ? settings.activeProjectId : '';
|
|
376
|
+
const active = projects.find((project) => project.id === activeId) || projects[0];
|
|
377
|
+
if (!active || !active.path) {
|
|
378
|
+
return { directory: null, error: 'Directory parameter or active project is required' };
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const validated = await validateDirectoryPath(active.path);
|
|
382
|
+
if (!validated.ok) {
|
|
383
|
+
return { directory: null, error: validated.error };
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
return { directory: validated.directory, error: null };
|
|
387
|
+
};
|
|
388
|
+
|
|
318
389
|
const sanitizeTypographySizesPartial = (input) => {
|
|
319
390
|
if (!input || typeof input !== 'object') {
|
|
320
391
|
return undefined;
|
|
@@ -384,6 +455,67 @@ const sanitizeSkillCatalogs = (input) => {
|
|
|
384
455
|
return result;
|
|
385
456
|
};
|
|
386
457
|
|
|
458
|
+
const sanitizeProjects = (input) => {
|
|
459
|
+
if (!Array.isArray(input)) {
|
|
460
|
+
return undefined;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
const result = [];
|
|
464
|
+
const seenIds = new Set();
|
|
465
|
+
const seenPaths = new Set();
|
|
466
|
+
|
|
467
|
+
for (const entry of input) {
|
|
468
|
+
if (!entry || typeof entry !== 'object') continue;
|
|
469
|
+
|
|
470
|
+
const candidate = entry;
|
|
471
|
+
const id = typeof candidate.id === 'string' ? candidate.id.trim() : '';
|
|
472
|
+
const rawPath = typeof candidate.path === 'string' ? candidate.path.trim() : '';
|
|
473
|
+
const normalizedPath = rawPath ? path.resolve(normalizeDirectoryPath(rawPath)) : '';
|
|
474
|
+
const label = typeof candidate.label === 'string' ? candidate.label.trim() : '';
|
|
475
|
+
const addedAt = Number.isFinite(candidate.addedAt) ? Number(candidate.addedAt) : null;
|
|
476
|
+
const lastOpenedAt = Number.isFinite(candidate.lastOpenedAt)
|
|
477
|
+
? Number(candidate.lastOpenedAt)
|
|
478
|
+
: null;
|
|
479
|
+
|
|
480
|
+
if (!id || !normalizedPath) continue;
|
|
481
|
+
if (seenIds.has(id)) continue;
|
|
482
|
+
if (seenPaths.has(normalizedPath)) continue;
|
|
483
|
+
|
|
484
|
+
seenIds.add(id);
|
|
485
|
+
seenPaths.add(normalizedPath);
|
|
486
|
+
|
|
487
|
+
const project = {
|
|
488
|
+
id,
|
|
489
|
+
path: normalizedPath,
|
|
490
|
+
...(label ? { label } : {}),
|
|
491
|
+
...(Number.isFinite(addedAt) && addedAt >= 0 ? { addedAt } : {}),
|
|
492
|
+
...(Number.isFinite(lastOpenedAt) && lastOpenedAt >= 0 ? { lastOpenedAt } : {}),
|
|
493
|
+
};
|
|
494
|
+
|
|
495
|
+
// Preserve worktreeDefaults
|
|
496
|
+
if (candidate.worktreeDefaults && typeof candidate.worktreeDefaults === 'object') {
|
|
497
|
+
const wt = candidate.worktreeDefaults;
|
|
498
|
+
const defaults = {};
|
|
499
|
+
if (typeof wt.branchPrefix === 'string' && wt.branchPrefix.trim()) {
|
|
500
|
+
defaults.branchPrefix = wt.branchPrefix.trim();
|
|
501
|
+
}
|
|
502
|
+
if (typeof wt.baseBranch === 'string' && wt.baseBranch.trim()) {
|
|
503
|
+
defaults.baseBranch = wt.baseBranch.trim();
|
|
504
|
+
}
|
|
505
|
+
if (typeof wt.autoCreateWorktree === 'boolean') {
|
|
506
|
+
defaults.autoCreateWorktree = wt.autoCreateWorktree;
|
|
507
|
+
}
|
|
508
|
+
if (Object.keys(defaults).length > 0) {
|
|
509
|
+
project.worktreeDefaults = defaults;
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
result.push(project);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
return result;
|
|
517
|
+
};
|
|
518
|
+
|
|
387
519
|
const sanitizeSettingsUpdate = (payload) => {
|
|
388
520
|
if (!payload || typeof payload !== 'object') {
|
|
389
521
|
return {};
|
|
@@ -413,6 +545,15 @@ const sanitizeSettingsUpdate = (payload) => {
|
|
|
413
545
|
if (typeof candidate.homeDirectory === 'string' && candidate.homeDirectory.length > 0) {
|
|
414
546
|
result.homeDirectory = candidate.homeDirectory;
|
|
415
547
|
}
|
|
548
|
+
if (Array.isArray(candidate.projects)) {
|
|
549
|
+
const projects = sanitizeProjects(candidate.projects);
|
|
550
|
+
if (projects) {
|
|
551
|
+
result.projects = projects;
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
if (typeof candidate.activeProjectId === 'string' && candidate.activeProjectId.length > 0) {
|
|
555
|
+
result.activeProjectId = candidate.activeProjectId;
|
|
556
|
+
}
|
|
416
557
|
|
|
417
558
|
if (Array.isArray(candidate.approvedDirectories)) {
|
|
418
559
|
result.approvedDirectories = normalizeStringArray(candidate.approvedDirectories);
|
|
@@ -458,6 +599,9 @@ const sanitizeSettingsUpdate = (payload) => {
|
|
|
458
599
|
if (typeof candidate.queueModeEnabled === 'boolean') {
|
|
459
600
|
result.queueModeEnabled = candidate.queueModeEnabled;
|
|
460
601
|
}
|
|
602
|
+
if (typeof candidate.autoCreateWorktree === 'boolean') {
|
|
603
|
+
result.autoCreateWorktree = candidate.autoCreateWorktree;
|
|
604
|
+
}
|
|
461
605
|
|
|
462
606
|
const skillCatalogs = sanitizeSkillCatalogs(candidate.skillCatalogs);
|
|
463
607
|
if (skillCatalogs) {
|
|
@@ -481,6 +625,16 @@ const mergePersistedSettings = (current, changes) => {
|
|
|
481
625
|
if (typeof changes.homeDirectory === 'string' && changes.homeDirectory.length > 0) {
|
|
482
626
|
additionalApproved.push(changes.homeDirectory);
|
|
483
627
|
}
|
|
628
|
+
const projectEntries = Array.isArray(changes.projects)
|
|
629
|
+
? changes.projects
|
|
630
|
+
: Array.isArray(current.projects)
|
|
631
|
+
? current.projects
|
|
632
|
+
: [];
|
|
633
|
+
projectEntries.forEach((project) => {
|
|
634
|
+
if (project && typeof project.path === 'string' && project.path.length > 0) {
|
|
635
|
+
additionalApproved.push(project.path);
|
|
636
|
+
}
|
|
637
|
+
});
|
|
484
638
|
const approvedSource = [...baseApproved, ...additionalApproved];
|
|
485
639
|
|
|
486
640
|
const baseBookmarks = Array.isArray(changes.securityScopedBookmarks)
|
|
@@ -535,10 +689,124 @@ const formatSettingsResponse = (settings) => {
|
|
|
535
689
|
};
|
|
536
690
|
};
|
|
537
691
|
|
|
692
|
+
const validateProjectEntries = async (projects) => {
|
|
693
|
+
if (!Array.isArray(projects)) {
|
|
694
|
+
return [];
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
const results = [];
|
|
698
|
+
for (const project of projects) {
|
|
699
|
+
if (!project || typeof project.path !== 'string' || project.path.length === 0) {
|
|
700
|
+
continue;
|
|
701
|
+
}
|
|
702
|
+
try {
|
|
703
|
+
const stats = await fsPromises.stat(project.path);
|
|
704
|
+
if (!stats.isDirectory()) {
|
|
705
|
+
continue;
|
|
706
|
+
}
|
|
707
|
+
results.push(project);
|
|
708
|
+
} catch (error) {
|
|
709
|
+
const err = error;
|
|
710
|
+
if (err && typeof err === 'object' && err.code === 'ENOENT') {
|
|
711
|
+
continue;
|
|
712
|
+
}
|
|
713
|
+
continue;
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
return results;
|
|
718
|
+
};
|
|
719
|
+
|
|
720
|
+
const migrateSettingsFromLegacyLastDirectory = async (current) => {
|
|
721
|
+
const settings = current && typeof current === 'object' ? current : {};
|
|
722
|
+
const now = Date.now();
|
|
723
|
+
|
|
724
|
+
const sanitizedProjects = sanitizeProjects(settings.projects) || [];
|
|
725
|
+
let nextProjects = sanitizedProjects;
|
|
726
|
+
let nextActiveProjectId =
|
|
727
|
+
typeof settings.activeProjectId === 'string' ? settings.activeProjectId : undefined;
|
|
728
|
+
|
|
729
|
+
let changed = false;
|
|
730
|
+
|
|
731
|
+
if (nextProjects.length === 0) {
|
|
732
|
+
const legacy = typeof settings.lastDirectory === 'string' ? settings.lastDirectory.trim() : '';
|
|
733
|
+
const candidate = legacy ? resolveDirectoryCandidate(legacy) : null;
|
|
734
|
+
|
|
735
|
+
if (candidate) {
|
|
736
|
+
try {
|
|
737
|
+
const stats = await fsPromises.stat(candidate);
|
|
738
|
+
if (stats.isDirectory()) {
|
|
739
|
+
const id = crypto.randomUUID();
|
|
740
|
+
nextProjects = [
|
|
741
|
+
{
|
|
742
|
+
id,
|
|
743
|
+
path: candidate,
|
|
744
|
+
addedAt: now,
|
|
745
|
+
lastOpenedAt: now,
|
|
746
|
+
},
|
|
747
|
+
];
|
|
748
|
+
nextActiveProjectId = id;
|
|
749
|
+
changed = true;
|
|
750
|
+
}
|
|
751
|
+
} catch {
|
|
752
|
+
// ignore invalid lastDirectory
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
if (nextProjects.length > 0) {
|
|
758
|
+
const active = nextProjects.find((project) => project.id === nextActiveProjectId) || null;
|
|
759
|
+
if (!active) {
|
|
760
|
+
nextActiveProjectId = nextProjects[0].id;
|
|
761
|
+
changed = true;
|
|
762
|
+
}
|
|
763
|
+
} else if (nextActiveProjectId) {
|
|
764
|
+
nextActiveProjectId = undefined;
|
|
765
|
+
changed = true;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
if (!changed) {
|
|
769
|
+
return { settings, changed: false };
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
const merged = mergePersistedSettings(settings, {
|
|
773
|
+
...settings,
|
|
774
|
+
projects: nextProjects,
|
|
775
|
+
...(nextActiveProjectId ? { activeProjectId: nextActiveProjectId } : { activeProjectId: undefined }),
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
return { settings: merged, changed: true };
|
|
779
|
+
};
|
|
780
|
+
|
|
781
|
+
const readSettingsFromDiskMigrated = async () => {
|
|
782
|
+
const current = await readSettingsFromDisk();
|
|
783
|
+
const { settings, changed } = await migrateSettingsFromLegacyLastDirectory(current);
|
|
784
|
+
if (changed) {
|
|
785
|
+
await writeSettingsToDisk(settings);
|
|
786
|
+
}
|
|
787
|
+
return settings;
|
|
788
|
+
};
|
|
789
|
+
|
|
538
790
|
const persistSettings = async (changes) => {
|
|
539
791
|
const current = await readSettingsFromDisk();
|
|
540
792
|
const sanitized = sanitizeSettingsUpdate(changes);
|
|
541
|
-
|
|
793
|
+
let next = mergePersistedSettings(current, sanitized);
|
|
794
|
+
|
|
795
|
+
if (Array.isArray(next.projects)) {
|
|
796
|
+
const validated = await validateProjectEntries(next.projects);
|
|
797
|
+
next = { ...next, projects: validated };
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
if (Array.isArray(next.projects) && next.projects.length > 0) {
|
|
801
|
+
const activeId = typeof next.activeProjectId === 'string' ? next.activeProjectId : '';
|
|
802
|
+
const active = next.projects.find((project) => project.id === activeId) || null;
|
|
803
|
+
if (!active) {
|
|
804
|
+
next = { ...next, activeProjectId: next.projects[0].id };
|
|
805
|
+
}
|
|
806
|
+
} else if (next.activeProjectId) {
|
|
807
|
+
next = { ...next, activeProjectId: undefined };
|
|
808
|
+
}
|
|
809
|
+
|
|
542
810
|
await writeSettingsToDisk(next);
|
|
543
811
|
return formatSettingsResponse(next);
|
|
544
812
|
};
|
|
@@ -551,7 +819,7 @@ const getHmrState = () => {
|
|
|
551
819
|
globalThis[HMR_STATE_KEY] = {
|
|
552
820
|
openCodeProcess: null,
|
|
553
821
|
openCodePort: null,
|
|
554
|
-
openCodeWorkingDirectory:
|
|
822
|
+
openCodeWorkingDirectory: os.homedir(),
|
|
555
823
|
isShuttingDown: false,
|
|
556
824
|
signalsAttached: false,
|
|
557
825
|
};
|
|
@@ -937,7 +1205,16 @@ function parseSseDataPayload(block) {
|
|
|
937
1205
|
}
|
|
938
1206
|
|
|
939
1207
|
try {
|
|
940
|
-
|
|
1208
|
+
const parsed = JSON.parse(payloadText);
|
|
1209
|
+
if (
|
|
1210
|
+
parsed &&
|
|
1211
|
+
typeof parsed === 'object' &&
|
|
1212
|
+
typeof parsed.payload === 'object' &&
|
|
1213
|
+
parsed.payload !== null
|
|
1214
|
+
) {
|
|
1215
|
+
return parsed.payload;
|
|
1216
|
+
}
|
|
1217
|
+
return parsed;
|
|
941
1218
|
} catch {
|
|
942
1219
|
return null;
|
|
943
1220
|
}
|
|
@@ -950,7 +1227,7 @@ function deriveSessionActivity(payload) {
|
|
|
950
1227
|
|
|
951
1228
|
if (payload.type === 'session.status') {
|
|
952
1229
|
const status = payload.properties?.status;
|
|
953
|
-
const sessionId = payload.properties?.sessionID;
|
|
1230
|
+
const sessionId = payload.properties?.sessionID ?? payload.properties?.sessionId;
|
|
954
1231
|
const statusType = status?.type;
|
|
955
1232
|
|
|
956
1233
|
if (typeof sessionId === 'string' && sessionId.length > 0 && typeof statusType === 'string') {
|
|
@@ -961,7 +1238,17 @@ function deriveSessionActivity(payload) {
|
|
|
961
1238
|
|
|
962
1239
|
if (payload.type === 'message.updated') {
|
|
963
1240
|
const info = payload.properties?.info;
|
|
964
|
-
const sessionId = info?.sessionID;
|
|
1241
|
+
const sessionId = info?.sessionID ?? info?.sessionId ?? payload.properties?.sessionID ?? payload.properties?.sessionId;
|
|
1242
|
+
const role = info?.role;
|
|
1243
|
+
const finish = info?.finish;
|
|
1244
|
+
if (typeof sessionId === 'string' && sessionId.length > 0 && role === 'assistant' && finish === 'stop') {
|
|
1245
|
+
return { sessionId, phase: 'cooldown' };
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
if (payload.type === 'message.part.updated') {
|
|
1250
|
+
const info = payload.properties?.info;
|
|
1251
|
+
const sessionId = info?.sessionID ?? info?.sessionId ?? payload.properties?.sessionID ?? payload.properties?.sessionId;
|
|
965
1252
|
const role = info?.role;
|
|
966
1253
|
const finish = info?.finish;
|
|
967
1254
|
if (typeof sessionId === 'string' && sessionId.length > 0 && role === 'assistant' && finish === 'stop') {
|
|
@@ -970,7 +1257,7 @@ function deriveSessionActivity(payload) {
|
|
|
970
1257
|
}
|
|
971
1258
|
|
|
972
1259
|
if (payload.type === 'session.idle') {
|
|
973
|
-
const sessionId = payload.properties?.sessionID;
|
|
1260
|
+
const sessionId = payload.properties?.sessionID ?? payload.properties?.sessionId;
|
|
974
1261
|
if (typeof sessionId === 'string' && sessionId.length > 0) {
|
|
975
1262
|
return { sessionId, phase: 'idle' };
|
|
976
1263
|
}
|
|
@@ -2072,11 +2359,124 @@ async function main(options = {}) {
|
|
|
2072
2359
|
}
|
|
2073
2360
|
});
|
|
2074
2361
|
|
|
2075
|
-
app.get('/api/event', async (req, res) => {
|
|
2076
|
-
if (!
|
|
2362
|
+
app.get('/api/global/event', async (req, res) => {
|
|
2363
|
+
if (!openCodeApiPrefixDetected) {
|
|
2364
|
+
try {
|
|
2365
|
+
await detectOpenCodeApiPrefix();
|
|
2366
|
+
} catch {
|
|
2367
|
+
// ignore detection failures
|
|
2368
|
+
}
|
|
2369
|
+
}
|
|
2370
|
+
|
|
2371
|
+
let targetUrl;
|
|
2372
|
+
try {
|
|
2373
|
+
const prefix = openCodeApiPrefixDetected ? openCodeApiPrefix : '';
|
|
2374
|
+
targetUrl = new URL(buildOpenCodeUrl('/global/event', prefix));
|
|
2375
|
+
} catch (error) {
|
|
2077
2376
|
return res.status(503).json({ error: 'OpenCode service unavailable' });
|
|
2078
2377
|
}
|
|
2079
2378
|
|
|
2379
|
+
const headers = {
|
|
2380
|
+
Accept: 'text/event-stream',
|
|
2381
|
+
'Cache-Control': 'no-cache',
|
|
2382
|
+
Connection: 'keep-alive'
|
|
2383
|
+
};
|
|
2384
|
+
|
|
2385
|
+
const lastEventId = req.header('Last-Event-ID');
|
|
2386
|
+
if (typeof lastEventId === 'string' && lastEventId.length > 0) {
|
|
2387
|
+
headers['Last-Event-ID'] = lastEventId;
|
|
2388
|
+
}
|
|
2389
|
+
|
|
2390
|
+
const controller = new AbortController();
|
|
2391
|
+
const cleanup = () => {
|
|
2392
|
+
if (!controller.signal.aborted) {
|
|
2393
|
+
controller.abort();
|
|
2394
|
+
}
|
|
2395
|
+
};
|
|
2396
|
+
|
|
2397
|
+
req.on('close', cleanup);
|
|
2398
|
+
req.on('error', cleanup);
|
|
2399
|
+
|
|
2400
|
+
let upstream;
|
|
2401
|
+
try {
|
|
2402
|
+
upstream = await fetch(targetUrl.toString(), {
|
|
2403
|
+
headers,
|
|
2404
|
+
signal: controller.signal,
|
|
2405
|
+
});
|
|
2406
|
+
} catch (error) {
|
|
2407
|
+
return res.status(502).json({ error: 'Failed to connect to OpenCode event stream' });
|
|
2408
|
+
}
|
|
2409
|
+
|
|
2410
|
+
if (!upstream.ok || !upstream.body) {
|
|
2411
|
+
return res.status(502).json({ error: `OpenCode event stream unavailable (${upstream.status})` });
|
|
2412
|
+
}
|
|
2413
|
+
|
|
2414
|
+
res.setHeader('Content-Type', 'text/event-stream');
|
|
2415
|
+
res.setHeader('Cache-Control', 'no-cache');
|
|
2416
|
+
res.setHeader('Connection', 'keep-alive');
|
|
2417
|
+
res.setHeader('X-Accel-Buffering', 'no');
|
|
2418
|
+
|
|
2419
|
+
if (typeof res.flushHeaders === 'function') {
|
|
2420
|
+
res.flushHeaders();
|
|
2421
|
+
}
|
|
2422
|
+
|
|
2423
|
+
const heartbeatInterval = setInterval(() => {
|
|
2424
|
+
writeSseEvent(res, { type: 'openchamber:heartbeat', timestamp: Date.now() });
|
|
2425
|
+
}, 30000);
|
|
2426
|
+
|
|
2427
|
+
const decoder = new TextDecoder();
|
|
2428
|
+
const reader = upstream.body.getReader();
|
|
2429
|
+
let buffer = '';
|
|
2430
|
+
|
|
2431
|
+
const forwardBlock = (block) => {
|
|
2432
|
+
if (!block) return;
|
|
2433
|
+
res.write(`${block}\n\n`);
|
|
2434
|
+
const payload = parseSseDataPayload(block);
|
|
2435
|
+
const activity = deriveSessionActivity(payload);
|
|
2436
|
+
if (activity) {
|
|
2437
|
+
writeSseEvent(res, {
|
|
2438
|
+
type: 'openchamber:session-activity',
|
|
2439
|
+
properties: {
|
|
2440
|
+
sessionId: activity.sessionId,
|
|
2441
|
+
phase: activity.phase,
|
|
2442
|
+
}
|
|
2443
|
+
});
|
|
2444
|
+
}
|
|
2445
|
+
};
|
|
2446
|
+
|
|
2447
|
+
try {
|
|
2448
|
+
while (true) {
|
|
2449
|
+
const { value, done } = await reader.read();
|
|
2450
|
+
if (done) break;
|
|
2451
|
+
buffer += decoder.decode(value, { stream: true }).replace(/\r\n/g, '\n');
|
|
2452
|
+
|
|
2453
|
+
let separatorIndex;
|
|
2454
|
+
while ((separatorIndex = buffer.indexOf('\n\n')) !== -1) {
|
|
2455
|
+
const block = buffer.slice(0, separatorIndex);
|
|
2456
|
+
buffer = buffer.slice(separatorIndex + 2);
|
|
2457
|
+
forwardBlock(block);
|
|
2458
|
+
}
|
|
2459
|
+
}
|
|
2460
|
+
|
|
2461
|
+
if (buffer.trim().length > 0) {
|
|
2462
|
+
forwardBlock(buffer.trim());
|
|
2463
|
+
}
|
|
2464
|
+
} catch (error) {
|
|
2465
|
+
if (!controller.signal.aborted) {
|
|
2466
|
+
console.warn('SSE proxy stream error:', error);
|
|
2467
|
+
}
|
|
2468
|
+
} finally {
|
|
2469
|
+
clearInterval(heartbeatInterval);
|
|
2470
|
+
cleanup();
|
|
2471
|
+
try {
|
|
2472
|
+
res.end();
|
|
2473
|
+
} catch {
|
|
2474
|
+
// ignore
|
|
2475
|
+
}
|
|
2476
|
+
}
|
|
2477
|
+
});
|
|
2478
|
+
|
|
2479
|
+
app.get('/api/event', async (req, res) => {
|
|
2080
2480
|
if (!openCodeApiPrefixDetected) {
|
|
2081
2481
|
try {
|
|
2082
2482
|
await detectOpenCodeApiPrefix();
|
|
@@ -2093,11 +2493,13 @@ async function main(options = {}) {
|
|
|
2093
2493
|
return res.status(503).json({ error: 'OpenCode service unavailable' });
|
|
2094
2494
|
}
|
|
2095
2495
|
|
|
2496
|
+
const headerDirectory = typeof req.get === 'function' ? req.get('x-opencode-directory') : null;
|
|
2096
2497
|
const directoryParam = Array.isArray(req.query.directory)
|
|
2097
2498
|
? req.query.directory[0]
|
|
2098
2499
|
: req.query.directory;
|
|
2099
|
-
|
|
2100
|
-
|
|
2500
|
+
const resolvedDirectory = headerDirectory || directoryParam || null;
|
|
2501
|
+
if (typeof resolvedDirectory === 'string' && resolvedDirectory.trim().length > 0) {
|
|
2502
|
+
targetUrl.searchParams.set('directory', resolvedDirectory.trim());
|
|
2101
2503
|
}
|
|
2102
2504
|
|
|
2103
2505
|
const headers = {
|
|
@@ -2144,6 +2546,10 @@ async function main(options = {}) {
|
|
|
2144
2546
|
res.flushHeaders();
|
|
2145
2547
|
}
|
|
2146
2548
|
|
|
2549
|
+
const heartbeatInterval = setInterval(() => {
|
|
2550
|
+
writeSseEvent(res, { type: 'openchamber:heartbeat', timestamp: Date.now() });
|
|
2551
|
+
}, 30000);
|
|
2552
|
+
|
|
2147
2553
|
const decoder = new TextDecoder();
|
|
2148
2554
|
const reader = upstream.body.getReader();
|
|
2149
2555
|
let buffer = '';
|
|
@@ -2186,6 +2592,7 @@ async function main(options = {}) {
|
|
|
2186
2592
|
console.warn('SSE proxy stream error:', error);
|
|
2187
2593
|
}
|
|
2188
2594
|
} finally {
|
|
2595
|
+
clearInterval(heartbeatInterval);
|
|
2189
2596
|
cleanup();
|
|
2190
2597
|
try {
|
|
2191
2598
|
res.end();
|
|
@@ -2197,7 +2604,7 @@ async function main(options = {}) {
|
|
|
2197
2604
|
|
|
2198
2605
|
app.get('/api/config/settings', async (_req, res) => {
|
|
2199
2606
|
try {
|
|
2200
|
-
const settings = await
|
|
2607
|
+
const settings = await readSettingsFromDiskMigrated();
|
|
2201
2608
|
res.json(formatSettingsResponse(settings));
|
|
2202
2609
|
} catch (error) {
|
|
2203
2610
|
console.error('Failed to load settings:', error);
|
|
@@ -2218,6 +2625,7 @@ async function main(options = {}) {
|
|
|
2218
2625
|
const {
|
|
2219
2626
|
getAgentSources,
|
|
2220
2627
|
getAgentScope,
|
|
2628
|
+
getAgentConfig,
|
|
2221
2629
|
createAgent,
|
|
2222
2630
|
updateAgent,
|
|
2223
2631
|
deleteAgent,
|
|
@@ -2230,16 +2638,23 @@ async function main(options = {}) {
|
|
|
2230
2638
|
COMMAND_SCOPE
|
|
2231
2639
|
} = await import('./lib/opencode-config.js');
|
|
2232
2640
|
|
|
2233
|
-
app.get('/api/config/agents/:name', (req, res) => {
|
|
2641
|
+
app.get('/api/config/agents/:name', async (req, res) => {
|
|
2234
2642
|
try {
|
|
2235
2643
|
const agentName = req.params.name;
|
|
2236
|
-
const
|
|
2237
|
-
|
|
2644
|
+
const { directory, error } = await resolveProjectDirectory(req);
|
|
2645
|
+
if (!directory) {
|
|
2646
|
+
return res.status(400).json({ error });
|
|
2647
|
+
}
|
|
2648
|
+
const sources = getAgentSources(agentName, directory);
|
|
2649
|
+
|
|
2650
|
+
const scope = sources.md.exists
|
|
2651
|
+
? sources.md.scope
|
|
2652
|
+
: (sources.json.exists ? sources.json.scope : null);
|
|
2238
2653
|
|
|
2239
2654
|
res.json({
|
|
2240
2655
|
name: agentName,
|
|
2241
2656
|
sources: sources,
|
|
2242
|
-
scope
|
|
2657
|
+
scope,
|
|
2243
2658
|
isBuiltIn: !sources.md.exists && !sources.json.exists
|
|
2244
2659
|
});
|
|
2245
2660
|
} catch (error) {
|
|
@@ -2248,17 +2663,36 @@ async function main(options = {}) {
|
|
|
2248
2663
|
}
|
|
2249
2664
|
});
|
|
2250
2665
|
|
|
2666
|
+
app.get('/api/config/agents/:name/config', async (req, res) => {
|
|
2667
|
+
try {
|
|
2668
|
+
const agentName = req.params.name;
|
|
2669
|
+
const { directory, error } = await resolveProjectDirectory(req);
|
|
2670
|
+
if (!directory) {
|
|
2671
|
+
return res.status(400).json({ error });
|
|
2672
|
+
}
|
|
2673
|
+
|
|
2674
|
+
const configInfo = getAgentConfig(agentName, directory);
|
|
2675
|
+
res.json(configInfo);
|
|
2676
|
+
} catch (error) {
|
|
2677
|
+
console.error('Failed to get agent config:', error);
|
|
2678
|
+
res.status(500).json({ error: 'Failed to get agent configuration' });
|
|
2679
|
+
}
|
|
2680
|
+
});
|
|
2681
|
+
|
|
2251
2682
|
app.post('/api/config/agents/:name', async (req, res) => {
|
|
2252
2683
|
try {
|
|
2253
2684
|
const agentName = req.params.name;
|
|
2254
2685
|
const { scope, ...config } = req.body;
|
|
2255
|
-
const
|
|
2686
|
+
const { directory, error } = await resolveProjectDirectory(req);
|
|
2687
|
+
if (!directory) {
|
|
2688
|
+
return res.status(400).json({ error });
|
|
2689
|
+
}
|
|
2256
2690
|
|
|
2257
2691
|
console.log('[Server] Creating agent:', agentName);
|
|
2258
2692
|
console.log('[Server] Config received:', JSON.stringify(config, null, 2));
|
|
2259
|
-
console.log('[Server] Scope:', scope, 'Working directory:',
|
|
2693
|
+
console.log('[Server] Scope:', scope, 'Working directory:', directory);
|
|
2260
2694
|
|
|
2261
|
-
createAgent(agentName, config,
|
|
2695
|
+
createAgent(agentName, config, directory, scope);
|
|
2262
2696
|
await refreshOpenCodeAfterConfigChange('agent creation', {
|
|
2263
2697
|
agentName
|
|
2264
2698
|
});
|
|
@@ -2279,13 +2713,16 @@ async function main(options = {}) {
|
|
|
2279
2713
|
try {
|
|
2280
2714
|
const agentName = req.params.name;
|
|
2281
2715
|
const updates = req.body;
|
|
2282
|
-
const
|
|
2716
|
+
const { directory, error } = await resolveProjectDirectory(req);
|
|
2717
|
+
if (!directory) {
|
|
2718
|
+
return res.status(400).json({ error });
|
|
2719
|
+
}
|
|
2283
2720
|
|
|
2284
2721
|
console.log(`[Server] Updating agent: ${agentName}`);
|
|
2285
2722
|
console.log('[Server] Updates:', JSON.stringify(updates, null, 2));
|
|
2286
|
-
console.log('[Server] Working directory:',
|
|
2723
|
+
console.log('[Server] Working directory:', directory);
|
|
2287
2724
|
|
|
2288
|
-
updateAgent(agentName, updates,
|
|
2725
|
+
updateAgent(agentName, updates, directory);
|
|
2289
2726
|
await refreshOpenCodeAfterConfigChange('agent update');
|
|
2290
2727
|
|
|
2291
2728
|
console.log(`[Server] Agent ${agentName} updated successfully`);
|
|
@@ -2306,9 +2743,12 @@ async function main(options = {}) {
|
|
|
2306
2743
|
app.delete('/api/config/agents/:name', async (req, res) => {
|
|
2307
2744
|
try {
|
|
2308
2745
|
const agentName = req.params.name;
|
|
2309
|
-
const
|
|
2746
|
+
const { directory, error } = await resolveProjectDirectory(req);
|
|
2747
|
+
if (!directory) {
|
|
2748
|
+
return res.status(400).json({ error });
|
|
2749
|
+
}
|
|
2310
2750
|
|
|
2311
|
-
deleteAgent(agentName,
|
|
2751
|
+
deleteAgent(agentName, directory);
|
|
2312
2752
|
await refreshOpenCodeAfterConfigChange('agent deletion');
|
|
2313
2753
|
|
|
2314
2754
|
res.json({
|
|
@@ -2323,16 +2763,23 @@ async function main(options = {}) {
|
|
|
2323
2763
|
}
|
|
2324
2764
|
});
|
|
2325
2765
|
|
|
2326
|
-
app.get('/api/config/commands/:name', (req, res) => {
|
|
2766
|
+
app.get('/api/config/commands/:name', async (req, res) => {
|
|
2327
2767
|
try {
|
|
2328
2768
|
const commandName = req.params.name;
|
|
2329
|
-
const
|
|
2330
|
-
|
|
2769
|
+
const { directory, error } = await resolveProjectDirectory(req);
|
|
2770
|
+
if (!directory) {
|
|
2771
|
+
return res.status(400).json({ error });
|
|
2772
|
+
}
|
|
2773
|
+
const sources = getCommandSources(commandName, directory);
|
|
2774
|
+
|
|
2775
|
+
const scope = sources.md.exists
|
|
2776
|
+
? sources.md.scope
|
|
2777
|
+
: (sources.json.exists ? sources.json.scope : null);
|
|
2331
2778
|
|
|
2332
2779
|
res.json({
|
|
2333
2780
|
name: commandName,
|
|
2334
2781
|
sources: sources,
|
|
2335
|
-
scope
|
|
2782
|
+
scope,
|
|
2336
2783
|
isBuiltIn: !sources.md.exists && !sources.json.exists
|
|
2337
2784
|
});
|
|
2338
2785
|
} catch (error) {
|
|
@@ -2345,13 +2792,16 @@ async function main(options = {}) {
|
|
|
2345
2792
|
try {
|
|
2346
2793
|
const commandName = req.params.name;
|
|
2347
2794
|
const { scope, ...config } = req.body;
|
|
2348
|
-
const
|
|
2795
|
+
const { directory, error } = await resolveProjectDirectory(req);
|
|
2796
|
+
if (!directory) {
|
|
2797
|
+
return res.status(400).json({ error });
|
|
2798
|
+
}
|
|
2349
2799
|
|
|
2350
2800
|
console.log('[Server] Creating command:', commandName);
|
|
2351
2801
|
console.log('[Server] Config received:', JSON.stringify(config, null, 2));
|
|
2352
|
-
console.log('[Server] Scope:', scope, 'Working directory:',
|
|
2802
|
+
console.log('[Server] Scope:', scope, 'Working directory:', directory);
|
|
2353
2803
|
|
|
2354
|
-
createCommand(commandName, config,
|
|
2804
|
+
createCommand(commandName, config, directory, scope);
|
|
2355
2805
|
await refreshOpenCodeAfterConfigChange('command creation', {
|
|
2356
2806
|
commandName
|
|
2357
2807
|
});
|
|
@@ -2372,13 +2822,16 @@ async function main(options = {}) {
|
|
|
2372
2822
|
try {
|
|
2373
2823
|
const commandName = req.params.name;
|
|
2374
2824
|
const updates = req.body;
|
|
2375
|
-
const
|
|
2825
|
+
const { directory, error } = await resolveProjectDirectory(req);
|
|
2826
|
+
if (!directory) {
|
|
2827
|
+
return res.status(400).json({ error });
|
|
2828
|
+
}
|
|
2376
2829
|
|
|
2377
2830
|
console.log(`[Server] Updating command: ${commandName}`);
|
|
2378
2831
|
console.log('[Server] Updates:', JSON.stringify(updates, null, 2));
|
|
2379
|
-
console.log('[Server] Working directory:',
|
|
2832
|
+
console.log('[Server] Working directory:', directory);
|
|
2380
2833
|
|
|
2381
|
-
updateCommand(commandName, updates,
|
|
2834
|
+
updateCommand(commandName, updates, directory);
|
|
2382
2835
|
await refreshOpenCodeAfterConfigChange('command update');
|
|
2383
2836
|
|
|
2384
2837
|
console.log(`[Server] Command ${commandName} updated successfully`);
|
|
@@ -2399,9 +2852,12 @@ async function main(options = {}) {
|
|
|
2399
2852
|
app.delete('/api/config/commands/:name', async (req, res) => {
|
|
2400
2853
|
try {
|
|
2401
2854
|
const commandName = req.params.name;
|
|
2402
|
-
const
|
|
2855
|
+
const { directory, error } = await resolveProjectDirectory(req);
|
|
2856
|
+
if (!directory) {
|
|
2857
|
+
return res.status(400).json({ error });
|
|
2858
|
+
}
|
|
2403
2859
|
|
|
2404
|
-
deleteCommand(commandName,
|
|
2860
|
+
deleteCommand(commandName, directory);
|
|
2405
2861
|
await refreshOpenCodeAfterConfigChange('command deletion');
|
|
2406
2862
|
|
|
2407
2863
|
res.json({
|
|
@@ -2432,14 +2888,17 @@ async function main(options = {}) {
|
|
|
2432
2888
|
} = await import('./lib/opencode-config.js');
|
|
2433
2889
|
|
|
2434
2890
|
// List all discovered skills
|
|
2435
|
-
app.get('/api/config/skills', (req, res) => {
|
|
2891
|
+
app.get('/api/config/skills', async (req, res) => {
|
|
2436
2892
|
try {
|
|
2437
|
-
const
|
|
2438
|
-
|
|
2893
|
+
const { directory, error } = await resolveProjectDirectory(req);
|
|
2894
|
+
if (!directory) {
|
|
2895
|
+
return res.status(400).json({ error });
|
|
2896
|
+
}
|
|
2897
|
+
const skills = discoverSkills(directory);
|
|
2439
2898
|
|
|
2440
2899
|
// Enrich with full sources info
|
|
2441
2900
|
const enrichedSkills = skills.map(skill => {
|
|
2442
|
-
const sources = getSkillSources(skill.name,
|
|
2901
|
+
const sources = getSkillSources(skill.name, directory);
|
|
2443
2902
|
return {
|
|
2444
2903
|
...skill,
|
|
2445
2904
|
sources
|
|
@@ -2489,7 +2948,10 @@ async function main(options = {}) {
|
|
|
2489
2948
|
|
|
2490
2949
|
app.get('/api/config/skills/catalog', async (req, res) => {
|
|
2491
2950
|
try {
|
|
2492
|
-
const
|
|
2951
|
+
const { directory, error } = await resolveProjectDirectory(req);
|
|
2952
|
+
if (!directory) {
|
|
2953
|
+
return res.status(400).json({ error });
|
|
2954
|
+
}
|
|
2493
2955
|
const refresh = String(req.query.refresh || '').toLowerCase() === 'true';
|
|
2494
2956
|
|
|
2495
2957
|
const curatedSources = getCuratedSkillsSources();
|
|
@@ -2507,7 +2969,7 @@ async function main(options = {}) {
|
|
|
2507
2969
|
|
|
2508
2970
|
const sources = [...curatedSources, ...customSources];
|
|
2509
2971
|
|
|
2510
|
-
const discovered = discoverSkills(
|
|
2972
|
+
const discovered = discoverSkills(directory);
|
|
2511
2973
|
const installedByName = new Map(discovered.map((s) => [s.name, s]));
|
|
2512
2974
|
|
|
2513
2975
|
const itemsBySource = {};
|
|
@@ -2611,12 +3073,16 @@ async function main(options = {}) {
|
|
|
2611
3073
|
conflictDecisions,
|
|
2612
3074
|
} = req.body || {};
|
|
2613
3075
|
|
|
2614
|
-
|
|
2615
|
-
if (scope === 'project'
|
|
2616
|
-
|
|
2617
|
-
|
|
2618
|
-
|
|
2619
|
-
|
|
3076
|
+
let workingDirectory = null;
|
|
3077
|
+
if (scope === 'project') {
|
|
3078
|
+
const resolved = await resolveProjectDirectory(req);
|
|
3079
|
+
if (!resolved.directory) {
|
|
3080
|
+
return res.status(400).json({
|
|
3081
|
+
ok: false,
|
|
3082
|
+
error: { kind: 'invalidSource', message: resolved.error || 'Project installs require a directory parameter' },
|
|
3083
|
+
});
|
|
3084
|
+
}
|
|
3085
|
+
workingDirectory = resolved.directory;
|
|
2620
3086
|
}
|
|
2621
3087
|
const identity = resolveGitIdentity(gitIdentityId);
|
|
2622
3088
|
|
|
@@ -2658,11 +3124,14 @@ async function main(options = {}) {
|
|
|
2658
3124
|
});
|
|
2659
3125
|
|
|
2660
3126
|
// Get single skill sources
|
|
2661
|
-
app.get('/api/config/skills/:name', (req, res) => {
|
|
3127
|
+
app.get('/api/config/skills/:name', async (req, res) => {
|
|
2662
3128
|
try {
|
|
2663
3129
|
const skillName = req.params.name;
|
|
2664
|
-
const
|
|
2665
|
-
|
|
3130
|
+
const { directory, error } = await resolveProjectDirectory(req);
|
|
3131
|
+
if (!directory) {
|
|
3132
|
+
return res.status(400).json({ error });
|
|
3133
|
+
}
|
|
3134
|
+
const sources = getSkillSources(skillName, directory);
|
|
2666
3135
|
|
|
2667
3136
|
res.json({
|
|
2668
3137
|
name: skillName,
|
|
@@ -2678,13 +3147,16 @@ async function main(options = {}) {
|
|
|
2678
3147
|
});
|
|
2679
3148
|
|
|
2680
3149
|
// Get skill supporting file content
|
|
2681
|
-
app.get('/api/config/skills/:name/files/*filePath', (req, res) => {
|
|
3150
|
+
app.get('/api/config/skills/:name/files/*filePath', async (req, res) => {
|
|
2682
3151
|
try {
|
|
2683
3152
|
const skillName = req.params.name;
|
|
2684
3153
|
const filePath = decodeURIComponent(req.params.filePath); // Decode URL-encoded path
|
|
2685
|
-
const
|
|
3154
|
+
const { directory, error } = await resolveProjectDirectory(req);
|
|
3155
|
+
if (!directory) {
|
|
3156
|
+
return res.status(400).json({ error });
|
|
3157
|
+
}
|
|
2686
3158
|
|
|
2687
|
-
const sources = getSkillSources(skillName,
|
|
3159
|
+
const sources = getSkillSources(skillName, directory);
|
|
2688
3160
|
if (!sources.md.exists || !sources.md.dir) {
|
|
2689
3161
|
return res.status(404).json({ error: 'Skill not found' });
|
|
2690
3162
|
}
|
|
@@ -2706,12 +3178,15 @@ async function main(options = {}) {
|
|
|
2706
3178
|
try {
|
|
2707
3179
|
const skillName = req.params.name;
|
|
2708
3180
|
const { scope, ...config } = req.body;
|
|
2709
|
-
const
|
|
3181
|
+
const { directory, error } = await resolveProjectDirectory(req);
|
|
3182
|
+
if (!directory) {
|
|
3183
|
+
return res.status(400).json({ error });
|
|
3184
|
+
}
|
|
2710
3185
|
|
|
2711
3186
|
console.log('[Server] Creating skill:', skillName);
|
|
2712
|
-
console.log('[Server] Scope:', scope, 'Working directory:',
|
|
3187
|
+
console.log('[Server] Scope:', scope, 'Working directory:', directory);
|
|
2713
3188
|
|
|
2714
|
-
createSkill(skillName, config,
|
|
3189
|
+
createSkill(skillName, config, directory, scope);
|
|
2715
3190
|
// Skills are just files - OpenCode loads them on-demand, no restart needed
|
|
2716
3191
|
|
|
2717
3192
|
res.json({
|
|
@@ -2730,12 +3205,15 @@ async function main(options = {}) {
|
|
|
2730
3205
|
try {
|
|
2731
3206
|
const skillName = req.params.name;
|
|
2732
3207
|
const updates = req.body;
|
|
2733
|
-
const
|
|
3208
|
+
const { directory, error } = await resolveProjectDirectory(req);
|
|
3209
|
+
if (!directory) {
|
|
3210
|
+
return res.status(400).json({ error });
|
|
3211
|
+
}
|
|
2734
3212
|
|
|
2735
3213
|
console.log(`[Server] Updating skill: ${skillName}`);
|
|
2736
|
-
console.log('[Server] Working directory:',
|
|
3214
|
+
console.log('[Server] Working directory:', directory);
|
|
2737
3215
|
|
|
2738
|
-
updateSkill(skillName, updates,
|
|
3216
|
+
updateSkill(skillName, updates, directory);
|
|
2739
3217
|
// Skills are just files - OpenCode loads them on-demand, no restart needed
|
|
2740
3218
|
|
|
2741
3219
|
res.json({
|
|
@@ -2755,9 +3233,12 @@ async function main(options = {}) {
|
|
|
2755
3233
|
const skillName = req.params.name;
|
|
2756
3234
|
const filePath = decodeURIComponent(req.params.filePath); // Decode URL-encoded path
|
|
2757
3235
|
const { content } = req.body;
|
|
2758
|
-
const
|
|
3236
|
+
const { directory, error } = await resolveProjectDirectory(req);
|
|
3237
|
+
if (!directory) {
|
|
3238
|
+
return res.status(400).json({ error });
|
|
3239
|
+
}
|
|
2759
3240
|
|
|
2760
|
-
const sources = getSkillSources(skillName,
|
|
3241
|
+
const sources = getSkillSources(skillName, directory);
|
|
2761
3242
|
if (!sources.md.exists || !sources.md.dir) {
|
|
2762
3243
|
return res.status(404).json({ error: 'Skill not found' });
|
|
2763
3244
|
}
|
|
@@ -2779,9 +3260,12 @@ async function main(options = {}) {
|
|
|
2779
3260
|
try {
|
|
2780
3261
|
const skillName = req.params.name;
|
|
2781
3262
|
const filePath = decodeURIComponent(req.params.filePath); // Decode URL-encoded path
|
|
2782
|
-
const
|
|
3263
|
+
const { directory, error } = await resolveProjectDirectory(req);
|
|
3264
|
+
if (!directory) {
|
|
3265
|
+
return res.status(400).json({ error });
|
|
3266
|
+
}
|
|
2783
3267
|
|
|
2784
|
-
const sources = getSkillSources(skillName,
|
|
3268
|
+
const sources = getSkillSources(skillName, directory);
|
|
2785
3269
|
if (!sources.md.exists || !sources.md.dir) {
|
|
2786
3270
|
return res.status(404).json({ error: 'Skill not found' });
|
|
2787
3271
|
}
|
|
@@ -2802,9 +3286,12 @@ async function main(options = {}) {
|
|
|
2802
3286
|
app.delete('/api/config/skills/:name', async (req, res) => {
|
|
2803
3287
|
try {
|
|
2804
3288
|
const skillName = req.params.name;
|
|
2805
|
-
const
|
|
3289
|
+
const { directory, error } = await resolveProjectDirectory(req);
|
|
3290
|
+
if (!directory) {
|
|
3291
|
+
return res.status(400).json({ error });
|
|
3292
|
+
}
|
|
2806
3293
|
|
|
2807
|
-
deleteSkill(skillName,
|
|
3294
|
+
deleteSkill(skillName, directory);
|
|
2808
3295
|
// Skills are just files - OpenCode loads them on-demand, no restart needed
|
|
2809
3296
|
|
|
2810
3297
|
res.json({
|
|
@@ -3331,6 +3818,30 @@ async function main(options = {}) {
|
|
|
3331
3818
|
}
|
|
3332
3819
|
});
|
|
3333
3820
|
|
|
3821
|
+
|
|
3822
|
+
app.put('/api/git/branches/rename', async (req, res) => {
|
|
3823
|
+
const { renameBranch } = await getGitLibraries();
|
|
3824
|
+
try {
|
|
3825
|
+
const directory = req.query.directory;
|
|
3826
|
+
if (!directory) {
|
|
3827
|
+
return res.status(400).json({ error: 'directory parameter is required' });
|
|
3828
|
+
}
|
|
3829
|
+
|
|
3830
|
+
const { oldName, newName } = req.body;
|
|
3831
|
+
if (!oldName) {
|
|
3832
|
+
return res.status(400).json({ error: 'oldName is required' });
|
|
3833
|
+
}
|
|
3834
|
+
if (!newName) {
|
|
3835
|
+
return res.status(400).json({ error: 'newName is required' });
|
|
3836
|
+
}
|
|
3837
|
+
|
|
3838
|
+
const result = await renameBranch(directory, oldName, newName);
|
|
3839
|
+
res.json(result);
|
|
3840
|
+
} catch (error) {
|
|
3841
|
+
console.error('Failed to rename branch:', error);
|
|
3842
|
+
res.status(500).json({ error: error.message || 'Failed to rename branch' });
|
|
3843
|
+
}
|
|
3844
|
+
});
|
|
3334
3845
|
app.delete('/api/git/remote-branches', async (req, res) => {
|
|
3335
3846
|
const { deleteRemoteBranch } = await getGitLibraries();
|
|
3336
3847
|
try {
|
|
@@ -3543,47 +4054,326 @@ async function main(options = {}) {
|
|
|
3543
4054
|
}
|
|
3544
4055
|
});
|
|
3545
4056
|
|
|
3546
|
-
|
|
4057
|
+
// Read file contents
|
|
4058
|
+
app.get('/api/fs/read', async (req, res) => {
|
|
4059
|
+
const filePath = typeof req.query.path === 'string' ? req.query.path.trim() : '';
|
|
4060
|
+
if (!filePath) {
|
|
4061
|
+
return res.status(400).json({ error: 'Path is required' });
|
|
4062
|
+
}
|
|
4063
|
+
|
|
3547
4064
|
try {
|
|
3548
|
-
const
|
|
3549
|
-
if (
|
|
3550
|
-
return res.status(400).json({ error: '
|
|
4065
|
+
const resolvedPath = path.resolve(normalizeDirectoryPath(filePath));
|
|
4066
|
+
if (resolvedPath.includes('..')) {
|
|
4067
|
+
return res.status(400).json({ error: 'Invalid path: path traversal not allowed' });
|
|
4068
|
+
}
|
|
4069
|
+
|
|
4070
|
+
const stats = await fsPromises.stat(resolvedPath);
|
|
4071
|
+
if (!stats.isFile()) {
|
|
4072
|
+
return res.status(400).json({ error: 'Specified path is not a file' });
|
|
4073
|
+
}
|
|
4074
|
+
|
|
4075
|
+
const content = await fsPromises.readFile(resolvedPath, 'utf8');
|
|
4076
|
+
res.type('text/plain').send(content);
|
|
4077
|
+
} catch (error) {
|
|
4078
|
+
const err = error;
|
|
4079
|
+
if (err && typeof err === 'object' && err.code === 'ENOENT') {
|
|
4080
|
+
return res.status(404).json({ error: 'File not found' });
|
|
4081
|
+
}
|
|
4082
|
+
if (err && typeof err === 'object' && err.code === 'EACCES') {
|
|
4083
|
+
return res.status(403).json({ error: 'Access to file denied' });
|
|
4084
|
+
}
|
|
4085
|
+
console.error('Failed to read file:', error);
|
|
4086
|
+
res.status(500).json({ error: (error && error.message) || 'Failed to read file' });
|
|
4087
|
+
}
|
|
4088
|
+
});
|
|
4089
|
+
|
|
4090
|
+
// Write file contents
|
|
4091
|
+
app.post('/api/fs/write', async (req, res) => {
|
|
4092
|
+
const { path: filePath, content } = req.body || {};
|
|
4093
|
+
if (!filePath || typeof filePath !== 'string') {
|
|
4094
|
+
return res.status(400).json({ error: 'Path is required' });
|
|
4095
|
+
}
|
|
4096
|
+
if (typeof content !== 'string') {
|
|
4097
|
+
return res.status(400).json({ error: 'Content is required' });
|
|
4098
|
+
}
|
|
4099
|
+
|
|
4100
|
+
try {
|
|
4101
|
+
const resolvedPath = path.resolve(normalizeDirectoryPath(filePath));
|
|
4102
|
+
if (resolvedPath.includes('..')) {
|
|
4103
|
+
return res.status(400).json({ error: 'Invalid path: path traversal not allowed' });
|
|
4104
|
+
}
|
|
4105
|
+
|
|
4106
|
+
// Ensure parent directory exists
|
|
4107
|
+
await fsPromises.mkdir(path.dirname(resolvedPath), { recursive: true });
|
|
4108
|
+
await fsPromises.writeFile(resolvedPath, content, 'utf8');
|
|
4109
|
+
res.json({ success: true, path: resolvedPath });
|
|
4110
|
+
} catch (error) {
|
|
4111
|
+
const err = error;
|
|
4112
|
+
if (err && typeof err === 'object' && err.code === 'EACCES') {
|
|
4113
|
+
return res.status(403).json({ error: 'Access denied' });
|
|
4114
|
+
}
|
|
4115
|
+
console.error('Failed to write file:', error);
|
|
4116
|
+
res.status(500).json({ error: (error && error.message) || 'Failed to write file' });
|
|
4117
|
+
}
|
|
4118
|
+
});
|
|
4119
|
+
|
|
4120
|
+
// Execute shell commands in a directory (for worktree setup)
|
|
4121
|
+
// NOTE: This route supports background execution to avoid tying up browser connections.
|
|
4122
|
+
const execJobs = new Map();
|
|
4123
|
+
const EXEC_JOB_TTL_MS = 30 * 60 * 1000;
|
|
4124
|
+
const COMMAND_TIMEOUT_MS = 60000;
|
|
4125
|
+
|
|
4126
|
+
const pruneExecJobs = () => {
|
|
4127
|
+
const now = Date.now();
|
|
4128
|
+
for (const [jobId, job] of execJobs.entries()) {
|
|
4129
|
+
if (!job || typeof job !== 'object') {
|
|
4130
|
+
execJobs.delete(jobId);
|
|
4131
|
+
continue;
|
|
4132
|
+
}
|
|
4133
|
+
const updatedAt = typeof job.updatedAt === 'number' ? job.updatedAt : 0;
|
|
4134
|
+
if (updatedAt && now - updatedAt > EXEC_JOB_TTL_MS) {
|
|
4135
|
+
execJobs.delete(jobId);
|
|
4136
|
+
}
|
|
4137
|
+
}
|
|
4138
|
+
};
|
|
4139
|
+
|
|
4140
|
+
const runCommandInDirectory = (shell, shellFlag, command, resolvedCwd) => {
|
|
4141
|
+
return new Promise((resolve) => {
|
|
4142
|
+
let stdout = '';
|
|
4143
|
+
let stderr = '';
|
|
4144
|
+
let timedOut = false;
|
|
4145
|
+
|
|
4146
|
+
const child = spawn(shell, [shellFlag, command], {
|
|
4147
|
+
cwd: resolvedCwd,
|
|
4148
|
+
env: process.env,
|
|
4149
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
4150
|
+
});
|
|
4151
|
+
|
|
4152
|
+
const timeout = setTimeout(() => {
|
|
4153
|
+
timedOut = true;
|
|
4154
|
+
try {
|
|
4155
|
+
child.kill('SIGKILL');
|
|
4156
|
+
} catch {
|
|
4157
|
+
// ignore
|
|
4158
|
+
}
|
|
4159
|
+
}, COMMAND_TIMEOUT_MS);
|
|
4160
|
+
|
|
4161
|
+
child.stdout?.on('data', (chunk) => {
|
|
4162
|
+
stdout += chunk.toString();
|
|
4163
|
+
});
|
|
4164
|
+
|
|
4165
|
+
child.stderr?.on('data', (chunk) => {
|
|
4166
|
+
stderr += chunk.toString();
|
|
4167
|
+
});
|
|
4168
|
+
|
|
4169
|
+
child.on('error', (error) => {
|
|
4170
|
+
clearTimeout(timeout);
|
|
4171
|
+
resolve({
|
|
4172
|
+
command,
|
|
4173
|
+
success: false,
|
|
4174
|
+
exitCode: undefined,
|
|
4175
|
+
stdout: stdout.trim(),
|
|
4176
|
+
stderr: stderr.trim(),
|
|
4177
|
+
error: (error && error.message) || 'Command execution failed',
|
|
4178
|
+
});
|
|
4179
|
+
});
|
|
4180
|
+
|
|
4181
|
+
child.on('close', (code, signal) => {
|
|
4182
|
+
clearTimeout(timeout);
|
|
4183
|
+
const exitCode = typeof code === 'number' ? code : undefined;
|
|
4184
|
+
const base = {
|
|
4185
|
+
command,
|
|
4186
|
+
success: exitCode === 0 && !timedOut,
|
|
4187
|
+
exitCode,
|
|
4188
|
+
stdout: stdout.trim(),
|
|
4189
|
+
stderr: stderr.trim(),
|
|
4190
|
+
};
|
|
4191
|
+
|
|
4192
|
+
if (timedOut) {
|
|
4193
|
+
resolve({
|
|
4194
|
+
...base,
|
|
4195
|
+
success: false,
|
|
4196
|
+
error: `Command timed out after ${COMMAND_TIMEOUT_MS}ms` + (signal ? ` (${signal})` : ''),
|
|
4197
|
+
});
|
|
4198
|
+
return;
|
|
4199
|
+
}
|
|
4200
|
+
|
|
4201
|
+
resolve(base);
|
|
4202
|
+
});
|
|
4203
|
+
});
|
|
4204
|
+
};
|
|
4205
|
+
|
|
4206
|
+
const runExecJob = async (job) => {
|
|
4207
|
+
job.status = 'running';
|
|
4208
|
+
job.updatedAt = Date.now();
|
|
4209
|
+
|
|
4210
|
+
const results = [];
|
|
4211
|
+
|
|
4212
|
+
for (const command of job.commands) {
|
|
4213
|
+
if (typeof command !== 'string' || !command.trim()) {
|
|
4214
|
+
results.push({ command, success: false, error: 'Invalid command' });
|
|
4215
|
+
continue;
|
|
3551
4216
|
}
|
|
3552
4217
|
|
|
3553
|
-
const resolvedPath = path.resolve(normalizeDirectoryPath(requestedPath));
|
|
3554
|
-
let stats;
|
|
3555
4218
|
try {
|
|
3556
|
-
|
|
4219
|
+
const result = await runCommandInDirectory(job.shell, job.shellFlag, command, job.resolvedCwd);
|
|
4220
|
+
results.push(result);
|
|
3557
4221
|
} catch (error) {
|
|
3558
|
-
|
|
3559
|
-
|
|
3560
|
-
|
|
3561
|
-
|
|
3562
|
-
|
|
3563
|
-
if (err.code === 'EACCES') {
|
|
3564
|
-
return res.status(403).json({ error: 'Access to directory denied' });
|
|
3565
|
-
}
|
|
3566
|
-
}
|
|
3567
|
-
throw error;
|
|
4222
|
+
results.push({
|
|
4223
|
+
command,
|
|
4224
|
+
success: false,
|
|
4225
|
+
error: (error && error.message) || 'Command execution failed',
|
|
4226
|
+
});
|
|
3568
4227
|
}
|
|
3569
4228
|
|
|
4229
|
+
job.results = results;
|
|
4230
|
+
job.updatedAt = Date.now();
|
|
4231
|
+
}
|
|
4232
|
+
|
|
4233
|
+
job.results = results;
|
|
4234
|
+
job.success = results.every((r) => r.success);
|
|
4235
|
+
job.status = 'done';
|
|
4236
|
+
job.finishedAt = Date.now();
|
|
4237
|
+
job.updatedAt = Date.now();
|
|
4238
|
+
};
|
|
4239
|
+
|
|
4240
|
+
app.post('/api/fs/exec', async (req, res) => {
|
|
4241
|
+
const { commands, cwd, background } = req.body || {};
|
|
4242
|
+
if (!Array.isArray(commands) || commands.length === 0) {
|
|
4243
|
+
return res.status(400).json({ error: 'Commands array is required' });
|
|
4244
|
+
}
|
|
4245
|
+
if (!cwd || typeof cwd !== 'string') {
|
|
4246
|
+
return res.status(400).json({ error: 'Working directory (cwd) is required' });
|
|
4247
|
+
}
|
|
4248
|
+
|
|
4249
|
+
pruneExecJobs();
|
|
4250
|
+
|
|
4251
|
+
try {
|
|
4252
|
+
const resolvedCwd = path.resolve(normalizeDirectoryPath(cwd));
|
|
4253
|
+
const stats = await fsPromises.stat(resolvedCwd);
|
|
3570
4254
|
if (!stats.isDirectory()) {
|
|
3571
|
-
return res.status(400).json({ error: 'Specified
|
|
4255
|
+
return res.status(400).json({ error: 'Specified cwd is not a directory' });
|
|
3572
4256
|
}
|
|
3573
4257
|
|
|
3574
|
-
|
|
3575
|
-
|
|
4258
|
+
const shell = process.env.SHELL || (process.platform === 'win32' ? 'cmd.exe' : '/bin/sh');
|
|
4259
|
+
const shellFlag = process.platform === 'win32' ? '/c' : '-c';
|
|
4260
|
+
|
|
4261
|
+
const jobId = crypto.randomUUID();
|
|
4262
|
+
const job = {
|
|
4263
|
+
jobId,
|
|
4264
|
+
status: 'queued',
|
|
4265
|
+
success: null,
|
|
4266
|
+
commands,
|
|
4267
|
+
resolvedCwd,
|
|
4268
|
+
shell,
|
|
4269
|
+
shellFlag,
|
|
4270
|
+
results: [],
|
|
4271
|
+
startedAt: Date.now(),
|
|
4272
|
+
finishedAt: null,
|
|
4273
|
+
updatedAt: Date.now(),
|
|
4274
|
+
};
|
|
4275
|
+
|
|
4276
|
+
execJobs.set(jobId, job);
|
|
4277
|
+
|
|
4278
|
+
const isBackground = background === true;
|
|
4279
|
+
if (isBackground) {
|
|
4280
|
+
void runExecJob(job).catch((error) => {
|
|
4281
|
+
job.status = 'done';
|
|
4282
|
+
job.success = false;
|
|
4283
|
+
job.results = Array.isArray(job.results) ? job.results : [];
|
|
4284
|
+
job.results.push({
|
|
4285
|
+
command: '',
|
|
4286
|
+
success: false,
|
|
4287
|
+
error: (error && error.message) || 'Command execution failed',
|
|
4288
|
+
});
|
|
4289
|
+
job.finishedAt = Date.now();
|
|
4290
|
+
job.updatedAt = Date.now();
|
|
4291
|
+
});
|
|
4292
|
+
|
|
4293
|
+
return res.status(202).json({
|
|
4294
|
+
jobId,
|
|
4295
|
+
status: 'running',
|
|
4296
|
+
});
|
|
3576
4297
|
}
|
|
3577
4298
|
|
|
3578
|
-
|
|
3579
|
-
|
|
4299
|
+
await runExecJob(job);
|
|
4300
|
+
res.json({
|
|
4301
|
+
jobId,
|
|
4302
|
+
status: job.status,
|
|
4303
|
+
success: job.success === true,
|
|
4304
|
+
results: job.results,
|
|
4305
|
+
});
|
|
4306
|
+
} catch (error) {
|
|
4307
|
+
console.error('Failed to execute commands:', error);
|
|
4308
|
+
res.status(500).json({ error: (error && error.message) || 'Failed to execute commands' });
|
|
4309
|
+
}
|
|
4310
|
+
});
|
|
4311
|
+
|
|
4312
|
+
app.get('/api/fs/exec/:jobId', (req, res) => {
|
|
4313
|
+
const jobId = typeof req.params?.jobId === 'string' ? req.params.jobId : '';
|
|
4314
|
+
if (!jobId) {
|
|
4315
|
+
return res.status(400).json({ error: 'Job id is required' });
|
|
4316
|
+
}
|
|
4317
|
+
|
|
4318
|
+
pruneExecJobs();
|
|
4319
|
+
|
|
4320
|
+
const job = execJobs.get(jobId);
|
|
4321
|
+
if (!job) {
|
|
4322
|
+
return res.status(404).json({ error: 'Job not found' });
|
|
4323
|
+
}
|
|
4324
|
+
|
|
4325
|
+
job.updatedAt = Date.now();
|
|
4326
|
+
|
|
4327
|
+
return res.json({
|
|
4328
|
+
jobId: job.jobId,
|
|
4329
|
+
status: job.status,
|
|
4330
|
+
success: job.success === true,
|
|
4331
|
+
results: Array.isArray(job.results) ? job.results : [],
|
|
4332
|
+
});
|
|
4333
|
+
});
|
|
4334
|
+
|
|
4335
|
+
app.post('/api/opencode/directory', async (req, res) => {
|
|
4336
|
+
try {
|
|
4337
|
+
const requestedPath = typeof req.body?.path === 'string' ? req.body.path.trim() : '';
|
|
4338
|
+
if (!requestedPath) {
|
|
4339
|
+
return res.status(400).json({ error: 'Path is required' });
|
|
4340
|
+
}
|
|
3580
4341
|
|
|
3581
|
-
await
|
|
4342
|
+
const validated = await validateDirectoryPath(requestedPath);
|
|
4343
|
+
if (!validated.ok) {
|
|
4344
|
+
return res.status(400).json({ error: validated.error });
|
|
4345
|
+
}
|
|
4346
|
+
|
|
4347
|
+
const resolvedPath = validated.directory;
|
|
4348
|
+
const currentSettings = await readSettingsFromDisk();
|
|
4349
|
+
const existingProjects = sanitizeProjects(currentSettings.projects) || [];
|
|
4350
|
+
const existing = existingProjects.find((project) => project.path === resolvedPath) || null;
|
|
4351
|
+
|
|
4352
|
+
const nextProjects = existing
|
|
4353
|
+
? existingProjects
|
|
4354
|
+
: [
|
|
4355
|
+
...existingProjects,
|
|
4356
|
+
{
|
|
4357
|
+
id: crypto.randomUUID(),
|
|
4358
|
+
path: resolvedPath,
|
|
4359
|
+
addedAt: Date.now(),
|
|
4360
|
+
lastOpenedAt: Date.now(),
|
|
4361
|
+
},
|
|
4362
|
+
];
|
|
4363
|
+
|
|
4364
|
+
const activeProjectId = existing ? existing.id : nextProjects[nextProjects.length - 1].id;
|
|
4365
|
+
|
|
4366
|
+
const updated = await persistSettings({
|
|
4367
|
+
projects: nextProjects,
|
|
4368
|
+
activeProjectId,
|
|
4369
|
+
lastDirectory: resolvedPath,
|
|
4370
|
+
});
|
|
3582
4371
|
|
|
3583
4372
|
res.json({
|
|
3584
4373
|
success: true,
|
|
3585
|
-
restarted:
|
|
3586
|
-
path: resolvedPath
|
|
4374
|
+
restarted: false,
|
|
4375
|
+
path: resolvedPath,
|
|
4376
|
+
settings: updated,
|
|
3587
4377
|
});
|
|
3588
4378
|
} catch (error) {
|
|
3589
4379
|
console.error('Failed to update OpenCode working directory:', error);
|