dot-studio 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +214 -0
- package/client/assets/index-C2eIILoa.css +41 -0
- package/client/assets/index-DUPZ_Lw5.js +616 -0
- package/client/assets/index.es-Btlrnc3g.js +1 -0
- package/client/index.html +14 -0
- package/dist/cli.js +196 -0
- package/dist/server/index.js +79 -0
- package/dist/server/lib/act-runtime.js +1282 -0
- package/dist/server/lib/cache.js +31 -0
- package/dist/server/lib/config.js +53 -0
- package/dist/server/lib/dot-authoring.js +245 -0
- package/dist/server/lib/dot-loader.js +61 -0
- package/dist/server/lib/dot-login.js +190 -0
- package/dist/server/lib/model-catalog.js +111 -0
- package/dist/server/lib/opencode-auth.js +69 -0
- package/dist/server/lib/opencode-errors.js +220 -0
- package/dist/server/lib/opencode-sidecar.js +144 -0
- package/dist/server/lib/opencode.js +12 -0
- package/dist/server/lib/package-bin.js +63 -0
- package/dist/server/lib/project-config.js +39 -0
- package/dist/server/lib/prompt.js +222 -0
- package/dist/server/lib/request-context.js +27 -0
- package/dist/server/lib/runtime-tools.js +208 -0
- package/dist/server/routes/assets.js +161 -0
- package/dist/server/routes/chat.js +356 -0
- package/dist/server/routes/compile.js +105 -0
- package/dist/server/routes/dot.js +270 -0
- package/dist/server/routes/health.js +56 -0
- package/dist/server/routes/opencode.js +421 -0
- package/dist/server/routes/stages.js +137 -0
- package/dist/server/start.js +23 -0
- package/dist/server/terminal.js +282 -0
- package/dist/shared/mcp-config.js +19 -0
- package/dist/shared/model-variants.js +50 -0
- package/dist/shared/project-mcp.js +22 -0
- package/dist/shared/session-metadata.js +26 -0
- package/package.json +103 -0
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
// OpenCode SDK Proxy Routes
|
|
2
|
+
// Models, Agents, Tools, Config, Provider Auth, File, Find, VCS, LSP, MCP
|
|
3
|
+
import { Hono } from 'hono';
|
|
4
|
+
import { getOpencode } from '../lib/opencode.js';
|
|
5
|
+
import { cached, invalidate, TTL } from '../lib/cache.js';
|
|
6
|
+
import { resolveRuntimeTools } from '../lib/runtime-tools.js';
|
|
7
|
+
import { requestDirectoryQuery, resolveRequestWorkingDir } from '../lib/request-context.js';
|
|
8
|
+
import { canRestartOpencodeSidecar, isManagedOpencode, restartOpencodeSidecar } from '../lib/opencode-sidecar.js';
|
|
9
|
+
import { OPENCODE_URL } from '../lib/config.js';
|
|
10
|
+
import { clearStoredProviderAuth } from '../lib/opencode-auth.js';
|
|
11
|
+
import { jsonOpencodeError, unwrapOpencodeResult } from '../lib/opencode-errors.js';
|
|
12
|
+
import { listRuntimeModels } from '../lib/model-catalog.js';
|
|
13
|
+
import { readProjectMcpCatalog, summarizeProjectMcpCatalog } from '../lib/project-config.js';
|
|
14
|
+
const opencode = new Hono();
|
|
15
|
+
// ── OpenCode Health ─────────────────────────────────────
|
|
16
|
+
opencode.get('/api/opencode/health', async (c) => {
|
|
17
|
+
try {
|
|
18
|
+
const oc = await getOpencode();
|
|
19
|
+
const res = await oc.project.current(requestDirectoryQuery(c));
|
|
20
|
+
const data = res.data;
|
|
21
|
+
return c.json({
|
|
22
|
+
connected: true,
|
|
23
|
+
url: OPENCODE_URL,
|
|
24
|
+
project: data,
|
|
25
|
+
managed: isManagedOpencode(),
|
|
26
|
+
mode: isManagedOpencode() ? 'managed' : 'external',
|
|
27
|
+
restartAvailable: canRestartOpencodeSidecar(),
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
catch (err) {
|
|
31
|
+
return c.json({
|
|
32
|
+
connected: false,
|
|
33
|
+
error: err.message,
|
|
34
|
+
url: OPENCODE_URL,
|
|
35
|
+
managed: isManagedOpencode(),
|
|
36
|
+
mode: isManagedOpencode() ? 'managed' : 'external',
|
|
37
|
+
restartAvailable: canRestartOpencodeSidecar(),
|
|
38
|
+
}, 503);
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
opencode.post('/api/opencode/restart', async (c) => {
|
|
42
|
+
try {
|
|
43
|
+
await restartOpencodeSidecar();
|
|
44
|
+
return c.json({
|
|
45
|
+
ok: true,
|
|
46
|
+
managed: isManagedOpencode(),
|
|
47
|
+
mode: isManagedOpencode() ? 'managed' : 'external',
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
catch (err) {
|
|
51
|
+
return c.json({ error: err.message }, 400);
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
// ── Models ──────────────────────────────────────────────
|
|
55
|
+
opencode.get('/api/models', async (c) => {
|
|
56
|
+
try {
|
|
57
|
+
const cwd = resolveRequestWorkingDir(c);
|
|
58
|
+
return c.json(await listRuntimeModels(cwd));
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return c.json([]);
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
opencode.get('/api/providers', async (c) => {
|
|
65
|
+
try {
|
|
66
|
+
const cwd = resolveRequestWorkingDir(c);
|
|
67
|
+
const oc = await getOpencode();
|
|
68
|
+
const data = unwrapOpencodeResult(await oc.provider.list({ directory: cwd }));
|
|
69
|
+
const connected = new Set((data?.connected || []));
|
|
70
|
+
const providers = (data?.all || []).map((provider) => ({
|
|
71
|
+
id: provider.id,
|
|
72
|
+
name: provider.name || provider.id,
|
|
73
|
+
source: provider.source || 'builtin',
|
|
74
|
+
env: Array.isArray(provider.env) ? provider.env : [],
|
|
75
|
+
connected: connected.has(provider.id),
|
|
76
|
+
modelCount: provider.models ? Object.keys(provider.models).length : 0,
|
|
77
|
+
defaultModel: data?.default?.[provider.id] || null,
|
|
78
|
+
}));
|
|
79
|
+
return c.json(providers);
|
|
80
|
+
}
|
|
81
|
+
catch (err) {
|
|
82
|
+
return jsonOpencodeError(c, err, { defaultStatus: 503 });
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
// ── Agents ──────────────────────────────────────────────
|
|
86
|
+
opencode.get('/api/agents', async (c) => {
|
|
87
|
+
try {
|
|
88
|
+
const oc = await getOpencode();
|
|
89
|
+
const res = await oc.app.agents(requestDirectoryQuery(c));
|
|
90
|
+
const data = res.data;
|
|
91
|
+
return c.json(data || []);
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return c.json([]);
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
// ── Tools ───────────────────────────────────────────────
|
|
98
|
+
opencode.get('/api/tools', async (c) => {
|
|
99
|
+
try {
|
|
100
|
+
const oc = await getOpencode();
|
|
101
|
+
const res = await oc.tool.ids(requestDirectoryQuery(c));
|
|
102
|
+
const data = res.data;
|
|
103
|
+
return c.json(data || []);
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
return c.json([]);
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
opencode.get('/api/tools/:provider/:model', async (c) => {
|
|
110
|
+
try {
|
|
111
|
+
const oc = await getOpencode();
|
|
112
|
+
const res = await oc.tool.list({
|
|
113
|
+
...requestDirectoryQuery(c),
|
|
114
|
+
provider: c.req.param('provider'),
|
|
115
|
+
model: c.req.param('model'),
|
|
116
|
+
});
|
|
117
|
+
const data = res.data;
|
|
118
|
+
return c.json(data || []);
|
|
119
|
+
}
|
|
120
|
+
catch (err) {
|
|
121
|
+
return c.json({ error: err.message }, 500);
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
opencode.post('/api/runtime/tools', async (c) => {
|
|
125
|
+
const { model = null, mcpServerNames = [] } = await c.req.json();
|
|
126
|
+
try {
|
|
127
|
+
const resolution = await resolveRuntimeTools(resolveRequestWorkingDir(c), model, mcpServerNames);
|
|
128
|
+
return c.json(resolution);
|
|
129
|
+
}
|
|
130
|
+
catch (err) {
|
|
131
|
+
return c.json({ error: err.message }, 500);
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
// ── Config ──────────────────────────────────────────────
|
|
135
|
+
opencode.get('/api/config', async (c) => {
|
|
136
|
+
try {
|
|
137
|
+
const oc = await getOpencode();
|
|
138
|
+
const res = await oc.config.get(requestDirectoryQuery(c));
|
|
139
|
+
const data = res.data;
|
|
140
|
+
return c.json(data || {});
|
|
141
|
+
}
|
|
142
|
+
catch (err) {
|
|
143
|
+
return c.json({ error: err.message }, 500);
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
opencode.get('/api/config/project', async (c) => {
|
|
147
|
+
const cwd = resolveRequestWorkingDir(c);
|
|
148
|
+
try {
|
|
149
|
+
const oc = await getOpencode();
|
|
150
|
+
const res = await oc.file.read({
|
|
151
|
+
directory: cwd,
|
|
152
|
+
path: 'config.json',
|
|
153
|
+
});
|
|
154
|
+
const data = res.data;
|
|
155
|
+
const raw = typeof data?.content === 'string' ? data.content : '{}';
|
|
156
|
+
const config = JSON.parse(raw);
|
|
157
|
+
return c.json({
|
|
158
|
+
exists: true,
|
|
159
|
+
path: `${cwd}/config.json`,
|
|
160
|
+
config,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
return c.json({
|
|
165
|
+
exists: false,
|
|
166
|
+
path: `${cwd}/config.json`,
|
|
167
|
+
config: {},
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
opencode.put('/api/config', async (c) => {
|
|
172
|
+
const body = await c.req.json();
|
|
173
|
+
try {
|
|
174
|
+
const oc = await getOpencode();
|
|
175
|
+
const res = await oc.config.update({ ...requestDirectoryQuery(c), config: body });
|
|
176
|
+
const data = res.data;
|
|
177
|
+
invalidate('mcp-servers');
|
|
178
|
+
return c.json(data);
|
|
179
|
+
}
|
|
180
|
+
catch (err) {
|
|
181
|
+
return c.json({ error: err.message }, 500);
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
// ── Provider Auth ───────────────────────────────────────
|
|
185
|
+
opencode.get('/api/provider/auth', async (c) => {
|
|
186
|
+
try {
|
|
187
|
+
const oc = await getOpencode();
|
|
188
|
+
const data = unwrapOpencodeResult(await oc.provider.auth(requestDirectoryQuery(c)));
|
|
189
|
+
return c.json(data || {});
|
|
190
|
+
}
|
|
191
|
+
catch (err) {
|
|
192
|
+
return jsonOpencodeError(c, err, { defaultStatus: 503 });
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
opencode.post('/api/provider/:id/oauth/authorize', async (c) => {
|
|
196
|
+
const { method } = await c.req.json();
|
|
197
|
+
try {
|
|
198
|
+
const oc = await getOpencode();
|
|
199
|
+
const data = unwrapOpencodeResult(await oc.provider.oauth.authorize({
|
|
200
|
+
providerID: c.req.param('id'),
|
|
201
|
+
...requestDirectoryQuery(c),
|
|
202
|
+
method,
|
|
203
|
+
}));
|
|
204
|
+
return c.json(data);
|
|
205
|
+
}
|
|
206
|
+
catch (err) {
|
|
207
|
+
return jsonOpencodeError(c, err, { providerId: c.req.param('id'), defaultStatus: 500 });
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
opencode.post('/api/provider/:id/oauth/callback', async (c) => {
|
|
211
|
+
const { method, code } = await c.req.json();
|
|
212
|
+
try {
|
|
213
|
+
const oc = await getOpencode();
|
|
214
|
+
const data = unwrapOpencodeResult(await oc.provider.oauth.callback({
|
|
215
|
+
providerID: c.req.param('id'),
|
|
216
|
+
...requestDirectoryQuery(c),
|
|
217
|
+
method,
|
|
218
|
+
...(code ? { code } : {}),
|
|
219
|
+
}));
|
|
220
|
+
unwrapOpencodeResult(await oc.instance.dispose(requestDirectoryQuery(c)));
|
|
221
|
+
return c.json(data);
|
|
222
|
+
}
|
|
223
|
+
catch (err) {
|
|
224
|
+
return jsonOpencodeError(c, err, { providerId: c.req.param('id'), defaultStatus: 500 });
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
opencode.put('/api/provider/:id/auth', async (c) => {
|
|
228
|
+
const auth = await c.req.json();
|
|
229
|
+
try {
|
|
230
|
+
const oc = await getOpencode();
|
|
231
|
+
const data = unwrapOpencodeResult(await oc.auth.set({
|
|
232
|
+
providerID: c.req.param('id'),
|
|
233
|
+
auth,
|
|
234
|
+
}));
|
|
235
|
+
unwrapOpencodeResult(await oc.instance.dispose(requestDirectoryQuery(c)));
|
|
236
|
+
return c.json(data);
|
|
237
|
+
}
|
|
238
|
+
catch (err) {
|
|
239
|
+
return jsonOpencodeError(c, err, { providerId: c.req.param('id'), defaultStatus: 500 });
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
opencode.delete('/api/provider/:id/auth', async (c) => {
|
|
243
|
+
try {
|
|
244
|
+
const oc = await getOpencode();
|
|
245
|
+
await clearStoredProviderAuth(c.req.param('id'));
|
|
246
|
+
unwrapOpencodeResult(await oc.instance.dispose(requestDirectoryQuery(c)));
|
|
247
|
+
return c.json({ ok: true });
|
|
248
|
+
}
|
|
249
|
+
catch (err) {
|
|
250
|
+
return jsonOpencodeError(c, err, { providerId: c.req.param('id'), defaultStatus: 500 });
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
// ── LSP ─────────────────────────────────────────────────
|
|
254
|
+
opencode.get('/api/lsp/status', async (c) => {
|
|
255
|
+
try {
|
|
256
|
+
const oc = await getOpencode();
|
|
257
|
+
const res = await oc.lsp.status(requestDirectoryQuery(c));
|
|
258
|
+
const data = res.data;
|
|
259
|
+
return c.json(data || []);
|
|
260
|
+
}
|
|
261
|
+
catch (err) {
|
|
262
|
+
return c.json({ error: err.message }, 500);
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
// ── MCP Servers ─────────────────────────────────────────
|
|
266
|
+
opencode.get('/api/mcp/servers', async (c) => {
|
|
267
|
+
try {
|
|
268
|
+
const cwd = resolveRequestWorkingDir(c);
|
|
269
|
+
return c.json(await cached(`mcp-servers-${cwd}`, TTL.MCP_SERVERS, async () => {
|
|
270
|
+
const oc = await getOpencode();
|
|
271
|
+
const res = await oc.mcp.status({ directory: cwd });
|
|
272
|
+
const data = (res.data || {});
|
|
273
|
+
const catalog = await readProjectMcpCatalog(cwd);
|
|
274
|
+
return summarizeProjectMcpCatalog(catalog, data);
|
|
275
|
+
}));
|
|
276
|
+
}
|
|
277
|
+
catch {
|
|
278
|
+
return c.json([]);
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
opencode.post('/api/mcp/add', async (c) => {
|
|
282
|
+
const { name, config } = await c.req.json();
|
|
283
|
+
try {
|
|
284
|
+
const oc = await getOpencode();
|
|
285
|
+
const res = await oc.mcp.add({
|
|
286
|
+
...requestDirectoryQuery(c),
|
|
287
|
+
name,
|
|
288
|
+
config: config,
|
|
289
|
+
});
|
|
290
|
+
const data = res.data;
|
|
291
|
+
invalidate('mcp-servers');
|
|
292
|
+
return c.json(data);
|
|
293
|
+
}
|
|
294
|
+
catch (err) {
|
|
295
|
+
return c.json({ error: err.message }, 500);
|
|
296
|
+
}
|
|
297
|
+
});
|
|
298
|
+
opencode.post('/api/mcp/:name/connect', async (c) => {
|
|
299
|
+
try {
|
|
300
|
+
const oc = await getOpencode();
|
|
301
|
+
const res = await oc.mcp.connect({
|
|
302
|
+
name: c.req.param('name'),
|
|
303
|
+
...requestDirectoryQuery(c),
|
|
304
|
+
});
|
|
305
|
+
const data = res.data;
|
|
306
|
+
invalidate('mcp-servers');
|
|
307
|
+
return c.json(data);
|
|
308
|
+
}
|
|
309
|
+
catch (err) {
|
|
310
|
+
return c.json({ error: err.message }, 500);
|
|
311
|
+
}
|
|
312
|
+
});
|
|
313
|
+
opencode.post('/api/mcp/:name/disconnect', async (c) => {
|
|
314
|
+
try {
|
|
315
|
+
const oc = await getOpencode();
|
|
316
|
+
const res = await oc.mcp.disconnect({
|
|
317
|
+
name: c.req.param('name'),
|
|
318
|
+
...requestDirectoryQuery(c),
|
|
319
|
+
});
|
|
320
|
+
const data = res.data;
|
|
321
|
+
invalidate('mcp-servers');
|
|
322
|
+
return c.json(data);
|
|
323
|
+
}
|
|
324
|
+
catch (err) {
|
|
325
|
+
return c.json({ error: err.message }, 500);
|
|
326
|
+
}
|
|
327
|
+
});
|
|
328
|
+
// ── File ────────────────────────────────────────────────
|
|
329
|
+
opencode.get('/api/file/list', async (c) => {
|
|
330
|
+
const dirPath = c.req.query('path') || '.';
|
|
331
|
+
try {
|
|
332
|
+
const oc = await getOpencode();
|
|
333
|
+
const res = await oc.file.list({ ...requestDirectoryQuery(c), path: dirPath });
|
|
334
|
+
const data = res.data;
|
|
335
|
+
return c.json(data || []);
|
|
336
|
+
}
|
|
337
|
+
catch {
|
|
338
|
+
return c.json([]);
|
|
339
|
+
}
|
|
340
|
+
});
|
|
341
|
+
opencode.get('/api/file/read', async (c) => {
|
|
342
|
+
const filePath = c.req.query('path');
|
|
343
|
+
if (!filePath)
|
|
344
|
+
return c.json({ error: 'path required' }, 400);
|
|
345
|
+
try {
|
|
346
|
+
const oc = await getOpencode();
|
|
347
|
+
const res = await oc.file.read({ ...requestDirectoryQuery(c), path: filePath });
|
|
348
|
+
const data = res.data;
|
|
349
|
+
return c.json(data);
|
|
350
|
+
}
|
|
351
|
+
catch (err) {
|
|
352
|
+
return c.json({ error: err.message }, 500);
|
|
353
|
+
}
|
|
354
|
+
});
|
|
355
|
+
opencode.get('/api/file/status', async (c) => {
|
|
356
|
+
try {
|
|
357
|
+
const oc = await getOpencode();
|
|
358
|
+
const res = await oc.file.status(requestDirectoryQuery(c));
|
|
359
|
+
const data = res.data;
|
|
360
|
+
return c.json(data || []);
|
|
361
|
+
}
|
|
362
|
+
catch {
|
|
363
|
+
return c.json([]);
|
|
364
|
+
}
|
|
365
|
+
});
|
|
366
|
+
// ── Find ────────────────────────────────────────────────
|
|
367
|
+
opencode.get('/api/find/text', async (c) => {
|
|
368
|
+
const pattern = c.req.query('pattern');
|
|
369
|
+
if (!pattern)
|
|
370
|
+
return c.json({ error: 'pattern required' }, 400);
|
|
371
|
+
try {
|
|
372
|
+
const oc = await getOpencode();
|
|
373
|
+
const res = await oc.find.text({ ...requestDirectoryQuery(c), pattern });
|
|
374
|
+
const data = res.data;
|
|
375
|
+
return c.json(data || []);
|
|
376
|
+
}
|
|
377
|
+
catch (err) {
|
|
378
|
+
return c.json({ error: err.message }, 500);
|
|
379
|
+
}
|
|
380
|
+
});
|
|
381
|
+
opencode.get('/api/find/files', async (c) => {
|
|
382
|
+
const pattern = c.req.query('pattern');
|
|
383
|
+
if (!pattern)
|
|
384
|
+
return c.json({ error: 'pattern required' }, 400);
|
|
385
|
+
try {
|
|
386
|
+
const oc = await getOpencode();
|
|
387
|
+
const res = await oc.find.files({ ...requestDirectoryQuery(c), query: pattern });
|
|
388
|
+
const data = res.data;
|
|
389
|
+
return c.json(data || []);
|
|
390
|
+
}
|
|
391
|
+
catch (err) {
|
|
392
|
+
return c.json({ error: err.message }, 500);
|
|
393
|
+
}
|
|
394
|
+
});
|
|
395
|
+
opencode.get('/api/find/symbols', async (c) => {
|
|
396
|
+
const pattern = c.req.query('pattern');
|
|
397
|
+
if (!pattern)
|
|
398
|
+
return c.json({ error: 'pattern required' }, 400);
|
|
399
|
+
try {
|
|
400
|
+
const oc = await getOpencode();
|
|
401
|
+
const res = await oc.find.symbols({ ...requestDirectoryQuery(c), query: pattern });
|
|
402
|
+
const data = res.data;
|
|
403
|
+
return c.json(data || []);
|
|
404
|
+
}
|
|
405
|
+
catch (err) {
|
|
406
|
+
return c.json({ error: err.message }, 500);
|
|
407
|
+
}
|
|
408
|
+
});
|
|
409
|
+
// ── VCS / Git ───────────────────────────────────────────
|
|
410
|
+
opencode.get('/api/vcs', async (c) => {
|
|
411
|
+
try {
|
|
412
|
+
const oc = await getOpencode();
|
|
413
|
+
const res = await oc.vcs.get(requestDirectoryQuery(c));
|
|
414
|
+
const data = res.data;
|
|
415
|
+
return c.json(data || {});
|
|
416
|
+
}
|
|
417
|
+
catch (err) {
|
|
418
|
+
return c.json({ error: err.message }, 500);
|
|
419
|
+
}
|
|
420
|
+
});
|
|
421
|
+
export default opencode;
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// Stage CRUD Routes — with path validation
|
|
2
|
+
import { Hono } from 'hono';
|
|
3
|
+
import fs from 'fs/promises';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import { createHash } from 'crypto';
|
|
6
|
+
import { stagesDir } from '../lib/config.js';
|
|
7
|
+
const stages = new Hono();
|
|
8
|
+
// ── Helpers ─────────────────────────────────────────────
|
|
9
|
+
function sanitizeStageId(id) {
|
|
10
|
+
return id
|
|
11
|
+
.replace(/\.\./g, '')
|
|
12
|
+
.replace(/[/\\:*?"<>|\x00-\x1f]/g, '')
|
|
13
|
+
.trim();
|
|
14
|
+
}
|
|
15
|
+
function validateStageId(id) {
|
|
16
|
+
const clean = sanitizeStageId(id);
|
|
17
|
+
if (!clean || clean.length === 0)
|
|
18
|
+
return null;
|
|
19
|
+
if (clean.length > 128)
|
|
20
|
+
return null;
|
|
21
|
+
return clean;
|
|
22
|
+
}
|
|
23
|
+
function normalizeWorkingDir(input) {
|
|
24
|
+
const trimmed = input.trim().replace(/\/+$/, '');
|
|
25
|
+
if (!trimmed)
|
|
26
|
+
return null;
|
|
27
|
+
return path.resolve(trimmed);
|
|
28
|
+
}
|
|
29
|
+
function stageIdForWorkingDir(workingDir) {
|
|
30
|
+
return createHash('sha1').update(workingDir).digest('hex').slice(0, 16);
|
|
31
|
+
}
|
|
32
|
+
function stagePathForId(id) {
|
|
33
|
+
return path.join(stagesDir(), `${id}.json`);
|
|
34
|
+
}
|
|
35
|
+
// ── List Stages ─────────────────────────────────────────
|
|
36
|
+
stages.get('/api/stages', async (c) => {
|
|
37
|
+
const dir = stagesDir();
|
|
38
|
+
try {
|
|
39
|
+
await fs.mkdir(dir, { recursive: true });
|
|
40
|
+
const files = await fs.readdir(dir);
|
|
41
|
+
const entries = await Promise.all(files
|
|
42
|
+
.filter((f) => f.endsWith('.json'))
|
|
43
|
+
.map(async (file) => {
|
|
44
|
+
const filePath = path.join(dir, file);
|
|
45
|
+
try {
|
|
46
|
+
const [raw, stat] = await Promise.all([
|
|
47
|
+
fs.readFile(filePath, 'utf-8'),
|
|
48
|
+
fs.stat(filePath),
|
|
49
|
+
]);
|
|
50
|
+
const parsed = JSON.parse(raw);
|
|
51
|
+
const workingDir = normalizeWorkingDir(parsed.workingDir || '') || '';
|
|
52
|
+
if (!workingDir) {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
id: file.replace('.json', ''),
|
|
57
|
+
workingDir,
|
|
58
|
+
updatedAt: stat.mtimeMs,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
}));
|
|
65
|
+
return c.json(entries
|
|
66
|
+
.filter((entry) => entry !== null)
|
|
67
|
+
.sort((a, b) => b.updatedAt - a.updatedAt));
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return c.json([]);
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
// ── Get Stage ───────────────────────────────────────────
|
|
74
|
+
stages.get('/api/stages/:id', async (c) => {
|
|
75
|
+
const rawId = c.req.param('id');
|
|
76
|
+
const id = validateStageId(rawId);
|
|
77
|
+
if (!id)
|
|
78
|
+
return c.json({ error: 'Invalid stage id' }, 400);
|
|
79
|
+
const filePath = stagePathForId(id);
|
|
80
|
+
// Ensure resolved path is within stages dir (prevent traversal)
|
|
81
|
+
if (!filePath.startsWith(stagesDir())) {
|
|
82
|
+
return c.json({ error: 'Invalid stage id' }, 400);
|
|
83
|
+
}
|
|
84
|
+
try {
|
|
85
|
+
const raw = await fs.readFile(filePath, 'utf-8');
|
|
86
|
+
return c.json(JSON.parse(raw));
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return c.json({ error: 'Stage not found' }, 404);
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
// ── Save Stage ──────────────────────────────────────────
|
|
93
|
+
stages.put('/api/stages', async (c) => {
|
|
94
|
+
const body = await c.req.json();
|
|
95
|
+
const workingDir = normalizeWorkingDir(body.workingDir || '');
|
|
96
|
+
if (!workingDir) {
|
|
97
|
+
return c.json({ error: 'workingDir is required' }, 400);
|
|
98
|
+
}
|
|
99
|
+
const id = stageIdForWorkingDir(workingDir);
|
|
100
|
+
const stage = {
|
|
101
|
+
...body,
|
|
102
|
+
workingDir,
|
|
103
|
+
};
|
|
104
|
+
const dir = stagesDir();
|
|
105
|
+
await fs.mkdir(dir, { recursive: true });
|
|
106
|
+
const filePath = stagePathForId(id);
|
|
107
|
+
if (!filePath.startsWith(dir)) {
|
|
108
|
+
return c.json({ error: 'Invalid stage id' }, 400);
|
|
109
|
+
}
|
|
110
|
+
await fs.writeFile(filePath, JSON.stringify(stage, null, 2), 'utf-8');
|
|
111
|
+
const stat = await fs.stat(filePath);
|
|
112
|
+
return c.json({
|
|
113
|
+
ok: true,
|
|
114
|
+
id,
|
|
115
|
+
workingDir,
|
|
116
|
+
updatedAt: stat.mtimeMs,
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
// ── Delete Stage ────────────────────────────────────────
|
|
120
|
+
stages.delete('/api/stages/:id', async (c) => {
|
|
121
|
+
const rawId = c.req.param('id');
|
|
122
|
+
const id = validateStageId(rawId);
|
|
123
|
+
if (!id)
|
|
124
|
+
return c.json({ error: 'Invalid stage id' }, 400);
|
|
125
|
+
const filePath = stagePathForId(id);
|
|
126
|
+
if (!filePath.startsWith(stagesDir())) {
|
|
127
|
+
return c.json({ error: 'Invalid stage id' }, 400);
|
|
128
|
+
}
|
|
129
|
+
try {
|
|
130
|
+
await fs.unlink(filePath);
|
|
131
|
+
return c.json({ ok: true });
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
return c.json({ error: 'Stage not found' }, 404);
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
export default stages;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import concurrently from 'concurrently';
|
|
2
|
+
async function main() {
|
|
3
|
+
const commands = [
|
|
4
|
+
{ command: 'npm run server:dev', name: 'server', prefixColor: 'blue' },
|
|
5
|
+
{ command: 'npm run dev', name: 'frontend', prefixColor: 'magenta' },
|
|
6
|
+
];
|
|
7
|
+
const { result } = concurrently(commands, {
|
|
8
|
+
prefix: 'name',
|
|
9
|
+
killOthers: ['failure'],
|
|
10
|
+
restartTries: 1,
|
|
11
|
+
});
|
|
12
|
+
try {
|
|
13
|
+
await result;
|
|
14
|
+
}
|
|
15
|
+
catch (err) {
|
|
16
|
+
console.error('One or more processes exited with an error');
|
|
17
|
+
process.exit(1);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
main().catch((err) => {
|
|
21
|
+
console.error(err);
|
|
22
|
+
process.exit(1);
|
|
23
|
+
});
|