robodev 0.41.0 → 0.43.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "robodev",
3
- "version": "0.41.0",
4
- "description": "CLI for Robodev Starbase — create, auth, link, deploy, and run a project locally with robodev dev",
3
+ "version": "0.43.0",
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",
7
7
  "repository": {
@@ -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.5.1",
29
+ "@robodev-ai/runtime": "0.6.0",
30
30
  "@robodev-ai/sdk": "0.13.0"
31
31
  },
32
32
  "devDependencies": {
@@ -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
 
@@ -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(["/_robodev/state.json", "/_robodev/logs.json"]);
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
@@ -39,6 +39,7 @@ import {
39
39
  import { parseCreateArgs } from "./parse-create-args.js";
40
40
  import { parseDeployArgs } from "./parse-deploy-args.js";
41
41
  import { ensureDeployLink } from "./ensure-deploy-link.js";
42
+ import { runLoad } from "./load.js";
42
43
  import { printLiveUrl } from "./print-live-url.js";
43
44
 
44
45
  const execFileAsync = promisify(execFile);
@@ -56,6 +57,7 @@ Commands:
56
57
  create [path] [--template <id>] [--starter <id>] [--empty] [--org <id>] Create a project, scaffold a starter, and deploy
57
58
  link [projectId] Write robodev/.robodev in the current folder
58
59
  deploy [--force] [--name <name>] [--org <id>] Deploy schema, APIs, jobs, and frontend (creates and links if unlinked)
60
+ load Download the latest deploy onto this folder
59
61
  dev [options] Run this project's APIs locally on an embedded Postgres, no Starbase needed
60
62
 
61
63
  dev options:
@@ -682,6 +684,9 @@ async function main(): Promise<void> {
682
684
  await runDeploy(parsed.force, {}, { name: parsed.name, orgId: parsed.orgId });
683
685
  break;
684
686
  }
687
+ case "load":
688
+ await runLoad(args);
689
+ break;
685
690
  case "dev": {
686
691
  const { main: runDev } = await import("./dev.js");
687
692
  await runDev(args);
@@ -0,0 +1,238 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
3
+ import { mkdtemp, rm } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { test } from "node:test";
7
+ import {
8
+ applyLoadFiles,
9
+ NO_DEPLOYMENT_MESSAGE,
10
+ NO_LINK_MESSAGE,
11
+ parseLoadArgs,
12
+ runLoad,
13
+ type LoadDeps,
14
+ type LoadFile,
15
+ } from "./load.js";
16
+ import type { ProjectLink } from "./config.js";
17
+
18
+ const linked: ProjectLink = {
19
+ projectId: "proj_load",
20
+ apiUrl: "https://robodev.povio.dev",
21
+ };
22
+
23
+ async function withTemp<T>(fn: (root: string) => Promise<T>): Promise<T> {
24
+ const root = await mkdtemp(join(tmpdir(), "rd-load-"));
25
+ try {
26
+ return await fn(root);
27
+ } finally {
28
+ await rm(root, { recursive: true, force: true });
29
+ }
30
+ }
31
+
32
+ function stubRun(
33
+ overrides: Partial<LoadDeps> & { files?: LoadFile[] } = {},
34
+ ): LoadDeps & { authedPaths: string[]; applied: LoadFile[][] } {
35
+ const authedPaths: string[] = [];
36
+ const applied: LoadFile[][] = [];
37
+ return {
38
+ authedPaths,
39
+ applied,
40
+ readLink: overrides.readLink ?? (async () => linked),
41
+ authed:
42
+ overrides.authed ??
43
+ (async <T>(path: string) => {
44
+ authedPaths.push(path);
45
+ return { files: overrides.files ?? [] } as T;
46
+ }),
47
+ apply:
48
+ overrides.apply ??
49
+ (async (_root, files) => {
50
+ applied.push(files);
51
+ return { written: files.length, removed: 0 };
52
+ }),
53
+ cwd: overrides.cwd ?? (() => "/tmp/app"),
54
+ log: overrides.log ?? (() => {}),
55
+ };
56
+ }
57
+
58
+ test("parseLoadArgs rejects unknown flags and extra arguments", () => {
59
+ assert.throws(() => parseLoadArgs(["--force"]), /Unknown flag --force/);
60
+ assert.throws(() => parseLoadArgs(["--name", "x"]), /Unknown flag --name/);
61
+ assert.throws(() => parseLoadArgs(["extra"]), /Unexpected argument extra/);
62
+ parseLoadArgs([]);
63
+ });
64
+
65
+ test("runLoad fails without a link and does not fetch or write", async () => {
66
+ const deps = stubRun({
67
+ readLink: async () => null,
68
+ authed: async () => {
69
+ throw new Error("should not fetch");
70
+ },
71
+ apply: async () => {
72
+ throw new Error("should not apply");
73
+ },
74
+ });
75
+ await assert.rejects(() => runLoad([], deps), new Error(NO_LINK_MESSAGE));
76
+ assert.deepEqual(deps.authedPaths, []);
77
+ assert.deepEqual(deps.applied, []);
78
+ });
79
+
80
+ test("runLoad fails on an empty deployment and does not write", async () => {
81
+ const deps = stubRun({ files: [] });
82
+ await assert.rejects(() => runLoad([], deps), new Error(NO_DEPLOYMENT_MESSAGE));
83
+ assert.deepEqual(deps.authedPaths, ["/v1/projects/proj_load/deployment/files"]);
84
+ assert.deepEqual(deps.applied, []);
85
+ });
86
+
87
+ test("runLoad rejects unknown flags before reading the link", async () => {
88
+ const deps = stubRun({
89
+ readLink: async () => {
90
+ throw new Error("should not read link");
91
+ },
92
+ });
93
+ await assert.rejects(() => runLoad(["--workspace"], deps), /Unknown flag --workspace/);
94
+ });
95
+
96
+ test("runLoad fetches deployment files and prints a summary", async () => {
97
+ const logs: string[] = [];
98
+ const files = [{ path: "robodev/database.ts", content: "export default {}" }];
99
+ const deps = stubRun({
100
+ files,
101
+ apply: async () => ({ written: 1, removed: 0 }),
102
+ log: (message) => logs.push(message),
103
+ });
104
+ await runLoad([], deps);
105
+ assert.deepEqual(deps.authedPaths, ["/v1/projects/proj_load/deployment/files"]);
106
+ assert.deepEqual(logs, ["Loaded 1 files from proj_load."]);
107
+ });
108
+
109
+ test("runLoad mentions pruned files in the summary", async () => {
110
+ const logs: string[] = [];
111
+ const deps = stubRun({
112
+ files: [{ path: "robodev/database.ts", content: "ok" }],
113
+ apply: async () => ({ written: 2, removed: 3 }),
114
+ log: (message) => logs.push(message),
115
+ });
116
+ await runLoad([], deps);
117
+ assert.deepEqual(logs, ["Loaded 2 files from proj_load (3 removed)."]);
118
+ });
119
+
120
+ test("applyLoadFiles aborts unsafe paths with no writes", async () => {
121
+ await withTemp(async (root) => {
122
+ await mkdir(join(root, "robodev"), { recursive: true });
123
+ await writeFile(join(root, "robodev/database.ts"), "keep");
124
+ const cases: LoadFile[][] = [
125
+ [
126
+ { path: "robodev/database.ts", content: "new" },
127
+ { path: "../escape.ts", content: "bad" },
128
+ ],
129
+ [{ path: "/tmp/abs.ts", content: "bad" }],
130
+ [{ path: "C:/Windows/x.ts", content: "bad" }],
131
+ [{ path: "robodev//database.ts", content: "bad" }],
132
+ ];
133
+ for (const files of cases) {
134
+ await assert.rejects(() => applyLoadFiles(root, files), /Invalid file path:/);
135
+ }
136
+ assert.equal(await readFile(join(root, "robodev/database.ts"), "utf8"), "keep");
137
+ });
138
+ });
139
+
140
+ test("applyLoadFiles skips protected and denied paths", async () => {
141
+ await withTemp(async (root) => {
142
+ await mkdir(join(root, "robodev"), { recursive: true });
143
+ await mkdir(join(root, "apps/fe/src"), { recursive: true });
144
+ await writeFile(join(root, ".env"), "SECRET=1");
145
+ await writeFile(join(root, ".env.local"), "LOCAL=1");
146
+ await writeFile(join(root, "robodev/.robodev"), '{"projectId":"keep"}');
147
+ await writeFile(join(root, "apps/fe/src/App.tsx"), "local-fe");
148
+ const result = await applyLoadFiles(root, [
149
+ { path: "robodev/database.ts", content: "export default {}" },
150
+ { path: ".env", content: "SECRET=leaked" },
151
+ { path: ".env.local", content: "LOCAL=leaked" },
152
+ { path: "robodev/.robodev", content: '{"projectId":"overwrite"}' },
153
+ { path: "apps/fe/src/App.tsx", content: "payload-fe" },
154
+ { path: "apps/fe/package.json", content: "{}" },
155
+ ]);
156
+ assert.equal(result.written, 1);
157
+ assert.equal(await readFile(join(root, "robodev/database.ts"), "utf8"), "export default {}");
158
+ assert.equal(await readFile(join(root, ".env"), "utf8"), "SECRET=1");
159
+ assert.equal(await readFile(join(root, ".env.local"), "utf8"), "LOCAL=1");
160
+ assert.equal(await readFile(join(root, "robodev/.robodev"), "utf8"), '{"projectId":"keep"}');
161
+ assert.equal(await readFile(join(root, "apps/fe/src/App.tsx"), "utf8"), "local-fe");
162
+ });
163
+ });
164
+
165
+ test("applyLoadFiles treats an all-skipped payload as empty", async () => {
166
+ await withTemp(async (root) => {
167
+ await writeFile(join(root, ".env"), "SECRET=1");
168
+ await assert.rejects(
169
+ () =>
170
+ applyLoadFiles(root, [
171
+ { path: ".env", content: "nope" },
172
+ { path: "apps/fe/src/App.tsx", content: "nope" },
173
+ ]),
174
+ new Error(NO_DEPLOYMENT_MESSAGE),
175
+ );
176
+ assert.equal(await readFile(join(root, ".env"), "utf8"), "SECRET=1");
177
+ });
178
+ });
179
+
180
+ test("applyLoadFiles overwrites existing allowed files", async () => {
181
+ await withTemp(async (root) => {
182
+ await mkdir(join(root, "robodev/api"), { recursive: true });
183
+ await writeFile(join(root, "robodev/database.ts"), "old-db");
184
+ await writeFile(join(root, "robodev/api/health.ts"), "old-health");
185
+ const result = await applyLoadFiles(root, [
186
+ { path: "robodev/database.ts", content: "new-db" },
187
+ { path: "robodev/api/health.ts", content: "new-health" },
188
+ ]);
189
+ assert.equal(result.written, 2);
190
+ assert.equal(result.removed, 0);
191
+ assert.equal(await readFile(join(root, "robodev/database.ts"), "utf8"), "new-db");
192
+ assert.equal(await readFile(join(root, "robodev/api/health.ts"), "utf8"), "new-health");
193
+ });
194
+ });
195
+
196
+ test("applyLoadFiles prunes stale collectable files and leaves apps/fe and .env", async () => {
197
+ await withTemp(async (root) => {
198
+ await mkdir(join(root, "robodev/api"), { recursive: true });
199
+ await mkdir(join(root, "apps/fe/src"), { recursive: true });
200
+ await writeFile(join(root, "robodev/database.ts"), "old");
201
+ await writeFile(join(root, "robodev/api/gone.ts"), "stale");
202
+ await writeFile(join(root, "robodev/api/keep.ts"), "old-keep");
203
+ await writeFile(join(root, ".env"), "SECRET=1");
204
+ await writeFile(join(root, "apps/fe/src/App.tsx"), "local-fe");
205
+ const result = await applyLoadFiles(root, [
206
+ { path: "robodev/database.ts", content: "new-db" },
207
+ { path: "robodev/api/keep.ts", content: "new-keep" },
208
+ ]);
209
+ assert.equal(result.written, 2);
210
+ assert.equal(result.removed, 1);
211
+ assert.equal(await readFile(join(root, "robodev/database.ts"), "utf8"), "new-db");
212
+ assert.equal(await readFile(join(root, "robodev/api/keep.ts"), "utf8"), "new-keep");
213
+ await assert.rejects(() => readFile(join(root, "robodev/api/gone.ts")), /ENOENT/);
214
+ assert.equal(await readFile(join(root, ".env"), "utf8"), "SECRET=1");
215
+ assert.equal(await readFile(join(root, "apps/fe/src/App.tsx"), "utf8"), "local-fe");
216
+ });
217
+ });
218
+
219
+ test("applyLoadFiles decodes public binaries from base64", async () => {
220
+ await withTemp(async (root) => {
221
+ const bytes = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
222
+ const result = await applyLoadFiles(root, [
223
+ { path: "index.html", content: "<html></html>" },
224
+ { path: "public/icon.png", content: bytes.toString("base64") },
225
+ ]);
226
+ assert.equal(result.written, 2);
227
+ assert.deepEqual(await readFile(join(root, "public/icon.png")), bytes);
228
+ assert.equal(await readFile(join(root, "index.html"), "utf8"), "<html></html>");
229
+ });
230
+ });
231
+
232
+ test("applyLoadFiles fails on an empty payload and writes nothing", async () => {
233
+ await withTemp(async (root) => {
234
+ await writeFile(join(root, "marker.txt"), "keep");
235
+ await assert.rejects(() => applyLoadFiles(root, []), new Error(NO_DEPLOYMENT_MESSAGE));
236
+ assert.equal(await readFile(join(root, "marker.txt"), "utf8"), "keep");
237
+ });
238
+ });
package/src/load.ts ADDED
@@ -0,0 +1,176 @@
1
+ import { mkdir, readdir, unlink, writeFile } from "node:fs/promises";
2
+ import { dirname, join, relative, resolve, sep } from "node:path";
3
+ import { authed } from "./api.js";
4
+ import { isPublicBinaryCollectedPath, shouldCollectPath } from "./collect-files.js";
5
+ import { readLink, type ProjectLink } from "./config.js";
6
+
7
+ export const NO_LINK_MESSAGE =
8
+ "No robodev/.robodev file. Run `robodev link` or `robodev create` first.";
9
+ export const NO_DEPLOYMENT_MESSAGE =
10
+ "No deployment for this project. Deploy from the dashboard or run `robodev deploy` first.";
11
+
12
+ const PRUNE_SKIP_DIRS = new Set(["node_modules", "dist", ".git", ".robodev", "apps"]);
13
+
14
+ export type LoadFile = { path: string; content: string };
15
+
16
+ export type LoadApplyResult = { written: number; removed: number };
17
+
18
+ export type LoadDeps = {
19
+ readLink: (cwd?: string) => Promise<ProjectLink | null>;
20
+ authed: <T>(path: string, init?: RequestInit) => Promise<T>;
21
+ apply: (root: string, files: LoadFile[]) => Promise<LoadApplyResult>;
22
+ cwd?: () => string;
23
+ log?: (message: string) => void;
24
+ };
25
+
26
+ export function parseLoadArgs(args: string[]): void {
27
+ for (const arg of args) {
28
+ if (arg.startsWith("-")) {
29
+ throw new Error(`Unknown flag ${arg}`);
30
+ }
31
+ throw new Error(`Unexpected argument ${arg}`);
32
+ }
33
+ }
34
+
35
+ function normalize(path: string): string {
36
+ return path.replaceAll("\\", "/");
37
+ }
38
+
39
+ /** Same conditions as collect-files `isUnsafe`. */
40
+ function isUnsafePath(path: string): boolean {
41
+ return (
42
+ path.startsWith("/") ||
43
+ path.includes("..") ||
44
+ path.split("/").some((segment) => segment === "") ||
45
+ /^[A-Za-z]:/.test(path)
46
+ );
47
+ }
48
+
49
+ function isProtectedPath(path: string): boolean {
50
+ if (path === "robodev/.robodev" || path === ".robodev" || path.startsWith(".robodev/")) {
51
+ return true;
52
+ }
53
+ if (path === ".env" || path.startsWith(".env.")) return true;
54
+ if (path === "credentials.json" || path.endsWith("/credentials.json")) return true;
55
+ if (path === "apps" || path.startsWith("apps/")) return true;
56
+ if (path === "node_modules" || path.startsWith("node_modules/")) return true;
57
+ if (path === ".git" || path.startsWith(".git/")) return true;
58
+ return false;
59
+ }
60
+
61
+ function resolveUnderRoot(root: string, rel: string): string | null {
62
+ const rootResolved = resolve(root);
63
+ const resolved = resolve(rootResolved, rel);
64
+ const prefix = rootResolved.endsWith(sep) ? rootResolved : `${rootResolved}${sep}`;
65
+ if (resolved !== rootResolved && !resolved.startsWith(prefix)) {
66
+ return null;
67
+ }
68
+ return resolved;
69
+ }
70
+
71
+ type Classified =
72
+ | { kind: "unsafe"; path: string }
73
+ | { kind: "denied"; path: string }
74
+ | { kind: "allowed"; path: string; content: string; abs: string };
75
+
76
+ export function classifyLoadFiles(root: string, files: LoadFile[]): Classified[] {
77
+ return files.map((file) => {
78
+ const path = normalize(file.path);
79
+ if (isUnsafePath(path)) return { kind: "unsafe", path };
80
+ const abs = resolveUnderRoot(root, path);
81
+ if (!abs) return { kind: "unsafe", path };
82
+ if (isProtectedPath(path) || !shouldCollectPath(path)) return { kind: "denied", path };
83
+ return { kind: "allowed", path, content: file.content, abs };
84
+ });
85
+ }
86
+
87
+ async function listCollectablePaths(root: string): Promise<string[]> {
88
+ const paths: string[] = [];
89
+
90
+ async function walk(dir: string): Promise<void> {
91
+ let entries;
92
+ try {
93
+ entries = await readdir(dir, { withFileTypes: true });
94
+ } catch {
95
+ return;
96
+ }
97
+ for (const entry of entries) {
98
+ const abs = join(dir, entry.name);
99
+ if (entry.isDirectory()) {
100
+ if (PRUNE_SKIP_DIRS.has(entry.name)) continue;
101
+ await walk(abs);
102
+ continue;
103
+ }
104
+ if (!entry.isFile()) continue;
105
+ const path = normalize(relative(root, abs));
106
+ if (!shouldCollectPath(path)) continue;
107
+ paths.push(path);
108
+ }
109
+ }
110
+
111
+ await walk(root);
112
+ return paths;
113
+ }
114
+
115
+ export async function applyLoadFiles(root: string, files: LoadFile[]): Promise<LoadApplyResult> {
116
+ const classified = classifyLoadFiles(root, files);
117
+ const unsafe = classified.find((item) => item.kind === "unsafe");
118
+ if (unsafe) {
119
+ throw new Error(`Invalid file path: ${unsafe.path}`);
120
+ }
121
+ const allowed = classified.filter(
122
+ (item): item is Extract<Classified, { kind: "allowed" }> => item.kind === "allowed",
123
+ );
124
+ if (allowed.length === 0) {
125
+ throw new Error(NO_DEPLOYMENT_MESSAGE);
126
+ }
127
+
128
+ for (const file of allowed) {
129
+ await mkdir(dirname(file.abs), { recursive: true });
130
+ if (isPublicBinaryCollectedPath(file.path)) {
131
+ await writeFile(file.abs, Buffer.from(file.content, "base64"));
132
+ } else {
133
+ await writeFile(file.abs, file.content, "utf8");
134
+ }
135
+ }
136
+
137
+ const keep = new Set(allowed.map((file) => file.path));
138
+ let removed = 0;
139
+ for (const path of await listCollectablePaths(root)) {
140
+ if (keep.has(path)) continue;
141
+ const abs = resolveUnderRoot(root, path);
142
+ if (!abs) continue;
143
+ await unlink(abs);
144
+ removed += 1;
145
+ }
146
+ return { written: allowed.length, removed };
147
+ }
148
+
149
+ const defaultDeps: LoadDeps = {
150
+ readLink,
151
+ authed,
152
+ apply: applyLoadFiles,
153
+ };
154
+
155
+ export async function runLoad(args: string[], deps: LoadDeps = defaultDeps): Promise<void> {
156
+ parseLoadArgs(args);
157
+ const cwd = deps.cwd?.() ?? process.cwd();
158
+ const log = deps.log ?? console.log;
159
+ const link = await deps.readLink(cwd);
160
+ if (!link) {
161
+ throw new Error(NO_LINK_MESSAGE);
162
+ }
163
+ const body = await deps.authed<{ files?: LoadFile[] }>(
164
+ `/v1/projects/${link.projectId}/deployment/files`,
165
+ );
166
+ const files = body.files ?? [];
167
+ if (files.length === 0) {
168
+ throw new Error(NO_DEPLOYMENT_MESSAGE);
169
+ }
170
+ const { written, removed } = await deps.apply(cwd, files);
171
+ if (removed > 0) {
172
+ log(`Loaded ${written} files from ${link.projectId} (${removed} removed).`);
173
+ } else {
174
+ log(`Loaded ${written} files from ${link.projectId}.`);
175
+ }
176
+ }
@@ -12,7 +12,7 @@
12
12
  "@robodev-ai/sdk": "^0.13.0"
13
13
  },
14
14
  "devDependencies": {
15
- "robodev": "^0.41.0",
15
+ "robodev": "^0.43.0",
16
16
  "rulesync": "^8.18.0",
17
17
  "typescript": "^5.9.2"
18
18
  }
@@ -11,7 +11,7 @@
11
11
  "@robodev-ai/sdk": "^0.13.0"
12
12
  },
13
13
  "devDependencies": {
14
- "robodev": "^0.41.0",
14
+ "robodev": "^0.43.0",
15
15
  "typescript": "^5.9.2"
16
16
  }
17
17
  }
@@ -16,7 +16,7 @@
16
16
  "react-dom": "^18.3.1"
17
17
  },
18
18
  "devDependencies": {
19
- "robodev": "^0.41.0",
19
+ "robodev": "^0.43.0",
20
20
  "typescript": "^5.9.2",
21
21
  "vite": "^6.0.0"
22
22
  }
@@ -12,7 +12,7 @@
12
12
  "@robodev-ai/sdk": "^0.13.0"
13
13
  },
14
14
  "devDependencies": {
15
- "robodev": "^0.41.0",
15
+ "robodev": "^0.43.0",
16
16
  "rulesync": "^8.18.0",
17
17
  "typescript": "^5.9.2"
18
18
  }
@@ -12,7 +12,7 @@
12
12
  "@robodev-ai/sdk": "^0.13.0"
13
13
  },
14
14
  "devDependencies": {
15
- "robodev": "^0.41.0",
15
+ "robodev": "^0.43.0",
16
16
  "rulesync": "^8.18.0",
17
17
  "typescript": "^5.9.2"
18
18
  }