robodev 0.42.0 → 0.44.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/package.json +2 -2
- package/src/dev-inspect.test.ts +12 -0
- package/src/dev-inspect.ts +606 -2
- package/src/dev-server.ts +82 -0
- package/src/index.ts +38 -13
- package/templates/auth-chat/apps/fe/vite.config.ts +29 -2
- package/templates/auth-chat/package.json +1 -1
- package/templates/backend/package.json +1 -1
- package/templates/empty/README.md +1 -1
- package/templates/empty/package.json +1 -1
- package/templates/marketplace/apps/fe/vite.config.ts +29 -2
- package/templates/marketplace/package.json +1 -1
- package/templates/space/apps/fe/vite.config.ts +29 -2
- package/templates/space/package.json +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "robodev",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.44.0",
|
|
4
4
|
"description": "CLI for Robodev Starbase — create, auth, link, deploy, load, and run a project locally with robodev dev",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
"embedded-postgres": "17.10.0-beta.17",
|
|
27
27
|
"fastify": "^5.12.1",
|
|
28
28
|
"tsx": "^4.20.5",
|
|
29
|
-
"@robodev-ai/runtime": "0.
|
|
29
|
+
"@robodev-ai/runtime": "0.6.0",
|
|
30
30
|
"@robodev-ai/sdk": "0.13.0"
|
|
31
31
|
},
|
|
32
32
|
"devDependencies": {
|
package/src/dev-inspect.test.ts
CHANGED
|
@@ -24,6 +24,7 @@ const fileRuntime = {
|
|
|
24
24
|
|
|
25
25
|
test("isInspectPath covers dashboard pages and JSON only", () => {
|
|
26
26
|
assert.equal(isInspectPath("/_robodev"), true);
|
|
27
|
+
assert.equal(isInspectPath("/_robodev/databases"), true);
|
|
27
28
|
assert.equal(isInspectPath("/_robodev/apis"), true);
|
|
28
29
|
assert.equal(isInspectPath("/_robodev/jobs"), true);
|
|
29
30
|
assert.equal(isInspectPath("/_robodev/emails"), true);
|
|
@@ -34,6 +35,8 @@ test("isInspectPath covers dashboard pages and JSON only", () => {
|
|
|
34
35
|
assert.equal(isInspectPath("/storage/objects/avatar.png"), false);
|
|
35
36
|
assert.equal(isInspectPath("/_robodev/state.json"), true);
|
|
36
37
|
assert.equal(isInspectPath("/_robodev/logs.json"), true);
|
|
38
|
+
assert.equal(isInspectPath("/_robodev/db/schema.json"), true);
|
|
39
|
+
assert.equal(isInspectPath("/_robodev/db/rows.json"), true);
|
|
37
40
|
assert.equal(isInspectPath("/_robodev/docs"), false);
|
|
38
41
|
assert.equal(isInspectPath("/_robodev/ready"), false);
|
|
39
42
|
assert.equal(isInspectPath("/_robodev/scalar"), false);
|
|
@@ -48,6 +51,9 @@ test("shouldCountRequestHistogram counts Auth and matched file routes only", ()
|
|
|
48
51
|
assert.equal(shouldCountRequestHistogram("/api/planets", fileRuntime, "POST"), false);
|
|
49
52
|
assert.equal(shouldCountRequestHistogram("/api/unknown", fileRuntime, "GET"), false);
|
|
50
53
|
assert.equal(shouldCountRequestHistogram("/_robodev", fileRuntime, "GET"), false);
|
|
54
|
+
assert.equal(shouldCountRequestHistogram("/_robodev/databases", fileRuntime, "GET"), false);
|
|
55
|
+
assert.equal(shouldCountRequestHistogram("/_robodev/db/schema.json", fileRuntime, "GET"), false);
|
|
56
|
+
assert.equal(shouldCountRequestHistogram("/_robodev/db/rows.json", fileRuntime, "GET"), false);
|
|
51
57
|
assert.equal(shouldCountRequestHistogram("/_robodev/apis", fileRuntime, "GET"), false);
|
|
52
58
|
assert.equal(shouldCountRequestHistogram("/_robodev/storage", fileRuntime, "GET"), false);
|
|
53
59
|
assert.equal(shouldCountRequestHistogram("/_robodev/sockets", fileRuntime, "GET"), false);
|
|
@@ -251,20 +257,26 @@ test("incomplete Google credentials are not labeled override", async () => {
|
|
|
251
257
|
test("inspect HTML has nav labels and polls state.json", () => {
|
|
252
258
|
const html = inspectHtml();
|
|
253
259
|
assert.match(html, />Overview</);
|
|
260
|
+
assert.match(html, />Databases</);
|
|
254
261
|
assert.match(html, />APIs</);
|
|
255
262
|
assert.match(html, />Jobs</);
|
|
256
263
|
assert.match(html, />Emails</);
|
|
257
264
|
assert.match(html, />Auth</);
|
|
258
265
|
assert.match(html, />Storage</);
|
|
259
266
|
assert.match(html, />Sockets</);
|
|
267
|
+
assert.match(html, /\/_robodev\/databases/);
|
|
260
268
|
assert.match(html, /\/_robodev\/storage/);
|
|
261
269
|
assert.match(html, /\/_robodev\/sockets/);
|
|
270
|
+
assert.match(html, /data-nav="overview"[\s\S]*data-nav="databases"[\s\S]*data-nav="apis"/);
|
|
262
271
|
assert.doesNotMatch(html, /<h2>Requests<\/h2>/);
|
|
263
272
|
assert.doesNotMatch(html, /<h2>Emails<\/h2>/);
|
|
264
273
|
assert.doesNotMatch(html, /<h2>Jobs<\/h2>/);
|
|
265
274
|
assert.doesNotMatch(html, /<h2>Logs<\/h2>/);
|
|
266
275
|
assert.match(html, /\/_robodev\/state\.json/);
|
|
276
|
+
assert.match(html, /\/_robodev\/db\/schema\.json/);
|
|
277
|
+
assert.match(html, /\/_robodev\/db\/rows\.json/);
|
|
267
278
|
assert.doesNotMatch(html, /fetch\("\/_robodev\/logs\.json"\)/);
|
|
279
|
+
assert.doesNotMatch(html, /@xyflow|React\.createElement|react-dom/);
|
|
268
280
|
assert.match(html, /setInterval\(poll, 1000\)/);
|
|
269
281
|
});
|
|
270
282
|
|
package/src/dev-inspect.ts
CHANGED
|
@@ -13,6 +13,7 @@ export const HISTOGRAM_MINUTES = 120;
|
|
|
13
13
|
|
|
14
14
|
const DASHBOARD_HTML_PATHS = new Set([
|
|
15
15
|
"/_robodev",
|
|
16
|
+
"/_robodev/databases",
|
|
16
17
|
"/_robodev/apis",
|
|
17
18
|
"/_robodev/jobs",
|
|
18
19
|
"/_robodev/emails",
|
|
@@ -22,7 +23,12 @@ const DASHBOARD_HTML_PATHS = new Set([
|
|
|
22
23
|
"/_robodev/logs",
|
|
23
24
|
]);
|
|
24
25
|
|
|
25
|
-
const DASHBOARD_JSON_PATHS = new Set([
|
|
26
|
+
const DASHBOARD_JSON_PATHS = new Set([
|
|
27
|
+
"/_robodev/state.json",
|
|
28
|
+
"/_robodev/logs.json",
|
|
29
|
+
"/_robodev/db/schema.json",
|
|
30
|
+
"/_robodev/db/rows.json",
|
|
31
|
+
]);
|
|
26
32
|
|
|
27
33
|
const HISTOGRAM_EXCLUDED_PATHS = new Set([
|
|
28
34
|
...DASHBOARD_HTML_PATHS,
|
|
@@ -684,9 +690,42 @@ export function inspectHtml(): string {
|
|
|
684
690
|
.bad-text { color: #dc2626; }
|
|
685
691
|
.page { display: none; }
|
|
686
692
|
.page.visible { display: block; }
|
|
693
|
+
.er-wrap {
|
|
694
|
+
overflow: auto;
|
|
695
|
+
height: 360px;
|
|
696
|
+
border: 1px solid #ddd;
|
|
697
|
+
border-radius: 8px;
|
|
698
|
+
margin: 0 0 16px;
|
|
699
|
+
}
|
|
700
|
+
.er-canvas { position: relative; min-height: 200px; }
|
|
701
|
+
.er-svg { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; }
|
|
702
|
+
.er-card {
|
|
703
|
+
position: absolute;
|
|
704
|
+
width: 240px;
|
|
705
|
+
box-sizing: border-box;
|
|
706
|
+
border: 1px solid #ddd;
|
|
707
|
+
border-radius: 8px;
|
|
708
|
+
padding: 8px;
|
|
709
|
+
background: Canvas;
|
|
710
|
+
cursor: grab;
|
|
711
|
+
user-select: none;
|
|
712
|
+
}
|
|
713
|
+
.er-card.selected { border-width: 2px; border-color: currentColor; }
|
|
714
|
+
.er-card h3 { margin: 0 0 6px; font-size: 0.82rem; }
|
|
715
|
+
.er-col {
|
|
716
|
+
display: flex;
|
|
717
|
+
gap: 6px;
|
|
718
|
+
font-size: 11px;
|
|
719
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
720
|
+
}
|
|
721
|
+
.er-col .pk { width: 12px; flex-shrink: 0; opacity: 0.7; }
|
|
722
|
+
.er-col .type { opacity: 0.65; }
|
|
723
|
+
.db-bar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; margin: 0 0 10px; font-size: 0.85rem; }
|
|
724
|
+
.db-bar input, .db-bar select, .db-filter input, .db-filter select { font: inherit; font-size: 0.85rem; }
|
|
725
|
+
.db-filter { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin: 0 0 8px; }
|
|
687
726
|
@media (prefers-color-scheme: dark) {
|
|
688
727
|
.lead, .empty, .card .label, .chart-title, .legend { color: #9aa0a6; }
|
|
689
|
-
.card, .chart { border-color: #333; }
|
|
728
|
+
.card, .chart, .er-wrap, .er-card { border-color: #333; }
|
|
690
729
|
th, td { border-bottom-color: #2a2a2a; }
|
|
691
730
|
.tabs button, .clear { border-color: #555; }
|
|
692
731
|
}
|
|
@@ -697,6 +736,7 @@ export function inspectHtml(): string {
|
|
|
697
736
|
<h1>robodev dev</h1>
|
|
698
737
|
<nav>
|
|
699
738
|
<a href="/_robodev" data-nav="overview">Overview</a>
|
|
739
|
+
<a href="/_robodev/databases" data-nav="databases">Databases</a>
|
|
700
740
|
<a href="/_robodev/apis" data-nav="apis">APIs</a>
|
|
701
741
|
<a href="/_robodev/jobs" data-nav="jobs">Jobs</a>
|
|
702
742
|
<a href="/_robodev/emails" data-nav="emails">Emails</a>
|
|
@@ -735,6 +775,26 @@ export function inspectHtml(): string {
|
|
|
735
775
|
</div>
|
|
736
776
|
</section>
|
|
737
777
|
|
|
778
|
+
<section id="page-databases" class="page">
|
|
779
|
+
<div class="db-bar">
|
|
780
|
+
<button type="button" id="db-reset" class="clear">Reset</button>
|
|
781
|
+
</div>
|
|
782
|
+
<div id="db-er-wrap" class="er-wrap">
|
|
783
|
+
<div id="db-er" class="er-canvas"></div>
|
|
784
|
+
</div>
|
|
785
|
+
<p id="db-schema-status" class="empty" hidden></p>
|
|
786
|
+
<div class="db-bar">
|
|
787
|
+
<label>Table <select id="db-table"></select></label>
|
|
788
|
+
<input id="db-q" type="search" placeholder="Search" maxlength="200" />
|
|
789
|
+
<button type="button" id="db-apply" class="clear">Apply</button>
|
|
790
|
+
<button type="button" id="db-add-filter" class="clear">Add filter</button>
|
|
791
|
+
</div>
|
|
792
|
+
<div id="db-filters"></div>
|
|
793
|
+
<p id="db-rows-status" class="empty" hidden></p>
|
|
794
|
+
<div id="db-rows"></div>
|
|
795
|
+
<div id="db-pager" class="filter" hidden></div>
|
|
796
|
+
</section>
|
|
797
|
+
|
|
738
798
|
<section id="page-apis" class="page">
|
|
739
799
|
<div class="tabs">
|
|
740
800
|
<button type="button" data-tab="routes">Routes</button>
|
|
@@ -785,6 +845,7 @@ export function inspectHtml(): string {
|
|
|
785
845
|
<script>
|
|
786
846
|
var PAGES = {
|
|
787
847
|
"/_robodev": "overview",
|
|
848
|
+
"/_robodev/databases": "databases",
|
|
788
849
|
"/_robodev/apis": "apis",
|
|
789
850
|
"/_robodev/jobs": "jobs",
|
|
790
851
|
"/_robodev/emails": "emails",
|
|
@@ -799,6 +860,29 @@ export function inspectHtml(): string {
|
|
|
799
860
|
var storageFilter = null;
|
|
800
861
|
var socketFilter = null;
|
|
801
862
|
var lastState = null;
|
|
863
|
+
var dbSchema = null;
|
|
864
|
+
var dbSchemaError = null;
|
|
865
|
+
var dbSchemaLoading = false;
|
|
866
|
+
var dbRows = null;
|
|
867
|
+
var dbRowsError = null;
|
|
868
|
+
var dbSelected = null;
|
|
869
|
+
var dbOffset = 0;
|
|
870
|
+
var dbPositions = null;
|
|
871
|
+
var dbDrag = null;
|
|
872
|
+
var ER_W = 240;
|
|
873
|
+
var ER_XG = 72;
|
|
874
|
+
var ER_YG = 28;
|
|
875
|
+
var ER_HEAD = 28;
|
|
876
|
+
var ER_ROW = 18;
|
|
877
|
+
var ER_PAD = 16;
|
|
878
|
+
var DRAG_THRESHOLD = 4;
|
|
879
|
+
var SECRET_COLS = {
|
|
880
|
+
password_hash: true,
|
|
881
|
+
password: true,
|
|
882
|
+
secret: true,
|
|
883
|
+
access_token: true,
|
|
884
|
+
refresh_token: true
|
|
885
|
+
};
|
|
802
886
|
|
|
803
887
|
function pagePath() {
|
|
804
888
|
return location.pathname.replace(/\\/$/, "") || "/";
|
|
@@ -929,6 +1013,7 @@ export function inspectHtml(): string {
|
|
|
929
1013
|
for (var i = 0; i < sections.length; i++) {
|
|
930
1014
|
sections[i].className = sections[i].id === "page-" + page ? "page visible" : "page";
|
|
931
1015
|
}
|
|
1016
|
+
if (page === "databases") ensureDbSchema();
|
|
932
1017
|
}
|
|
933
1018
|
function setApisTab(tab) {
|
|
934
1019
|
apisTab = tab;
|
|
@@ -1180,6 +1265,525 @@ export function inspectHtml(): string {
|
|
|
1180
1265
|
setSocketTab("events");
|
|
1181
1266
|
if (lastState) render(lastState);
|
|
1182
1267
|
});
|
|
1268
|
+
function isSecretCol(name) {
|
|
1269
|
+
return Boolean(SECRET_COLS[String(name).toLowerCase()]);
|
|
1270
|
+
}
|
|
1271
|
+
function visibleCols(columns) {
|
|
1272
|
+
return (columns || []).filter(function (column) { return !isSecretCol(column.name); });
|
|
1273
|
+
}
|
|
1274
|
+
function tableIdentity(table) {
|
|
1275
|
+
return table.schema === "public" ? table.name : table.schema + "." + table.name;
|
|
1276
|
+
}
|
|
1277
|
+
function tableKeyOf(schema, name) {
|
|
1278
|
+
return schema + "." + name;
|
|
1279
|
+
}
|
|
1280
|
+
function estimateCardH(columnCount) {
|
|
1281
|
+
return ER_HEAD + Math.max(columnCount, 1) * ER_ROW + ER_PAD;
|
|
1282
|
+
}
|
|
1283
|
+
function fkCols(fk) {
|
|
1284
|
+
return Array.isArray(fk.columns) ? fk.columns : [];
|
|
1285
|
+
}
|
|
1286
|
+
function fkRefs(fk) {
|
|
1287
|
+
return Array.isArray(fk.referencedColumns) ? fk.referencedColumns : [];
|
|
1288
|
+
}
|
|
1289
|
+
function fkTitle(fk) {
|
|
1290
|
+
var cols = fkCols(fk);
|
|
1291
|
+
var refs = fkRefs(fk);
|
|
1292
|
+
return cols.map(function (column, index) {
|
|
1293
|
+
return column + " → " + (refs[index] || "?");
|
|
1294
|
+
}).join(", ");
|
|
1295
|
+
}
|
|
1296
|
+
function layoutDb(tables) {
|
|
1297
|
+
var keys = tables.map(function (table) { return tableKeyOf(table.schema, table.name); });
|
|
1298
|
+
var keySet = {};
|
|
1299
|
+
var incoming = {};
|
|
1300
|
+
var outgoing = {};
|
|
1301
|
+
for (var i = 0; i < keys.length; i++) {
|
|
1302
|
+
keySet[keys[i]] = true;
|
|
1303
|
+
incoming[keys[i]] = 0;
|
|
1304
|
+
outgoing[keys[i]] = [];
|
|
1305
|
+
}
|
|
1306
|
+
for (var t = 0; t < tables.length; t++) {
|
|
1307
|
+
var table = tables[t];
|
|
1308
|
+
var source = tableKeyOf(table.schema, table.name);
|
|
1309
|
+
var seen = {};
|
|
1310
|
+
var fks = table.foreignKeys || [];
|
|
1311
|
+
for (var f = 0; f < fks.length; f++) {
|
|
1312
|
+
var target = tableKeyOf(fks[f].referencedSchema, fks[f].referencedTable);
|
|
1313
|
+
if (!keySet[target] || target === source || seen[target]) continue;
|
|
1314
|
+
seen[target] = true;
|
|
1315
|
+
outgoing[source].push(target);
|
|
1316
|
+
incoming[target] = (incoming[target] || 0) + 1;
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
var layer = {};
|
|
1320
|
+
var queue = [];
|
|
1321
|
+
for (var k = 0; k < keys.length; k++) {
|
|
1322
|
+
if ((incoming[keys[k]] || 0) === 0) {
|
|
1323
|
+
layer[keys[k]] = 0;
|
|
1324
|
+
queue.push(keys[k]);
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1327
|
+
for (var qi = 0; qi < queue.length; qi++) {
|
|
1328
|
+
var current = queue[qi];
|
|
1329
|
+
var nextLayer = (layer[current] || 0) + 1;
|
|
1330
|
+
var dests = outgoing[current] || [];
|
|
1331
|
+
for (var d = 0; d < dests.length; d++) {
|
|
1332
|
+
if (layer[dests[d]] === undefined) {
|
|
1333
|
+
layer[dests[d]] = nextLayer;
|
|
1334
|
+
queue.push(dests[d]);
|
|
1335
|
+
}
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
for (var k2 = 0; k2 < keys.length; k2++) {
|
|
1339
|
+
if (layer[keys[k2]] === undefined) layer[keys[k2]] = 0;
|
|
1340
|
+
}
|
|
1341
|
+
var byLayer = {};
|
|
1342
|
+
for (var t2 = 0; t2 < tables.length; t2++) {
|
|
1343
|
+
var idx = layer[tableKeyOf(tables[t2].schema, tables[t2].name)] || 0;
|
|
1344
|
+
if (!byLayer[idx]) byLayer[idx] = [];
|
|
1345
|
+
byLayer[idx].push(tables[t2]);
|
|
1346
|
+
}
|
|
1347
|
+
var positions = {};
|
|
1348
|
+
var layerKeys = Object.keys(byLayer).map(Number).sort(function (a, b) { return a - b; });
|
|
1349
|
+
for (var li = 0; li < layerKeys.length; li++) {
|
|
1350
|
+
var layerTables = byLayer[layerKeys[li]];
|
|
1351
|
+
var y = 16;
|
|
1352
|
+
for (var lt = 0; lt < layerTables.length; lt++) {
|
|
1353
|
+
var laid = layerTables[lt];
|
|
1354
|
+
var vis = visibleCols(laid.columns);
|
|
1355
|
+
var key = tableKeyOf(laid.schema, laid.name);
|
|
1356
|
+
positions[key] = {
|
|
1357
|
+
x: 16 + layerKeys[li] * (ER_W + ER_XG),
|
|
1358
|
+
y: y,
|
|
1359
|
+
h: estimateCardH(vis.length)
|
|
1360
|
+
};
|
|
1361
|
+
y += positions[key].h + ER_YG;
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
return positions;
|
|
1365
|
+
}
|
|
1366
|
+
function selectedTable() {
|
|
1367
|
+
if (!dbSelected || !dbSchema) return null;
|
|
1368
|
+
for (var i = 0; i < dbSchema.length; i++) {
|
|
1369
|
+
if (dbSchema[i].schema === dbSelected.schema && dbSchema[i].name === dbSelected.name) {
|
|
1370
|
+
return dbSchema[i];
|
|
1371
|
+
}
|
|
1372
|
+
}
|
|
1373
|
+
return null;
|
|
1374
|
+
}
|
|
1375
|
+
function setDbStatus(id, message) {
|
|
1376
|
+
var el = document.getElementById(id);
|
|
1377
|
+
if (!message) {
|
|
1378
|
+
el.hidden = true;
|
|
1379
|
+
el.textContent = "";
|
|
1380
|
+
return;
|
|
1381
|
+
}
|
|
1382
|
+
el.hidden = false;
|
|
1383
|
+
el.textContent = message;
|
|
1384
|
+
}
|
|
1385
|
+
function drawDbEdges(svg) {
|
|
1386
|
+
while (svg.firstChild) svg.removeChild(svg.firstChild);
|
|
1387
|
+
if (!dbSchema || !dbPositions) return;
|
|
1388
|
+
for (var i = 0; i < dbSchema.length; i++) {
|
|
1389
|
+
var fks = dbSchema[i].foreignKeys || [];
|
|
1390
|
+
for (var f = 0; f < fks.length; f++) {
|
|
1391
|
+
var fk = fks[f];
|
|
1392
|
+
var src = tableKeyOf(fk.schema, fk.table);
|
|
1393
|
+
var tgt = tableKeyOf(fk.referencedSchema, fk.referencedTable);
|
|
1394
|
+
var a = dbPositions[src];
|
|
1395
|
+
var b = dbPositions[tgt];
|
|
1396
|
+
if (!a || !b) continue;
|
|
1397
|
+
var x1 = a.x + ER_W;
|
|
1398
|
+
var y1 = a.y + a.h / 2;
|
|
1399
|
+
var x2 = b.x;
|
|
1400
|
+
var y2 = b.y + b.h / 2;
|
|
1401
|
+
var mx = (x1 + x2) / 2;
|
|
1402
|
+
var path = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
|
1403
|
+
path.setAttribute("d", "M " + x1 + " " + y1 + " C " + mx + " " + y1 + ", " + mx + " " + y2 + ", " + x2 + " " + y2);
|
|
1404
|
+
path.setAttribute("fill", "none");
|
|
1405
|
+
path.setAttribute("stroke", "currentColor");
|
|
1406
|
+
path.setAttribute("stroke-opacity", "0.4");
|
|
1407
|
+
var title = document.createElementNS("http://www.w3.org/2000/svg", "title");
|
|
1408
|
+
title.textContent = fkTitle(fk);
|
|
1409
|
+
path.appendChild(title);
|
|
1410
|
+
svg.appendChild(path);
|
|
1411
|
+
}
|
|
1412
|
+
}
|
|
1413
|
+
}
|
|
1414
|
+
function growDbCanvas(canvas) {
|
|
1415
|
+
var maxX = 280;
|
|
1416
|
+
var maxY = 200;
|
|
1417
|
+
if (dbPositions) {
|
|
1418
|
+
var keys = Object.keys(dbPositions);
|
|
1419
|
+
for (var i = 0; i < keys.length; i++) {
|
|
1420
|
+
var pos = dbPositions[keys[i]];
|
|
1421
|
+
maxX = Math.max(maxX, pos.x + ER_W + 40);
|
|
1422
|
+
maxY = Math.max(maxY, pos.y + pos.h + 40);
|
|
1423
|
+
}
|
|
1424
|
+
}
|
|
1425
|
+
canvas.style.width = maxX + "px";
|
|
1426
|
+
canvas.style.height = maxY + "px";
|
|
1427
|
+
}
|
|
1428
|
+
function renderEr() {
|
|
1429
|
+
var canvas = document.getElementById("db-er");
|
|
1430
|
+
canvas.replaceChildren();
|
|
1431
|
+
if (!dbSchema || !dbSchema.length) {
|
|
1432
|
+
canvas.appendChild(emptyNode());
|
|
1433
|
+
canvas.style.width = "";
|
|
1434
|
+
canvas.style.height = "200px";
|
|
1435
|
+
return;
|
|
1436
|
+
}
|
|
1437
|
+
if (!dbPositions) dbPositions = layoutDb(dbSchema);
|
|
1438
|
+
growDbCanvas(canvas);
|
|
1439
|
+
var svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
|
1440
|
+
svg.setAttribute("class", "er-svg");
|
|
1441
|
+
canvas.appendChild(svg);
|
|
1442
|
+
drawDbEdges(svg);
|
|
1443
|
+
var selectedKey = dbSelected ? tableKeyOf(dbSelected.schema, dbSelected.name) : "";
|
|
1444
|
+
for (var i = 0; i < dbSchema.length; i++) {
|
|
1445
|
+
(function (table) {
|
|
1446
|
+
var key = tableKeyOf(table.schema, table.name);
|
|
1447
|
+
var pos = dbPositions[key];
|
|
1448
|
+
if (!pos) return;
|
|
1449
|
+
var vis = visibleCols(table.columns);
|
|
1450
|
+
var card = document.createElement("div");
|
|
1451
|
+
card.className = "er-card" + (key === selectedKey ? " selected" : "");
|
|
1452
|
+
card.style.left = pos.x + "px";
|
|
1453
|
+
card.style.top = pos.y + "px";
|
|
1454
|
+
var heading = document.createElement("h3");
|
|
1455
|
+
heading.textContent = tableIdentity(table);
|
|
1456
|
+
card.appendChild(heading);
|
|
1457
|
+
for (var c = 0; c < vis.length; c++) {
|
|
1458
|
+
var row = document.createElement("div");
|
|
1459
|
+
row.className = "er-col";
|
|
1460
|
+
var pk = document.createElement("span");
|
|
1461
|
+
pk.className = "pk";
|
|
1462
|
+
pk.textContent = vis[c].primaryKey ? "•" : "";
|
|
1463
|
+
var name = document.createElement("span");
|
|
1464
|
+
name.textContent = vis[c].name;
|
|
1465
|
+
var type = document.createElement("span");
|
|
1466
|
+
type.className = "type";
|
|
1467
|
+
type.textContent = vis[c].type + (vis[c].notNull ? "" : "?");
|
|
1468
|
+
row.appendChild(pk);
|
|
1469
|
+
row.appendChild(name);
|
|
1470
|
+
row.appendChild(type);
|
|
1471
|
+
card.appendChild(row);
|
|
1472
|
+
}
|
|
1473
|
+
card.addEventListener("pointerdown", function (event) {
|
|
1474
|
+
if (event.button !== 0) return;
|
|
1475
|
+
card.setPointerCapture(event.pointerId);
|
|
1476
|
+
dbDrag = {
|
|
1477
|
+
key: key,
|
|
1478
|
+
sx: event.clientX,
|
|
1479
|
+
sy: event.clientY,
|
|
1480
|
+
ox: pos.x,
|
|
1481
|
+
oy: pos.y,
|
|
1482
|
+
moved: false
|
|
1483
|
+
};
|
|
1484
|
+
});
|
|
1485
|
+
card.addEventListener("pointermove", function (event) {
|
|
1486
|
+
if (!dbDrag || dbDrag.key !== key) return;
|
|
1487
|
+
var dx = event.clientX - dbDrag.sx;
|
|
1488
|
+
var dy = event.clientY - dbDrag.sy;
|
|
1489
|
+
if (!dbDrag.moved && Math.abs(dx) < DRAG_THRESHOLD && Math.abs(dy) < DRAG_THRESHOLD) return;
|
|
1490
|
+
dbDrag.moved = true;
|
|
1491
|
+
pos.x = Math.max(0, dbDrag.ox + dx);
|
|
1492
|
+
pos.y = Math.max(0, dbDrag.oy + dy);
|
|
1493
|
+
card.style.left = pos.x + "px";
|
|
1494
|
+
card.style.top = pos.y + "px";
|
|
1495
|
+
growDbCanvas(canvas);
|
|
1496
|
+
drawDbEdges(svg);
|
|
1497
|
+
});
|
|
1498
|
+
card.addEventListener("pointerup", function () {
|
|
1499
|
+
if (!dbDrag || dbDrag.key !== key) return;
|
|
1500
|
+
var moved = dbDrag.moved;
|
|
1501
|
+
dbDrag = null;
|
|
1502
|
+
if (!moved) selectDbTable(table.schema, table.name, true);
|
|
1503
|
+
});
|
|
1504
|
+
canvas.appendChild(card);
|
|
1505
|
+
})(dbSchema[i]);
|
|
1506
|
+
}
|
|
1507
|
+
}
|
|
1508
|
+
function fillTableSelect() {
|
|
1509
|
+
var select = document.getElementById("db-table");
|
|
1510
|
+
select.replaceChildren();
|
|
1511
|
+
if (!dbSchema) return;
|
|
1512
|
+
for (var i = 0; i < dbSchema.length; i++) {
|
|
1513
|
+
var option = document.createElement("option");
|
|
1514
|
+
option.value = tableKeyOf(dbSchema[i].schema, dbSchema[i].name);
|
|
1515
|
+
option.textContent = tableIdentity(dbSchema[i]);
|
|
1516
|
+
if (dbSelected && dbSelected.schema === dbSchema[i].schema && dbSelected.name === dbSchema[i].name) {
|
|
1517
|
+
option.selected = true;
|
|
1518
|
+
}
|
|
1519
|
+
select.appendChild(option);
|
|
1520
|
+
}
|
|
1521
|
+
}
|
|
1522
|
+
function filterableColumns() {
|
|
1523
|
+
var table = selectedTable();
|
|
1524
|
+
return table ? visibleCols(table.columns) : [];
|
|
1525
|
+
}
|
|
1526
|
+
function addDbFilterRow(preset) {
|
|
1527
|
+
var columns = filterableColumns();
|
|
1528
|
+
var row = document.createElement("div");
|
|
1529
|
+
row.className = "db-filter";
|
|
1530
|
+
var col = document.createElement("select");
|
|
1531
|
+
for (var i = 0; i < columns.length; i++) {
|
|
1532
|
+
var option = document.createElement("option");
|
|
1533
|
+
option.value = columns[i].name;
|
|
1534
|
+
option.textContent = columns[i].name;
|
|
1535
|
+
if (preset && preset.column === columns[i].name) option.selected = true;
|
|
1536
|
+
col.appendChild(option);
|
|
1537
|
+
}
|
|
1538
|
+
var op = document.createElement("select");
|
|
1539
|
+
var ops = [
|
|
1540
|
+
{ value: "eq", label: "equals" },
|
|
1541
|
+
{ value: "contains", label: "contains" },
|
|
1542
|
+
{ value: "is_null", label: "is empty" },
|
|
1543
|
+
{ value: "is_not_null", label: "is not empty" }
|
|
1544
|
+
];
|
|
1545
|
+
for (var o = 0; o < ops.length; o++) {
|
|
1546
|
+
var opOpt = document.createElement("option");
|
|
1547
|
+
opOpt.value = ops[o].value;
|
|
1548
|
+
opOpt.textContent = ops[o].label;
|
|
1549
|
+
if (preset && preset.op === ops[o].value) opOpt.selected = true;
|
|
1550
|
+
op.appendChild(opOpt);
|
|
1551
|
+
}
|
|
1552
|
+
var value = document.createElement("input");
|
|
1553
|
+
value.type = "text";
|
|
1554
|
+
value.placeholder = "Value";
|
|
1555
|
+
if (preset && preset.value) value.value = preset.value;
|
|
1556
|
+
var remove = document.createElement("button");
|
|
1557
|
+
remove.type = "button";
|
|
1558
|
+
remove.className = "clear";
|
|
1559
|
+
remove.textContent = "Remove";
|
|
1560
|
+
function syncValue() {
|
|
1561
|
+
value.hidden = op.value === "is_null" || op.value === "is_not_null";
|
|
1562
|
+
}
|
|
1563
|
+
op.addEventListener("change", syncValue);
|
|
1564
|
+
remove.addEventListener("click", function () { row.remove(); });
|
|
1565
|
+
syncValue();
|
|
1566
|
+
row.appendChild(col);
|
|
1567
|
+
row.appendChild(op);
|
|
1568
|
+
row.appendChild(value);
|
|
1569
|
+
row.appendChild(remove);
|
|
1570
|
+
document.getElementById("db-filters").appendChild(row);
|
|
1571
|
+
}
|
|
1572
|
+
function collectDbFilters() {
|
|
1573
|
+
var rows = document.querySelectorAll("#db-filters .db-filter");
|
|
1574
|
+
var filters = [];
|
|
1575
|
+
for (var i = 0; i < rows.length; i++) {
|
|
1576
|
+
var selects = rows[i].querySelectorAll("select");
|
|
1577
|
+
var input = rows[i].querySelector("input");
|
|
1578
|
+
var column = selects[0] && selects[0].value;
|
|
1579
|
+
var op = selects[1] && selects[1].value;
|
|
1580
|
+
if (!column || !op) continue;
|
|
1581
|
+
if (isSecretCol(column)) continue;
|
|
1582
|
+
var filter = { column: column, op: op };
|
|
1583
|
+
if (op === "eq" || op === "contains") filter.value = input ? input.value : "";
|
|
1584
|
+
if ((op === "eq" || op === "contains") && !filter.value) continue;
|
|
1585
|
+
filters.push(filter);
|
|
1586
|
+
}
|
|
1587
|
+
return filters;
|
|
1588
|
+
}
|
|
1589
|
+
function clearDbFilters() {
|
|
1590
|
+
document.getElementById("db-filters").replaceChildren();
|
|
1591
|
+
}
|
|
1592
|
+
function renderDbRows() {
|
|
1593
|
+
var host = document.getElementById("db-rows");
|
|
1594
|
+
if (dbRowsError) {
|
|
1595
|
+
fill(host, emptyNode());
|
|
1596
|
+
return;
|
|
1597
|
+
}
|
|
1598
|
+
if (!dbRows) {
|
|
1599
|
+
host.replaceChildren();
|
|
1600
|
+
return;
|
|
1601
|
+
}
|
|
1602
|
+
var headers = dbRows.columns || [];
|
|
1603
|
+
fill(host, table(
|
|
1604
|
+
headers,
|
|
1605
|
+
dbRows.rows.map(function (row) {
|
|
1606
|
+
return {
|
|
1607
|
+
cells: headers.map(function (column) {
|
|
1608
|
+
var value = row[column];
|
|
1609
|
+
if (isSecretCol(column)) return "***";
|
|
1610
|
+
if (value === null || value === undefined) return "null";
|
|
1611
|
+
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
|
|
1612
|
+
return String(value);
|
|
1613
|
+
}
|
|
1614
|
+
try { return JSON.stringify(value); }
|
|
1615
|
+
catch (e) { return String(value); }
|
|
1616
|
+
})
|
|
1617
|
+
};
|
|
1618
|
+
})
|
|
1619
|
+
));
|
|
1620
|
+
var pager = document.getElementById("db-pager");
|
|
1621
|
+
var total = dbRows.total || 0;
|
|
1622
|
+
if (!total) {
|
|
1623
|
+
pager.hidden = true;
|
|
1624
|
+
pager.replaceChildren();
|
|
1625
|
+
return;
|
|
1626
|
+
}
|
|
1627
|
+
pager.hidden = false;
|
|
1628
|
+
pager.replaceChildren();
|
|
1629
|
+
var start = dbOffset + 1;
|
|
1630
|
+
var end = Math.min(dbOffset + 50, total);
|
|
1631
|
+
var label = document.createElement("span");
|
|
1632
|
+
label.textContent = start + "–" + end + " of " + total;
|
|
1633
|
+
var prev = document.createElement("button");
|
|
1634
|
+
prev.type = "button";
|
|
1635
|
+
prev.className = "clear";
|
|
1636
|
+
prev.textContent = "Prev";
|
|
1637
|
+
prev.disabled = dbOffset <= 0;
|
|
1638
|
+
prev.addEventListener("click", function () {
|
|
1639
|
+
dbOffset = Math.max(0, dbOffset - 50);
|
|
1640
|
+
loadDbRows();
|
|
1641
|
+
});
|
|
1642
|
+
var next = document.createElement("button");
|
|
1643
|
+
next.type = "button";
|
|
1644
|
+
next.className = "clear";
|
|
1645
|
+
next.textContent = "Next";
|
|
1646
|
+
next.disabled = dbOffset + 50 >= total;
|
|
1647
|
+
next.addEventListener("click", function () {
|
|
1648
|
+
dbOffset += 50;
|
|
1649
|
+
loadDbRows();
|
|
1650
|
+
});
|
|
1651
|
+
pager.appendChild(label);
|
|
1652
|
+
pager.appendChild(prev);
|
|
1653
|
+
pager.appendChild(next);
|
|
1654
|
+
}
|
|
1655
|
+
function defaultDbTable() {
|
|
1656
|
+
if (!dbSchema || !dbSchema.length) return null;
|
|
1657
|
+
for (var i = 0; i < dbSchema.length; i++) {
|
|
1658
|
+
if (dbSchema[i].schema === "public") return dbSchema[i];
|
|
1659
|
+
}
|
|
1660
|
+
return dbSchema[0];
|
|
1661
|
+
}
|
|
1662
|
+
function selectDbTable(schema, name, resetQuery) {
|
|
1663
|
+
dbSelected = { schema: schema, name: name };
|
|
1664
|
+
if (resetQuery) {
|
|
1665
|
+
dbOffset = 0;
|
|
1666
|
+
document.getElementById("db-q").value = "";
|
|
1667
|
+
clearDbFilters();
|
|
1668
|
+
}
|
|
1669
|
+
fillTableSelect();
|
|
1670
|
+
renderEr();
|
|
1671
|
+
loadDbRows();
|
|
1672
|
+
}
|
|
1673
|
+
async function loadDbRows() {
|
|
1674
|
+
if (!dbSelected) {
|
|
1675
|
+
dbRows = null;
|
|
1676
|
+
dbRowsError = null;
|
|
1677
|
+
setDbStatus("db-rows-status", dbSchema && dbSchema.length ? "" : "None yet");
|
|
1678
|
+
renderDbRows();
|
|
1679
|
+
return;
|
|
1680
|
+
}
|
|
1681
|
+
var params = new URLSearchParams();
|
|
1682
|
+
params.set("schema", dbSelected.schema);
|
|
1683
|
+
params.set("table", dbSelected.name);
|
|
1684
|
+
params.set("limit", "50");
|
|
1685
|
+
params.set("offset", String(dbOffset));
|
|
1686
|
+
var q = document.getElementById("db-q").value.trim().slice(0, 200);
|
|
1687
|
+
if (q) params.set("q", q);
|
|
1688
|
+
var filters = collectDbFilters();
|
|
1689
|
+
if (filters.length) params.set("filters", JSON.stringify(filters));
|
|
1690
|
+
try {
|
|
1691
|
+
var response = await fetch("/_robodev/db/rows.json?" + params.toString());
|
|
1692
|
+
var body = await response.json();
|
|
1693
|
+
if (!response.ok) {
|
|
1694
|
+
dbRows = null;
|
|
1695
|
+
dbRowsError = body.error || ("HTTP " + response.status);
|
|
1696
|
+
setDbStatus("db-rows-status", dbRowsError);
|
|
1697
|
+
renderDbRows();
|
|
1698
|
+
return;
|
|
1699
|
+
}
|
|
1700
|
+
dbRows = body;
|
|
1701
|
+
dbRowsError = null;
|
|
1702
|
+
setDbStatus("db-rows-status", body.rows && body.rows.length ? "" : "None yet");
|
|
1703
|
+
renderDbRows();
|
|
1704
|
+
} catch (e) {
|
|
1705
|
+
dbRows = null;
|
|
1706
|
+
dbRowsError = "Could not load rows";
|
|
1707
|
+
setDbStatus("db-rows-status", dbRowsError);
|
|
1708
|
+
renderDbRows();
|
|
1709
|
+
}
|
|
1710
|
+
}
|
|
1711
|
+
async function loadDbSchema(resetViewport) {
|
|
1712
|
+
dbSchemaLoading = true;
|
|
1713
|
+
dbSchemaError = null;
|
|
1714
|
+
setDbStatus("db-schema-status", "");
|
|
1715
|
+
try {
|
|
1716
|
+
var response = await fetch("/_robodev/db/schema.json");
|
|
1717
|
+
var body = await response.json();
|
|
1718
|
+
if (!response.ok) {
|
|
1719
|
+
dbSchema = null;
|
|
1720
|
+
dbPositions = null;
|
|
1721
|
+
dbSelected = null;
|
|
1722
|
+
dbSchemaError = body.error || ("HTTP " + response.status);
|
|
1723
|
+
setDbStatus("db-schema-status", dbSchemaError);
|
|
1724
|
+
fillTableSelect();
|
|
1725
|
+
renderEr();
|
|
1726
|
+
dbRows = null;
|
|
1727
|
+
renderDbRows();
|
|
1728
|
+
return;
|
|
1729
|
+
}
|
|
1730
|
+
dbSchema = body.tables || [];
|
|
1731
|
+
dbPositions = layoutDb(dbSchema);
|
|
1732
|
+
if (resetViewport) {
|
|
1733
|
+
var wrap = document.getElementById("db-er-wrap");
|
|
1734
|
+
wrap.scrollLeft = 0;
|
|
1735
|
+
wrap.scrollTop = 0;
|
|
1736
|
+
}
|
|
1737
|
+
var next = defaultDbTable();
|
|
1738
|
+
if (next) {
|
|
1739
|
+
selectDbTable(next.schema, next.name, true);
|
|
1740
|
+
} else {
|
|
1741
|
+
dbSelected = null;
|
|
1742
|
+
fillTableSelect();
|
|
1743
|
+
renderEr();
|
|
1744
|
+
dbRows = null;
|
|
1745
|
+
setDbStatus("db-rows-status", "None yet");
|
|
1746
|
+
renderDbRows();
|
|
1747
|
+
}
|
|
1748
|
+
} catch (e) {
|
|
1749
|
+
dbSchema = null;
|
|
1750
|
+
dbPositions = null;
|
|
1751
|
+
dbSchemaError = "Could not load schema";
|
|
1752
|
+
setDbStatus("db-schema-status", dbSchemaError);
|
|
1753
|
+
renderEr();
|
|
1754
|
+
} finally {
|
|
1755
|
+
dbSchemaLoading = false;
|
|
1756
|
+
}
|
|
1757
|
+
}
|
|
1758
|
+
function ensureDbSchema() {
|
|
1759
|
+
if (dbSchemaLoading || dbSchema || dbSchemaError) return;
|
|
1760
|
+
loadDbSchema(true);
|
|
1761
|
+
}
|
|
1762
|
+
document.getElementById("db-reset").addEventListener("click", function () {
|
|
1763
|
+
dbSchema = null;
|
|
1764
|
+
dbSchemaError = null;
|
|
1765
|
+
dbPositions = null;
|
|
1766
|
+
loadDbSchema(true);
|
|
1767
|
+
});
|
|
1768
|
+
document.getElementById("db-table").addEventListener("change", function (event) {
|
|
1769
|
+
var value = event.target.value;
|
|
1770
|
+
var dot = value.indexOf(".");
|
|
1771
|
+
if (dot < 0) return;
|
|
1772
|
+
selectDbTable(value.slice(0, dot), value.slice(dot + 1), true);
|
|
1773
|
+
});
|
|
1774
|
+
document.getElementById("db-apply").addEventListener("click", function () {
|
|
1775
|
+
dbOffset = 0;
|
|
1776
|
+
loadDbRows();
|
|
1777
|
+
});
|
|
1778
|
+
document.getElementById("db-q").addEventListener("keydown", function (event) {
|
|
1779
|
+
if (event.key === "Enter") {
|
|
1780
|
+
dbOffset = 0;
|
|
1781
|
+
loadDbRows();
|
|
1782
|
+
}
|
|
1783
|
+
});
|
|
1784
|
+
document.getElementById("db-add-filter").addEventListener("click", function () {
|
|
1785
|
+
addDbFilterRow();
|
|
1786
|
+
});
|
|
1183
1787
|
async function poll() {
|
|
1184
1788
|
try {
|
|
1185
1789
|
var state = await fetch("/_robodev/state.json").then(function (r) { return r.json(); });
|
package/src/dev-server.ts
CHANGED
|
@@ -4,9 +4,12 @@ import websocket from "@fastify/websocket";
|
|
|
4
4
|
import Fastify, { type FastifyInstance, type FastifyReply, type FastifyRequest } from "fastify";
|
|
5
5
|
import type { WebSocket } from "ws";
|
|
6
6
|
import {
|
|
7
|
+
BrowseError,
|
|
7
8
|
SOCKET_CLOSE_POLICY,
|
|
8
9
|
SOCKET_IDLE_PING_MS,
|
|
9
10
|
bindSocketSession,
|
|
11
|
+
browseRows,
|
|
12
|
+
browseSchema,
|
|
10
13
|
buildOpenApi,
|
|
11
14
|
handleLocalAuth,
|
|
12
15
|
isLocalAuthPath,
|
|
@@ -21,6 +24,7 @@ import {
|
|
|
21
24
|
swaggerHtml,
|
|
22
25
|
verifyStorageServeToken,
|
|
23
26
|
type AuthQueryable,
|
|
27
|
+
type BrowseRowFilter,
|
|
24
28
|
type RuntimeRoute,
|
|
25
29
|
type RuntimeSocket,
|
|
26
30
|
} from "@robodev-ai/runtime";
|
|
@@ -82,6 +86,43 @@ function rawBodyOf(request: FastifyRequest): Buffer | undefined {
|
|
|
82
86
|
return (request as FastifyRequest & { rawBody?: Buffer }).rawBody;
|
|
83
87
|
}
|
|
84
88
|
|
|
89
|
+
const BROWSE_FILTER_OPS = new Set(["eq", "contains", "is_null", "is_not_null"]);
|
|
90
|
+
|
|
91
|
+
function parseInspectRowFilters(raw: string | undefined): BrowseRowFilter[] | undefined {
|
|
92
|
+
if (!raw) return undefined;
|
|
93
|
+
let parsed: unknown;
|
|
94
|
+
try {
|
|
95
|
+
parsed = JSON.parse(raw);
|
|
96
|
+
} catch {
|
|
97
|
+
throw new BrowseError(400, "Invalid filters");
|
|
98
|
+
}
|
|
99
|
+
if (!Array.isArray(parsed)) {
|
|
100
|
+
throw new BrowseError(400, "Invalid filters");
|
|
101
|
+
}
|
|
102
|
+
return parsed.map((item) => {
|
|
103
|
+
if (!item || typeof item !== "object") {
|
|
104
|
+
throw new BrowseError(400, "Invalid filters");
|
|
105
|
+
}
|
|
106
|
+
const filter = item as { column?: unknown; op?: unknown; value?: unknown };
|
|
107
|
+
if (typeof filter.column !== "string" || !BROWSE_FILTER_OPS.has(String(filter.op))) {
|
|
108
|
+
throw new BrowseError(400, "Invalid filters");
|
|
109
|
+
}
|
|
110
|
+
const next: BrowseRowFilter = {
|
|
111
|
+
column: filter.column,
|
|
112
|
+
op: filter.op as BrowseRowFilter["op"],
|
|
113
|
+
};
|
|
114
|
+
if (filter.value !== undefined) next.value = String(filter.value);
|
|
115
|
+
return next;
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function sendInspectBrowseError(reply: FastifyReply, error: unknown) {
|
|
120
|
+
if (error instanceof BrowseError) {
|
|
121
|
+
return reply.code(error.statusCode).send({ error: error.message });
|
|
122
|
+
}
|
|
123
|
+
throw error;
|
|
124
|
+
}
|
|
125
|
+
|
|
85
126
|
function queryRecord(query: unknown): Record<string, string> {
|
|
86
127
|
if (!query || typeof query !== "object") return {};
|
|
87
128
|
const out: Record<string, string> = {};
|
|
@@ -478,6 +519,47 @@ export async function startDevServer(
|
|
|
478
519
|
}),
|
|
479
520
|
);
|
|
480
521
|
}
|
|
522
|
+
if (pathname === "/_robodev/db/schema.json") {
|
|
523
|
+
const runtime = getRuntime();
|
|
524
|
+
if (!runtime?.pool) {
|
|
525
|
+
return reply.code(503).send({ error: "database unavailable" });
|
|
526
|
+
}
|
|
527
|
+
try {
|
|
528
|
+
return reply.send({ tables: await browseSchema(runtime.pool) });
|
|
529
|
+
} catch (error) {
|
|
530
|
+
return sendInspectBrowseError(reply, error);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
if (pathname === "/_robodev/db/rows.json") {
|
|
534
|
+
const runtime = getRuntime();
|
|
535
|
+
if (!runtime?.pool) {
|
|
536
|
+
return reply.code(503).send({ error: "database unavailable" });
|
|
537
|
+
}
|
|
538
|
+
const query = queryRecord(request.query);
|
|
539
|
+
if (!query.schema) {
|
|
540
|
+
return reply.code(400).send({ error: "schema is required" });
|
|
541
|
+
}
|
|
542
|
+
if (!query.table) {
|
|
543
|
+
return reply.code(400).send({ error: "table is required" });
|
|
544
|
+
}
|
|
545
|
+
const limit = query.limit ? Number(query.limit) : 50;
|
|
546
|
+
const offset = query.offset ? Number(query.offset) : 0;
|
|
547
|
+
if (!Number.isFinite(limit) || !Number.isFinite(offset)) {
|
|
548
|
+
return reply.code(400).send({ error: "Invalid limit or offset" });
|
|
549
|
+
}
|
|
550
|
+
const q = query.q?.trim().slice(0, 200);
|
|
551
|
+
try {
|
|
552
|
+
const filters = parseInspectRowFilters(query.filters);
|
|
553
|
+
return reply.send(
|
|
554
|
+
await browseRows(runtime.pool, query.schema, query.table, limit, offset, {
|
|
555
|
+
q: q || undefined,
|
|
556
|
+
filters,
|
|
557
|
+
}),
|
|
558
|
+
);
|
|
559
|
+
} catch (error) {
|
|
560
|
+
return sendInspectBrowseError(reply, error);
|
|
561
|
+
}
|
|
562
|
+
}
|
|
481
563
|
return reply.type("text/html; charset=utf-8").send(options.inspect.html());
|
|
482
564
|
}
|
|
483
565
|
|
package/src/index.ts
CHANGED
|
@@ -489,7 +489,31 @@ async function assertCreatable(root: string): Promise<void> {
|
|
|
489
489
|
}
|
|
490
490
|
}
|
|
491
491
|
|
|
492
|
-
|
|
492
|
+
const CREATE_PNPM_VERSION = "10.14.0";
|
|
493
|
+
|
|
494
|
+
async function spawnInherit(command: string, args: string[], cwd?: string): Promise<void> {
|
|
495
|
+
await new Promise<void>((resolve, reject) => {
|
|
496
|
+
const child = spawn(command, args, { cwd, stdio: "inherit" });
|
|
497
|
+
child.on("error", reject);
|
|
498
|
+
child.on("exit", (code) => {
|
|
499
|
+
if (code === 0) resolve();
|
|
500
|
+
else reject(new Error(`${command} ${args.join(" ")} failed (${code})`));
|
|
501
|
+
});
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
async function ensurePnpm(): Promise<{ command: string; prefix: string[] }> {
|
|
506
|
+
if (await commandAvailable("pnpm")) return { command: "pnpm", prefix: [] };
|
|
507
|
+
if (!(await commandAvailable("corepack"))) {
|
|
508
|
+
throw new Error("pnpm is not available. Enable it with Corepack (Node 22) or install pnpm.");
|
|
509
|
+
}
|
|
510
|
+
await spawnInherit("corepack", ["enable"]);
|
|
511
|
+
await spawnInherit("corepack", ["prepare", `pnpm@${CREATE_PNPM_VERSION}`, "--activate"]);
|
|
512
|
+
if (await commandAvailable("pnpm")) return { command: "pnpm", prefix: [] };
|
|
513
|
+
return { command: "corepack", prefix: ["pnpm"] };
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
async function pnpmInstallRobodevDeps(root: string): Promise<void> {
|
|
493
517
|
const pkgPath = join(root, "robodev", "package.json");
|
|
494
518
|
let raw: string;
|
|
495
519
|
try {
|
|
@@ -506,18 +530,19 @@ async function npmInstallRobodevDeps(root: string): Promise<void> {
|
|
|
506
530
|
const deps = pkg.dependencies ?? {};
|
|
507
531
|
const extra = Object.keys(deps).filter((name) => name !== "@robodev-ai/sdk");
|
|
508
532
|
if (extra.length === 0) return;
|
|
509
|
-
console.log("Installing robodev/
|
|
510
|
-
await
|
|
533
|
+
console.log("Installing robodev/ packages…");
|
|
534
|
+
await pnpmInstall(join(root, "robodev"));
|
|
511
535
|
}
|
|
512
536
|
|
|
513
|
-
async function
|
|
514
|
-
console.log("Installing
|
|
537
|
+
async function pnpmInstall(root: string): Promise<void> {
|
|
538
|
+
console.log("Installing packages…");
|
|
539
|
+
const pnpm = await ensurePnpm();
|
|
515
540
|
await new Promise<void>((resolve, reject) => {
|
|
516
|
-
const child = spawn(
|
|
541
|
+
const child = spawn(pnpm.command, [...pnpm.prefix, "install"], { cwd: root, stdio: "inherit" });
|
|
517
542
|
child.on("error", reject);
|
|
518
543
|
child.on("exit", (code) => {
|
|
519
544
|
if (code === 0) resolve();
|
|
520
|
-
else reject(new Error(`
|
|
545
|
+
else reject(new Error(`pnpm install failed (${code})`));
|
|
521
546
|
});
|
|
522
547
|
});
|
|
523
548
|
}
|
|
@@ -607,20 +632,20 @@ async function runCreate(args: string[]): Promise<void> {
|
|
|
607
632
|
const liveUrl = await runDeploy(false, { root, frontendFiles, skipFrontendBuild });
|
|
608
633
|
|
|
609
634
|
try {
|
|
610
|
-
await
|
|
611
|
-
await
|
|
635
|
+
await pnpmInstall(root);
|
|
636
|
+
await pnpmInstallRobodevDeps(root);
|
|
612
637
|
} catch (error) {
|
|
613
638
|
console.log(error instanceof Error ? error.message : error);
|
|
614
639
|
if (starter.kind === "backend") {
|
|
615
640
|
console.log(
|
|
616
|
-
"
|
|
641
|
+
"pnpm install failed. Copy the API URL printed above into Lovable, Bolt, or v0 as VITE_API_URL.",
|
|
617
642
|
);
|
|
618
643
|
console.log("https://robodev.povio.dev/docs/frontend");
|
|
619
644
|
} else if (await hasAppsFe(root)) {
|
|
620
|
-
console.log("Run `
|
|
645
|
+
console.log("Run `pnpm install` in the project folder.");
|
|
621
646
|
console.log("bun is required for apps/fe. APIs are already deployed.");
|
|
622
647
|
} else {
|
|
623
|
-
console.log("Run `
|
|
648
|
+
console.log("Run `pnpm install` in the project folder, then `pnpm run dev`.");
|
|
624
649
|
}
|
|
625
650
|
if (liveUrl) {
|
|
626
651
|
printLiveUrl(liveUrl);
|
|
@@ -650,7 +675,7 @@ async function runCreate(args: string[]): Promise<void> {
|
|
|
650
675
|
console.log(" The project App URL serves the hosted Welcome page.");
|
|
651
676
|
console.log(" Edit index.html and src/, then robodev deploy (or Code + Save).");
|
|
652
677
|
console.log(" robodev dev # local APIs");
|
|
653
|
-
console.log("
|
|
678
|
+
console.log(" pnpm run dev:fe # optional local Vite");
|
|
654
679
|
}
|
|
655
680
|
if (liveUrl) {
|
|
656
681
|
printLiveUrl(liveUrl);
|
|
@@ -4,13 +4,39 @@ import tailwindCSS from "@tailwindcss/vite";
|
|
|
4
4
|
import { devtools } from "@tanstack/devtools-vite";
|
|
5
5
|
import { tanstackRouter } from "@tanstack/router-plugin/vite";
|
|
6
6
|
import viteReact from "@vitejs/plugin-react";
|
|
7
|
-
import { readFileSync } from "node:fs";
|
|
7
|
+
import { copyFileSync, existsSync, readdirSync, readFileSync } from "node:fs";
|
|
8
8
|
import { join } from "node:path";
|
|
9
|
-
import { defineConfig as defineViteConfig, mergeConfig } from "vite";
|
|
9
|
+
import { defineConfig as defineViteConfig, mergeConfig, type Plugin } from "vite";
|
|
10
10
|
import devtoolsJson from "vite-plugin-devtools-json";
|
|
11
11
|
import { defineConfig as defineVitestConfig } from "vitest/config";
|
|
12
12
|
import { reactIconsSprite } from "react-icons-sprite/vite";
|
|
13
13
|
|
|
14
|
+
/** react-icons-sprite maps Trash2 → trash2.mjs and MenuIcon → menu-icon.mjs; lucide ships trash-2.mjs / menu.mjs. */
|
|
15
|
+
function lucideSpriteIconAliases(): Plugin {
|
|
16
|
+
return {
|
|
17
|
+
name: "lucide-sprite-icon-aliases",
|
|
18
|
+
enforce: "pre",
|
|
19
|
+
apply: "build",
|
|
20
|
+
buildStart() {
|
|
21
|
+
const iconsDir = join(process.cwd(), "node_modules", "lucide-react", "dist", "esm", "icons");
|
|
22
|
+
if (!existsSync(iconsDir)) return;
|
|
23
|
+
for (const file of readdirSync(iconsDir)) {
|
|
24
|
+
if (!file.endsWith(".mjs")) continue;
|
|
25
|
+
const src = join(iconsDir, file);
|
|
26
|
+
const digit = /^(.+)-(\d+)\.mjs$/.exec(file);
|
|
27
|
+
if (digit) {
|
|
28
|
+
const dest = join(iconsDir, `${digit[1]}${digit[2]}.mjs`);
|
|
29
|
+
if (!existsSync(dest)) copyFileSync(src, dest);
|
|
30
|
+
}
|
|
31
|
+
if (!file.endsWith("-icon.mjs")) {
|
|
32
|
+
const dest = join(iconsDir, file.replace(/\.mjs$/, "-icon.mjs"));
|
|
33
|
+
if (!existsSync(dest)) copyFileSync(src, dest);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
14
40
|
const repoRoot = new URL("../..", import.meta.url).pathname;
|
|
15
41
|
|
|
16
42
|
function readDotEnvKey(root: string, key: string): string | undefined {
|
|
@@ -71,6 +97,7 @@ const createViteConfig = (isDevServer: boolean) => ({
|
|
|
71
97
|
devtools({
|
|
72
98
|
eventBusConfig: { enabled: false },
|
|
73
99
|
}),
|
|
100
|
+
lucideSpriteIconAliases(),
|
|
74
101
|
iconsSpritePlugin,
|
|
75
102
|
viteReact({
|
|
76
103
|
babel: {
|
|
@@ -7,7 +7,7 @@ The project App URL serves the hosted Welcome page. Edit `index.html` and `src/`
|
|
|
7
7
|
```bash
|
|
8
8
|
robodev create my-app
|
|
9
9
|
robodev dev # local APIs
|
|
10
|
-
|
|
10
|
+
pnpm run dev:fe # optional local Vite
|
|
11
11
|
```
|
|
12
12
|
|
|
13
13
|
`robodev create` (TTY and non-TTY) uses this starter by default. Pass `--template space` for Space. Lovable, Bolt, or v0 should use `--template backend`.
|
|
@@ -4,13 +4,39 @@ import tailwindCSS from "@tailwindcss/vite";
|
|
|
4
4
|
import { devtools } from "@tanstack/devtools-vite";
|
|
5
5
|
import { tanstackRouter } from "@tanstack/router-plugin/vite";
|
|
6
6
|
import viteReact from "@vitejs/plugin-react";
|
|
7
|
-
import { readFileSync } from "node:fs";
|
|
7
|
+
import { copyFileSync, existsSync, readdirSync, readFileSync } from "node:fs";
|
|
8
8
|
import { join } from "node:path";
|
|
9
|
-
import { defineConfig as defineViteConfig, mergeConfig } from "vite";
|
|
9
|
+
import { defineConfig as defineViteConfig, mergeConfig, type Plugin } from "vite";
|
|
10
10
|
import devtoolsJson from "vite-plugin-devtools-json";
|
|
11
11
|
import { defineConfig as defineVitestConfig } from "vitest/config";
|
|
12
12
|
import { reactIconsSprite } from "react-icons-sprite/vite";
|
|
13
13
|
|
|
14
|
+
/** react-icons-sprite maps Trash2 → trash2.mjs and MenuIcon → menu-icon.mjs; lucide ships trash-2.mjs / menu.mjs. */
|
|
15
|
+
function lucideSpriteIconAliases(): Plugin {
|
|
16
|
+
return {
|
|
17
|
+
name: "lucide-sprite-icon-aliases",
|
|
18
|
+
enforce: "pre",
|
|
19
|
+
apply: "build",
|
|
20
|
+
buildStart() {
|
|
21
|
+
const iconsDir = join(process.cwd(), "node_modules", "lucide-react", "dist", "esm", "icons");
|
|
22
|
+
if (!existsSync(iconsDir)) return;
|
|
23
|
+
for (const file of readdirSync(iconsDir)) {
|
|
24
|
+
if (!file.endsWith(".mjs")) continue;
|
|
25
|
+
const src = join(iconsDir, file);
|
|
26
|
+
const digit = /^(.+)-(\d+)\.mjs$/.exec(file);
|
|
27
|
+
if (digit) {
|
|
28
|
+
const dest = join(iconsDir, `${digit[1]}${digit[2]}.mjs`);
|
|
29
|
+
if (!existsSync(dest)) copyFileSync(src, dest);
|
|
30
|
+
}
|
|
31
|
+
if (!file.endsWith("-icon.mjs")) {
|
|
32
|
+
const dest = join(iconsDir, file.replace(/\.mjs$/, "-icon.mjs"));
|
|
33
|
+
if (!existsSync(dest)) copyFileSync(src, dest);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
14
40
|
const repoRoot = new URL("../..", import.meta.url).pathname;
|
|
15
41
|
|
|
16
42
|
function readDotEnvKey(root: string, key: string): string | undefined {
|
|
@@ -71,6 +97,7 @@ const createViteConfig = (isDevServer: boolean) => ({
|
|
|
71
97
|
devtools({
|
|
72
98
|
eventBusConfig: { enabled: false },
|
|
73
99
|
}),
|
|
100
|
+
lucideSpriteIconAliases(),
|
|
74
101
|
iconsSpritePlugin,
|
|
75
102
|
viteReact({
|
|
76
103
|
babel: {
|
|
@@ -4,13 +4,39 @@ import tailwindCSS from "@tailwindcss/vite";
|
|
|
4
4
|
import { devtools } from "@tanstack/devtools-vite";
|
|
5
5
|
import { tanstackRouter } from "@tanstack/router-plugin/vite";
|
|
6
6
|
import viteReact from "@vitejs/plugin-react";
|
|
7
|
-
import { readFileSync } from "node:fs";
|
|
7
|
+
import { copyFileSync, existsSync, readdirSync, readFileSync } from "node:fs";
|
|
8
8
|
import { join } from "node:path";
|
|
9
|
-
import { defineConfig as defineViteConfig, mergeConfig } from "vite";
|
|
9
|
+
import { defineConfig as defineViteConfig, mergeConfig, type Plugin } from "vite";
|
|
10
10
|
import devtoolsJson from "vite-plugin-devtools-json";
|
|
11
11
|
import { defineConfig as defineVitestConfig } from "vitest/config";
|
|
12
12
|
import { reactIconsSprite } from "react-icons-sprite/vite";
|
|
13
13
|
|
|
14
|
+
/** react-icons-sprite maps Trash2 → trash2.mjs and MenuIcon → menu-icon.mjs; lucide ships trash-2.mjs / menu.mjs. */
|
|
15
|
+
function lucideSpriteIconAliases(): Plugin {
|
|
16
|
+
return {
|
|
17
|
+
name: "lucide-sprite-icon-aliases",
|
|
18
|
+
enforce: "pre",
|
|
19
|
+
apply: "build",
|
|
20
|
+
buildStart() {
|
|
21
|
+
const iconsDir = join(process.cwd(), "node_modules", "lucide-react", "dist", "esm", "icons");
|
|
22
|
+
if (!existsSync(iconsDir)) return;
|
|
23
|
+
for (const file of readdirSync(iconsDir)) {
|
|
24
|
+
if (!file.endsWith(".mjs")) continue;
|
|
25
|
+
const src = join(iconsDir, file);
|
|
26
|
+
const digit = /^(.+)-(\d+)\.mjs$/.exec(file);
|
|
27
|
+
if (digit) {
|
|
28
|
+
const dest = join(iconsDir, `${digit[1]}${digit[2]}.mjs`);
|
|
29
|
+
if (!existsSync(dest)) copyFileSync(src, dest);
|
|
30
|
+
}
|
|
31
|
+
if (!file.endsWith("-icon.mjs")) {
|
|
32
|
+
const dest = join(iconsDir, file.replace(/\.mjs$/, "-icon.mjs"));
|
|
33
|
+
if (!existsSync(dest)) copyFileSync(src, dest);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
14
40
|
const repoRoot = new URL("../..", import.meta.url).pathname;
|
|
15
41
|
|
|
16
42
|
function readDotEnvKey(root: string, key: string): string | undefined {
|
|
@@ -71,6 +97,7 @@ const createViteConfig = (isDevServer: boolean) => ({
|
|
|
71
97
|
devtools({
|
|
72
98
|
eventBusConfig: { enabled: false },
|
|
73
99
|
}),
|
|
100
|
+
lucideSpriteIconAliases(),
|
|
74
101
|
iconsSpritePlugin,
|
|
75
102
|
viteReact({
|
|
76
103
|
babel: {
|