@thesammykins/tether 1.6.0 → 1.7.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/bin/tether.ts +169 -1
- package/package.json +1 -1
- package/src/adapters/codex.ts +22 -6
- package/src/adapters/opencode.ts +71 -30
- package/src/api.ts +110 -0
- package/src/bot.ts +423 -72
- package/src/config.ts +33 -1
- package/src/db.ts +107 -1
- package/src/features/projects.ts +219 -0
- package/src/queue.ts +1 -0
- package/src/worker.ts +31 -4
package/bin/tether.ts
CHANGED
|
@@ -42,7 +42,7 @@ import {
|
|
|
42
42
|
deleteKey as deleteConfigKey, resolve as resolveConfig, resolveAll,
|
|
43
43
|
isKnownKey, isSecret, getKeyMeta, getKnownKeys, hasSecrets, hasConfig,
|
|
44
44
|
importDotEnv, CONFIG_PATHS,
|
|
45
|
-
} from '../src/config';
|
|
45
|
+
} from '../src/config.js';
|
|
46
46
|
|
|
47
47
|
const PID_FILE = join(process.cwd(), '.tether.pid');
|
|
48
48
|
const API_BASE = process.env.TETHER_API_URL || 'http://localhost:2643';
|
|
@@ -674,6 +674,164 @@ async function updateState() {
|
|
|
674
674
|
|
|
675
675
|
// ============ Management Commands ============
|
|
676
676
|
|
|
677
|
+
async function projectCommand() {
|
|
678
|
+
const subcommand = args[0];
|
|
679
|
+
|
|
680
|
+
switch (subcommand) {
|
|
681
|
+
case 'add': {
|
|
682
|
+
const name = args[1];
|
|
683
|
+
const rawPath = args[2];
|
|
684
|
+
if (!name || !rawPath) {
|
|
685
|
+
console.error('Usage: tether project add <name> <path>');
|
|
686
|
+
process.exit(1);
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
const { resolve: resolvePath } = await import('path');
|
|
690
|
+
const resolvedPath = resolvePath(rawPath);
|
|
691
|
+
|
|
692
|
+
if (!existsSync(resolvedPath)) {
|
|
693
|
+
console.error(`Error: Path does not exist: ${resolvedPath}`);
|
|
694
|
+
process.exit(1);
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
try {
|
|
698
|
+
const response = await fetch(`${API_BASE}/projects`, {
|
|
699
|
+
method: 'POST',
|
|
700
|
+
headers: buildApiHeaders(),
|
|
701
|
+
body: JSON.stringify({ name, path: resolvedPath }),
|
|
702
|
+
});
|
|
703
|
+
const data = await response.json() as Record<string, unknown>;
|
|
704
|
+
if (!response.ok || data.error) {
|
|
705
|
+
console.error('Error:', data.error || 'Request failed');
|
|
706
|
+
process.exit(1);
|
|
707
|
+
}
|
|
708
|
+
console.log(`Project "${name}" added: ${resolvedPath}`);
|
|
709
|
+
} catch (error: unknown) {
|
|
710
|
+
const err = error as { code?: string; message?: string };
|
|
711
|
+
if (err.code === 'ECONNREFUSED') {
|
|
712
|
+
console.error('Error: Cannot connect to Tether API. Is the bot running? (tether start)');
|
|
713
|
+
} else {
|
|
714
|
+
console.error('Error:', err.message);
|
|
715
|
+
}
|
|
716
|
+
process.exit(1);
|
|
717
|
+
}
|
|
718
|
+
break;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
case 'list': {
|
|
722
|
+
try {
|
|
723
|
+
const response = await fetch(`${API_BASE}/projects`, {
|
|
724
|
+
headers: buildApiHeaders(),
|
|
725
|
+
});
|
|
726
|
+
const projects = await response.json() as Array<{
|
|
727
|
+
name: string;
|
|
728
|
+
path: string;
|
|
729
|
+
is_default: number;
|
|
730
|
+
}>;
|
|
731
|
+
if (!response.ok) {
|
|
732
|
+
console.error('Error: Failed to list projects');
|
|
733
|
+
process.exit(1);
|
|
734
|
+
}
|
|
735
|
+
if (projects.length === 0) {
|
|
736
|
+
console.log('No projects registered. Add one with: tether project add <name> <path>');
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
739
|
+
console.log('\nProjects:\n');
|
|
740
|
+
for (const p of projects) {
|
|
741
|
+
const marker = p.is_default ? ' (default)' : '';
|
|
742
|
+
console.log(` ${p.name}${marker}`);
|
|
743
|
+
console.log(` ${p.path}\n`);
|
|
744
|
+
}
|
|
745
|
+
} catch (error: unknown) {
|
|
746
|
+
const err = error as { code?: string; message?: string };
|
|
747
|
+
if (err.code === 'ECONNREFUSED') {
|
|
748
|
+
console.error('Error: Cannot connect to Tether API. Is the bot running? (tether start)');
|
|
749
|
+
} else {
|
|
750
|
+
console.error('Error:', err.message);
|
|
751
|
+
}
|
|
752
|
+
process.exit(1);
|
|
753
|
+
}
|
|
754
|
+
break;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
case 'remove': {
|
|
758
|
+
const name = args[1];
|
|
759
|
+
if (!name) {
|
|
760
|
+
console.error('Usage: tether project remove <name>');
|
|
761
|
+
process.exit(1);
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
try {
|
|
765
|
+
const response = await fetch(`${API_BASE}/projects/${encodeURIComponent(name)}`, {
|
|
766
|
+
method: 'DELETE',
|
|
767
|
+
headers: buildApiHeaders(),
|
|
768
|
+
});
|
|
769
|
+
const data = await response.json() as Record<string, unknown>;
|
|
770
|
+
if (!response.ok || data.error) {
|
|
771
|
+
console.error('Error:', data.error || 'Request failed');
|
|
772
|
+
process.exit(1);
|
|
773
|
+
}
|
|
774
|
+
console.log(`Project "${name}" removed.`);
|
|
775
|
+
} catch (error: unknown) {
|
|
776
|
+
const err = error as { code?: string; message?: string };
|
|
777
|
+
if (err.code === 'ECONNREFUSED') {
|
|
778
|
+
console.error('Error: Cannot connect to Tether API. Is the bot running? (tether start)');
|
|
779
|
+
} else {
|
|
780
|
+
console.error('Error:', err.message);
|
|
781
|
+
}
|
|
782
|
+
process.exit(1);
|
|
783
|
+
}
|
|
784
|
+
break;
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
case 'set-default': {
|
|
788
|
+
const name = args[1];
|
|
789
|
+
if (!name) {
|
|
790
|
+
console.error('Usage: tether project set-default <name>');
|
|
791
|
+
process.exit(1);
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
try {
|
|
795
|
+
const response = await fetch(`${API_BASE}/projects/${encodeURIComponent(name)}/default`, {
|
|
796
|
+
method: 'POST',
|
|
797
|
+
headers: buildApiHeaders(),
|
|
798
|
+
});
|
|
799
|
+
const data = await response.json() as Record<string, unknown>;
|
|
800
|
+
if (!response.ok || data.error) {
|
|
801
|
+
console.error('Error:', data.error || 'Request failed');
|
|
802
|
+
process.exit(1);
|
|
803
|
+
}
|
|
804
|
+
console.log(`Project "${name}" set as default.`);
|
|
805
|
+
} catch (error: unknown) {
|
|
806
|
+
const err = error as { code?: string; message?: string };
|
|
807
|
+
if (err.code === 'ECONNREFUSED') {
|
|
808
|
+
console.error('Error: Cannot connect to Tether API. Is the bot running? (tether start)');
|
|
809
|
+
} else {
|
|
810
|
+
console.error('Error:', err.message);
|
|
811
|
+
}
|
|
812
|
+
process.exit(1);
|
|
813
|
+
}
|
|
814
|
+
break;
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
default:
|
|
818
|
+
console.log(`
|
|
819
|
+
Usage: tether project <subcommand>
|
|
820
|
+
|
|
821
|
+
Subcommands:
|
|
822
|
+
add <name> <path> Register a project (validates path exists)
|
|
823
|
+
list List all registered projects
|
|
824
|
+
remove <name> Remove a project
|
|
825
|
+
set-default <name> Set a project as the default
|
|
826
|
+
`);
|
|
827
|
+
if (subcommand) {
|
|
828
|
+
console.error(`Unknown project subcommand: ${subcommand}`);
|
|
829
|
+
process.exit(1);
|
|
830
|
+
}
|
|
831
|
+
break;
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
|
|
677
835
|
async function setup() {
|
|
678
836
|
console.log('\n🔌 Tether Setup\n');
|
|
679
837
|
|
|
@@ -1268,6 +1426,7 @@ Management Commands:
|
|
|
1268
1426
|
health Check Distether connection
|
|
1269
1427
|
setup Interactive setup wizard
|
|
1270
1428
|
config Manage configuration and encrypted secrets
|
|
1429
|
+
project Manage named projects
|
|
1271
1430
|
help Show this help
|
|
1272
1431
|
|
|
1273
1432
|
Distether Commands:
|
|
@@ -1349,6 +1508,12 @@ Config Commands:
|
|
|
1349
1508
|
config import [path] Import from .env file (default: ./.env)
|
|
1350
1509
|
config path Show config file locations
|
|
1351
1510
|
|
|
1511
|
+
Project Commands:
|
|
1512
|
+
project add <name> <path> Register a named project directory
|
|
1513
|
+
project list List all registered projects
|
|
1514
|
+
project remove <name> Remove a project
|
|
1515
|
+
project set-default <name> Set a project as the default
|
|
1516
|
+
|
|
1352
1517
|
Examples:
|
|
1353
1518
|
tether send 123456789 "Hello world!"
|
|
1354
1519
|
tether embed 123456789 "Status update" --title "Daily Report" --color green --field "Tasks:5 done:inline"
|
|
@@ -1439,6 +1604,9 @@ switch (command) {
|
|
|
1439
1604
|
case 'config':
|
|
1440
1605
|
configCommand();
|
|
1441
1606
|
break;
|
|
1607
|
+
case 'project':
|
|
1608
|
+
projectCommand();
|
|
1609
|
+
break;
|
|
1442
1610
|
|
|
1443
1611
|
default:
|
|
1444
1612
|
console.log(`Unknown command: ${command}`);
|
package/package.json
CHANGED
package/src/adapters/codex.ts
CHANGED
|
@@ -10,9 +10,12 @@ import { debugLog, debugBlock } from '../debug.js';
|
|
|
10
10
|
* Wraps the Codex CLI with session handling and JSON output parsing.
|
|
11
11
|
*
|
|
12
12
|
* Key commands:
|
|
13
|
-
* - `codex exec
|
|
14
|
-
* - `codex exec resume <sessionId
|
|
13
|
+
* - `codex exec`: Execute a new prompt (reads from stdin)
|
|
14
|
+
* - `codex exec resume <sessionId>`: Resume existing session
|
|
15
15
|
* - `--json`: Structured output
|
|
16
|
+
*
|
|
17
|
+
* The prompt is piped via stdin to handle multi-line content with
|
|
18
|
+
* XML tags and special characters safely.
|
|
16
19
|
*/
|
|
17
20
|
|
|
18
21
|
// Cache resolved binary path
|
|
@@ -106,17 +109,15 @@ export class CodexAdapter implements AgentAdapter {
|
|
|
106
109
|
|
|
107
110
|
// Session handling
|
|
108
111
|
if (resume) {
|
|
109
|
-
// Resume existing session
|
|
110
112
|
args.push('resume', sessionId);
|
|
111
113
|
}
|
|
112
114
|
|
|
113
115
|
// JSON output format
|
|
114
116
|
args.push('--json');
|
|
115
117
|
|
|
116
|
-
//
|
|
117
|
-
|
|
118
|
+
// Prompt is piped via stdin to avoid shell escaping issues with
|
|
119
|
+
// multi-line prompts containing XML tags, angle brackets, etc.
|
|
118
120
|
|
|
119
|
-
// Spawn the process
|
|
120
121
|
const cwd = workingDir || process.cwd();
|
|
121
122
|
|
|
122
123
|
debugBlock('codex', 'Spawn', {
|
|
@@ -124,6 +125,7 @@ export class CodexAdapter implements AgentAdapter {
|
|
|
124
125
|
args: args.join(' '),
|
|
125
126
|
cwd,
|
|
126
127
|
resume: String(resume),
|
|
128
|
+
promptLength: String(prompt.length),
|
|
127
129
|
});
|
|
128
130
|
|
|
129
131
|
let proc: ReturnType<typeof Bun.spawn>;
|
|
@@ -131,6 +133,7 @@ export class CodexAdapter implements AgentAdapter {
|
|
|
131
133
|
proc = Bun.spawn(args, {
|
|
132
134
|
cwd,
|
|
133
135
|
env: process.env,
|
|
136
|
+
stdin: 'pipe',
|
|
134
137
|
stdout: 'pipe',
|
|
135
138
|
stderr: 'pipe',
|
|
136
139
|
});
|
|
@@ -146,6 +149,19 @@ export class CodexAdapter implements AgentAdapter {
|
|
|
146
149
|
});
|
|
147
150
|
}
|
|
148
151
|
|
|
152
|
+
// Write prompt to stdin then close the stream.
|
|
153
|
+
// Bun.spawn with stdin:'pipe' returns a FileSink.
|
|
154
|
+
const stdin = proc.stdin;
|
|
155
|
+
if (!stdin || typeof stdin === 'number') {
|
|
156
|
+
throw new Error('Failed to get writable stdin from Codex process');
|
|
157
|
+
}
|
|
158
|
+
try {
|
|
159
|
+
stdin.write(prompt);
|
|
160
|
+
stdin.end();
|
|
161
|
+
} catch (error) {
|
|
162
|
+
throw new Error(`Failed to write prompt to Codex stdin: ${error}`);
|
|
163
|
+
}
|
|
164
|
+
|
|
149
165
|
// Collect output and handle async spawn failures
|
|
150
166
|
let stdout: string;
|
|
151
167
|
let stderr: string;
|
package/src/adapters/opencode.ts
CHANGED
|
@@ -10,10 +10,12 @@ import { debugLog, debugBlock } from '../debug.js';
|
|
|
10
10
|
* Wraps the OpenCode CLI with session handling and JSON output parsing.
|
|
11
11
|
*
|
|
12
12
|
* Key flags:
|
|
13
|
-
* - `run
|
|
13
|
+
* - `run`: Execute a prompt (reads from stdin or positional args)
|
|
14
14
|
* - `--session <id>` or `--continue`: Resume existing session
|
|
15
|
-
* - `--format json`:
|
|
16
|
-
*
|
|
15
|
+
* - `--format json`: NDJSON event stream output
|
|
16
|
+
*
|
|
17
|
+
* The prompt is piped via stdin to handle multi-line content with
|
|
18
|
+
* XML tags and special characters safely.
|
|
17
19
|
*/
|
|
18
20
|
|
|
19
21
|
// Cache resolved binary path
|
|
@@ -26,6 +28,45 @@ export function _resetBinaryCache(): void {
|
|
|
26
28
|
cachedBinarySource = 'unknown';
|
|
27
29
|
}
|
|
28
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Parse NDJSON event stream from `opencode run --format json`.
|
|
33
|
+
*
|
|
34
|
+
* Each line is a JSON object like:
|
|
35
|
+
* {"type":"text","sessionID":"ses_xxx","part":{"text":"Hello",...}}
|
|
36
|
+
* {"type":"step_start","sessionID":"ses_xxx","part":{...}}
|
|
37
|
+
* {"type":"step_finish","sessionID":"ses_xxx","part":{...}}
|
|
38
|
+
*
|
|
39
|
+
* We concatenate all "text" event parts and extract the sessionID.
|
|
40
|
+
*/
|
|
41
|
+
function parseNdjsonEvents(raw: string): { text: string; eventSessionId: string | null } {
|
|
42
|
+
const lines = raw.trim().split('\n').filter(Boolean);
|
|
43
|
+
const textParts: string[] = [];
|
|
44
|
+
let eventSessionId: string | null = null;
|
|
45
|
+
|
|
46
|
+
for (const line of lines) {
|
|
47
|
+
try {
|
|
48
|
+
const event = JSON.parse(line) as Record<string, unknown>;
|
|
49
|
+
|
|
50
|
+
// Extract sessionID from any event
|
|
51
|
+
if (!eventSessionId && typeof event.sessionID === 'string') {
|
|
52
|
+
eventSessionId = event.sessionID;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Collect text content
|
|
56
|
+
if (event.type === 'text') {
|
|
57
|
+
const part = event.part as Record<string, unknown> | undefined;
|
|
58
|
+
if (part && typeof part.text === 'string') {
|
|
59
|
+
textParts.push(part.text);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
} catch {
|
|
63
|
+
// Skip non-JSON lines (shouldn't happen with --format json)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return { text: textParts.join(''), eventSessionId };
|
|
68
|
+
}
|
|
69
|
+
|
|
29
70
|
export class OpenCodeAdapter implements AgentAdapter {
|
|
30
71
|
readonly name = 'opencode';
|
|
31
72
|
|
|
@@ -105,24 +146,20 @@ export class OpenCodeAdapter implements AgentAdapter {
|
|
|
105
146
|
const binaryPath = await this.getBinaryPath();
|
|
106
147
|
const args = [binaryPath, 'run'];
|
|
107
148
|
|
|
108
|
-
// Format as JSON for structured output
|
|
149
|
+
// Format as JSON for structured output (NDJSON event stream)
|
|
109
150
|
args.push('--format', 'json');
|
|
110
151
|
|
|
111
152
|
// Session handling
|
|
112
153
|
if (resume) {
|
|
113
|
-
// Resume existing session
|
|
114
154
|
args.push('--session', sessionId);
|
|
115
155
|
}
|
|
116
156
|
|
|
117
|
-
//
|
|
118
|
-
|
|
119
|
-
args.push('--cwd', workingDir);
|
|
120
|
-
}
|
|
157
|
+
// Note: opencode has no --cwd flag; working directory is set via
|
|
158
|
+
// Bun.spawn's cwd option below.
|
|
121
159
|
|
|
122
|
-
//
|
|
123
|
-
|
|
160
|
+
// Prompt is piped via stdin to avoid shell escaping issues with
|
|
161
|
+
// multi-line prompts containing XML tags, angle brackets, etc.
|
|
124
162
|
|
|
125
|
-
// Spawn the process
|
|
126
163
|
const cwd = workingDir || process.cwd();
|
|
127
164
|
|
|
128
165
|
debugBlock('opencode', 'Spawn', {
|
|
@@ -130,6 +167,7 @@ export class OpenCodeAdapter implements AgentAdapter {
|
|
|
130
167
|
args: args.join(' '),
|
|
131
168
|
cwd,
|
|
132
169
|
resume: String(resume),
|
|
170
|
+
promptLength: String(prompt.length),
|
|
133
171
|
});
|
|
134
172
|
|
|
135
173
|
let proc: ReturnType<typeof Bun.spawn>;
|
|
@@ -137,6 +175,7 @@ export class OpenCodeAdapter implements AgentAdapter {
|
|
|
137
175
|
proc = Bun.spawn(args, {
|
|
138
176
|
cwd,
|
|
139
177
|
env: process.env,
|
|
178
|
+
stdin: 'pipe',
|
|
140
179
|
stdout: 'pipe',
|
|
141
180
|
stderr: 'pipe',
|
|
142
181
|
});
|
|
@@ -152,6 +191,19 @@ export class OpenCodeAdapter implements AgentAdapter {
|
|
|
152
191
|
});
|
|
153
192
|
}
|
|
154
193
|
|
|
194
|
+
// Write prompt to stdin then close the stream.
|
|
195
|
+
// Bun.spawn with stdin:'pipe' returns a FileSink.
|
|
196
|
+
const stdin = proc.stdin;
|
|
197
|
+
if (!stdin || typeof stdin === 'number') {
|
|
198
|
+
throw new Error('Failed to get writable stdin from OpenCode process');
|
|
199
|
+
}
|
|
200
|
+
try {
|
|
201
|
+
stdin.write(prompt);
|
|
202
|
+
stdin.end();
|
|
203
|
+
} catch (error) {
|
|
204
|
+
throw new Error(`Failed to write prompt to OpenCode stdin: ${error}`);
|
|
205
|
+
}
|
|
206
|
+
|
|
155
207
|
// Collect output and handle async spawn failures
|
|
156
208
|
let stdout: string;
|
|
157
209
|
let stderr: string;
|
|
@@ -178,24 +230,13 @@ export class OpenCodeAdapter implements AgentAdapter {
|
|
|
178
230
|
throw new Error(`OpenCode CLI failed (exit ${exitCode}): ${stderr || 'Unknown error'}`);
|
|
179
231
|
}
|
|
180
232
|
|
|
181
|
-
// Parse
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
// Extract session ID from output if not resuming
|
|
190
|
-
if (!resume && parsed.sessionId) {
|
|
191
|
-
finalSessionId = parsed.sessionId;
|
|
192
|
-
} else if (!resume && parsed.session_id) {
|
|
193
|
-
finalSessionId = parsed.session_id;
|
|
194
|
-
}
|
|
195
|
-
} catch {
|
|
196
|
-
// Not JSON, use raw output
|
|
197
|
-
output = stdout.trim();
|
|
198
|
-
}
|
|
233
|
+
// Parse NDJSON event stream from --format json output.
|
|
234
|
+
// Each line is a JSON object with a "type" field. We extract:
|
|
235
|
+
// - sessionID from any event (they all carry it)
|
|
236
|
+
// - text content from "text" type events
|
|
237
|
+
const { text, eventSessionId } = parseNdjsonEvents(stdout);
|
|
238
|
+
const output = text || stdout.trim();
|
|
239
|
+
const finalSessionId = eventSessionId || sessionId;
|
|
199
240
|
|
|
200
241
|
return {
|
|
201
242
|
output,
|
package/src/api.ts
CHANGED
|
@@ -7,6 +7,12 @@
|
|
|
7
7
|
|
|
8
8
|
import { Client, TextChannel, EmbedBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle } from 'discord.js';
|
|
9
9
|
import { timingSafeEqual, createHmac } from 'crypto';
|
|
10
|
+
import { existsSync } from 'fs';
|
|
11
|
+
import { resolve } from 'path';
|
|
12
|
+
import {
|
|
13
|
+
listProjects, createProject, deleteProject, setProjectDefault,
|
|
14
|
+
getProject,
|
|
15
|
+
} from './db.js';
|
|
10
16
|
|
|
11
17
|
const log = (msg: string) => process.stdout.write(`[api] ${msg}\n`);
|
|
12
18
|
|
|
@@ -559,6 +565,110 @@ export function startApiServer(client: Client, port: number = 2643) {
|
|
|
559
565
|
}), { headers });
|
|
560
566
|
}
|
|
561
567
|
|
|
568
|
+
// --- Project management endpoints ---
|
|
569
|
+
|
|
570
|
+
// GET /projects — list all projects
|
|
571
|
+
if (url.pathname === '/projects' && req.method === 'GET') {
|
|
572
|
+
const projects = listProjects();
|
|
573
|
+
return new Response(JSON.stringify(projects), { headers });
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// POST /projects — create a project
|
|
577
|
+
if (url.pathname === '/projects' && req.method === 'POST') {
|
|
578
|
+
try {
|
|
579
|
+
const body = await req.json() as {
|
|
580
|
+
name?: string;
|
|
581
|
+
path?: string;
|
|
582
|
+
isDefault?: boolean;
|
|
583
|
+
};
|
|
584
|
+
|
|
585
|
+
if (!body.name || !body.path) {
|
|
586
|
+
return new Response(JSON.stringify({ error: 'name and path are required' }), {
|
|
587
|
+
status: 400,
|
|
588
|
+
headers,
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// Validate path exists on disk
|
|
593
|
+
const resolvedPath = resolve(body.path);
|
|
594
|
+
if (!existsSync(resolvedPath)) {
|
|
595
|
+
return new Response(JSON.stringify({ error: `Path does not exist: ${resolvedPath}` }), {
|
|
596
|
+
status: 400,
|
|
597
|
+
headers,
|
|
598
|
+
});
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
createProject(body.name, resolvedPath, body.isDefault);
|
|
602
|
+
const project = getProject(body.name);
|
|
603
|
+
log(`Project created: ${body.name} → ${resolvedPath}`);
|
|
604
|
+
return new Response(JSON.stringify({ success: true, project }), {
|
|
605
|
+
status: 201,
|
|
606
|
+
headers,
|
|
607
|
+
});
|
|
608
|
+
} catch (error) {
|
|
609
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
610
|
+
// Surface unique constraint violations as 409 Conflict
|
|
611
|
+
if (message.includes('UNIQUE constraint')) {
|
|
612
|
+
return new Response(JSON.stringify({ error: `Project already exists` }), {
|
|
613
|
+
status: 409,
|
|
614
|
+
headers,
|
|
615
|
+
});
|
|
616
|
+
}
|
|
617
|
+
log(`Create project error: ${error instanceof Error ? error.stack : message}`);
|
|
618
|
+
return new Response(JSON.stringify({ error: 'Internal server error' }), {
|
|
619
|
+
status: 500,
|
|
620
|
+
headers,
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// DELETE /projects/:name — delete a project
|
|
626
|
+
if (url.pathname.startsWith('/projects/') && req.method === 'DELETE') {
|
|
627
|
+
const name = decodeURIComponent(url.pathname.slice('/projects/'.length).split('/')[0] || '');
|
|
628
|
+
if (!name) {
|
|
629
|
+
return new Response(JSON.stringify({ error: 'Project name is required' }), {
|
|
630
|
+
status: 400,
|
|
631
|
+
headers,
|
|
632
|
+
});
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
const existing = getProject(name);
|
|
636
|
+
if (!existing) {
|
|
637
|
+
return new Response(JSON.stringify({ error: `Project "${name}" not found` }), {
|
|
638
|
+
status: 404,
|
|
639
|
+
headers,
|
|
640
|
+
});
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
deleteProject(name);
|
|
644
|
+
log(`Project deleted: ${name}`);
|
|
645
|
+
return new Response(JSON.stringify({ success: true }), { headers });
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// POST /projects/:name/default — set project as default
|
|
649
|
+
if (url.pathname.match(/^\/projects\/[^/]+\/default$/) && req.method === 'POST') {
|
|
650
|
+
const parts = url.pathname.split('/');
|
|
651
|
+
const name = decodeURIComponent(parts[2] || '');
|
|
652
|
+
if (!name) {
|
|
653
|
+
return new Response(JSON.stringify({ error: 'Project name is required' }), {
|
|
654
|
+
status: 400,
|
|
655
|
+
headers,
|
|
656
|
+
});
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
const existing = getProject(name);
|
|
660
|
+
if (!existing) {
|
|
661
|
+
return new Response(JSON.stringify({ error: `Project "${name}" not found` }), {
|
|
662
|
+
status: 404,
|
|
663
|
+
headers,
|
|
664
|
+
});
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
setProjectDefault(name);
|
|
668
|
+
log(`Project set as default: ${name}`);
|
|
669
|
+
return new Response(JSON.stringify({ success: true }), { headers });
|
|
670
|
+
}
|
|
671
|
+
|
|
562
672
|
// 404 for unknown routes
|
|
563
673
|
return new Response(JSON.stringify({ error: 'Not found' }), {
|
|
564
674
|
status: 404,
|