relmio 0.8.0 → 0.9.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/src/web/server.js CHANGED
@@ -5,6 +5,7 @@ import packageManifest from "../../package.json" with { type: "json" };
5
5
 
6
6
  import { discoverN8n, discoverNetworks } from "../services/discovery.js";
7
7
  import { installSidecar } from "../services/installer.js";
8
+ import { installAssistant } from "../services/assistant-installer.js";
8
9
  import {
9
10
  getAuthStatus,
10
11
  readAuthContents,
@@ -19,6 +20,9 @@ import {
19
20
  validatePort,
20
21
  } from "../domain/validation.js";
21
22
  import { SIDECAR_HOSTNAME } from "../domain/templates.js";
23
+ import {
24
+ ASSISTANT_ROOT,
25
+ } from "../domain/assistant.js";
22
26
  import {
23
27
  createLocalDeploymentPlan,
24
28
  validateLocalTarget,
@@ -78,6 +82,7 @@ const defaultServices = {
78
82
  discoverN8n,
79
83
  discoverNetworks,
80
84
  installSidecar,
85
+ installAssistant,
81
86
  attestLocalCodexInstallation,
82
87
  getLocalDockerStatus,
83
88
  getProjectMeta,
@@ -289,6 +294,95 @@ function requireConnection(state) {
289
294
  return state.connection;
290
295
  }
291
296
 
297
+ function rejectActiveVpsMutation(state) {
298
+ if (state.closing) {
299
+ throw Object.assign(new Error("The local wizard is closing."), {
300
+ statusCode: 409,
301
+ });
302
+ }
303
+ if (state.vpsMutationInFlight) {
304
+ throw Object.assign(
305
+ new Error("A VPS installation is already in progress. Wait for it to finish before trying another installation."),
306
+ { statusCode: 409 },
307
+ );
308
+ }
309
+ }
310
+
311
+ function acquireVpsMutationLock(state) {
312
+ rejectActiveVpsMutation(state);
313
+ const lock = Symbol("vps-mutation");
314
+ let resolveCompletion;
315
+ state.vpsMutationInFlight = true;
316
+ state.vpsMutationLock = lock;
317
+ state.vpsMutationCompletion = new Promise((resolve) => {
318
+ resolveCompletion = resolve;
319
+ });
320
+ return () => {
321
+ if (state.vpsMutationLock === lock) {
322
+ state.vpsMutationInFlight = false;
323
+ state.vpsMutationLock = null;
324
+ state.vpsMutationCompletion = null;
325
+ resolveCompletion();
326
+ }
327
+ };
328
+ }
329
+
330
+ function requireReviewedVpsPlan(plan, body, label) {
331
+ if (
332
+ !plan ||
333
+ plan.containerName !== body.containerName ||
334
+ plan.networkName !== body.networkName
335
+ ) {
336
+ throw new Error(`Review a fresh ${label} plan before installing.`);
337
+ }
338
+ }
339
+
340
+ function requireAssistantSearxngSelection(value) {
341
+ if (typeof value !== "boolean") {
342
+ throw new Error("Choose whether to include optional SearXNG web search.");
343
+ }
344
+ return value;
345
+ }
346
+
347
+ function requireEnabledInstanceAi(state, containerName) {
348
+ const instanceAi = state.networksByContainer.get(containerName)?.instanceAi;
349
+ if (instanceAi?.status === "enabled") return instanceAi;
350
+ if (instanceAi?.status === "missing" || instanceAi?.status === "configured") {
351
+ throw new Error(
352
+ "The selected n8n container needs N8N_ENABLED_MODULES to include instance-ai. Update its existing deployment separately; restart n8n outside this wizard only if you later authorize that action.",
353
+ );
354
+ }
355
+ throw new Error("The selected n8n container's AI Assistant prerequisite could not be verified.");
356
+ }
357
+
358
+ function requireReviewedAssistantPlan(state, plan, body) {
359
+ requireReviewedVpsPlan(plan, body, "AI Assistant");
360
+ if (plan.includeSearxng !== body.includeSearxng) {
361
+ throw new Error("Review a fresh AI Assistant plan for the selected web-search option.");
362
+ }
363
+ const instanceAi = requireEnabledInstanceAi(state, body.containerName);
364
+ if (plan.instanceAi?.status !== instanceAi.status) {
365
+ throw new Error("Review a fresh AI Assistant plan after prerequisite discovery.");
366
+ }
367
+ }
368
+
369
+ function requireFreshAssistantInstallState(plan, networks, body) {
370
+ if (!Array.isArray(networks?.networks) || !networks.networks.includes(body.networkName)) {
371
+ throw new Error(
372
+ "The selected Docker network changed after plan review. Review a fresh AI Assistant plan before installing.",
373
+ );
374
+ }
375
+ const instanceAi = networks.instanceAi;
376
+ if (instanceAi?.status !== "enabled") {
377
+ throw new Error(
378
+ "The selected n8n container needs N8N_ENABLED_MODULES to include instance-ai. Update its existing deployment separately; restart n8n outside this wizard only if you later authorize that action.",
379
+ );
380
+ }
381
+ if (plan.instanceAi?.status !== instanceAi.status) {
382
+ throw new Error("Review a fresh AI Assistant plan after prerequisite discovery.");
383
+ }
384
+ }
385
+
292
386
  function requireDiscoveredContainer(state, containerName) {
293
387
  const container = state.discovery?.containers.find(
294
388
  (candidate) => candidate.name === containerName,
@@ -310,7 +404,11 @@ function requireDiscoveredNetwork(state, containerName, networkName) {
310
404
 
311
405
  function safeErrorMessage(error) {
312
406
  const message =
313
- typeof error?.message === "string" ? error.message : "Request failed.";
407
+ typeof error?.safeMessage === "string"
408
+ ? error.safeMessage
409
+ : typeof error?.message === "string"
410
+ ? error.message
411
+ : "Request failed.";
314
412
  if (
315
413
  message.length > 240 ||
316
414
  /[\r\n]/u.test(message) ||
@@ -356,6 +454,9 @@ async function loadDefaultUiFiles() {
356
454
  readFile(new URL("../ui/app.js", import.meta.url), "utf8"),
357
455
  readFile(new URL("../ui/local.js", import.meta.url), "utf8"),
358
456
  readFile(new URL("../ui/oauth-popup.js", import.meta.url), "utf8"),
457
+ readFile(new URL("../ui/assistant.html", import.meta.url), "utf8"),
458
+ readFile(new URL("../ui/assistant.js", import.meta.url), "utf8"),
459
+ readFile(new URL("../ui/assistant.css", import.meta.url), "utf8"),
359
460
  readFile(new URL("../ui/theme.js", import.meta.url), "utf8"),
360
461
  readFile(new URL("../ui/time.js", import.meta.url), "utf8"),
361
462
  readFile(new URL("../ui/styles.css", import.meta.url), "utf8"),
@@ -363,6 +464,8 @@ async function loadDefaultUiFiles() {
363
464
  readFile(new URL("../ui/icons/monitor.svg", import.meta.url), "utf8"),
364
465
  readFile(new URL("../ui/icons/sun.svg", import.meta.url), "utf8"),
365
466
  readFile(new URL("../ui/icons/moon.svg", import.meta.url), "utf8"),
467
+ readFile(new URL("../ui/relmio-icon.png", import.meta.url)),
468
+ readFile(new URL("../ui/relmio-icon-rounded.svg", import.meta.url), "utf8"),
366
469
  ]);
367
470
 
368
471
  return {
@@ -371,13 +474,18 @@ async function loadDefaultUiFiles() {
371
474
  "/app.js": files[2],
372
475
  "/local.js": files[3],
373
476
  "/oauth-popup.js": files[4],
374
- "/theme.js": files[5],
375
- "/time.js": files[6],
376
- "/styles.css": files[7],
377
- "/local.css": files[8],
378
- "/icons/monitor.svg": files[9],
379
- "/icons/sun.svg": files[10],
380
- "/icons/moon.svg": files[11],
477
+ "/assistant": files[5],
478
+ "/assistant.js": files[6],
479
+ "/assistant.css": files[7],
480
+ "/theme.js": files[8],
481
+ "/time.js": files[9],
482
+ "/styles.css": files[10],
483
+ "/local.css": files[11],
484
+ "/icons/monitor.svg": files[12],
485
+ "/icons/sun.svg": files[13],
486
+ "/icons/moon.svg": files[14],
487
+ "/relmio-icon.png": files[15],
488
+ "/relmio-icon-rounded.svg": files[16],
381
489
  };
382
490
  }
383
491
 
@@ -1085,6 +1193,7 @@ async function handleApi(request, response, path, state) {
1085
1193
  }
1086
1194
 
1087
1195
  if (path === "/api/ssh/fingerprint") {
1196
+ rejectActiveVpsMutation(state);
1088
1197
  enforceRateLimit(state, path);
1089
1198
  const host = validateHostname(body.host);
1090
1199
  const port = validatePort(body.port);
@@ -1092,12 +1201,14 @@ async function handleApi(request, response, path, state) {
1092
1201
  host,
1093
1202
  port,
1094
1203
  });
1204
+ rejectActiveVpsMutation(state);
1095
1205
  state.scannedHost = { host, port, fingerprint };
1096
1206
  sendJson(response, 200, { fingerprint });
1097
1207
  return;
1098
1208
  }
1099
1209
 
1100
1210
  if (path === "/api/ssh/connect") {
1211
+ rejectActiveVpsMutation(state);
1101
1212
  enforceRateLimit(state, path);
1102
1213
  const host = validateHostname(body.host);
1103
1214
  const port = validatePort(body.port);
@@ -1116,8 +1227,9 @@ async function handleApi(request, response, path, state) {
1116
1227
  state.connection?.close();
1117
1228
  state.connection = null;
1118
1229
 
1230
+ let candidateConnection = null;
1119
1231
  try {
1120
- state.connection = await state.services.connectVerified({
1232
+ candidateConnection = await state.services.connectVerified({
1121
1233
  host,
1122
1234
  port,
1123
1235
  username: body.username,
@@ -1125,43 +1237,67 @@ async function handleApi(request, response, path, state) {
1125
1237
  agent: body.useAgent ? process.env.SSH_AUTH_SOCK : undefined,
1126
1238
  expectedFingerprint: scannedHost.fingerprint,
1127
1239
  });
1240
+ rejectActiveVpsMutation(state);
1241
+ state.connection = candidateConnection;
1242
+ candidateConnection = null;
1128
1243
  } finally {
1129
1244
  body.password = undefined;
1245
+ try {
1246
+ candidateConnection?.close();
1247
+ } catch {
1248
+ // A stale connection must never become the shared VPS connection.
1249
+ }
1130
1250
  }
1131
1251
 
1132
1252
  state.scannedHost = null;
1133
1253
  state.discovery = null;
1134
1254
  state.networksByContainer.clear();
1255
+ state.sidecarPlan = null;
1256
+ state.assistantPlan = null;
1135
1257
  sendJson(response, 200, { connected: true });
1136
1258
  return;
1137
1259
  }
1138
1260
 
1139
1261
  if (path === "/api/discover") {
1262
+ rejectActiveVpsMutation(state);
1140
1263
  const connection = requireConnection(state);
1141
- state.discovery = await state.services.discoverN8n(connection);
1264
+ const discovery = await state.services.discoverN8n(connection);
1265
+ rejectActiveVpsMutation(state);
1266
+ state.discovery = discovery;
1142
1267
  state.networksByContainer.clear();
1268
+ state.sidecarPlan = null;
1269
+ state.assistantPlan = null;
1143
1270
  sendJson(response, 200, state.discovery);
1144
1271
  return;
1145
1272
  }
1146
1273
 
1147
1274
  if (path === "/api/networks") {
1275
+ rejectActiveVpsMutation(state);
1148
1276
  const connection = requireConnection(state);
1149
1277
  requireDiscoveredContainer(state, body.containerName);
1150
1278
  const networks = await state.services.discoverNetworks(
1151
1279
  connection,
1152
1280
  body.containerName,
1153
1281
  );
1282
+ rejectActiveVpsMutation(state);
1154
1283
  state.networksByContainer.set(body.containerName, networks);
1284
+ state.sidecarPlan = null;
1285
+ state.assistantPlan = null;
1155
1286
  sendJson(response, 200, networks);
1156
1287
  return;
1157
1288
  }
1158
1289
 
1159
1290
  if (path === "/api/plan") {
1291
+ rejectActiveVpsMutation(state);
1160
1292
  requireDiscoveredNetwork(
1161
1293
  state,
1162
1294
  body.containerName,
1163
1295
  body.networkName,
1164
1296
  );
1297
+ state.sidecarPlan = {
1298
+ containerName: body.containerName,
1299
+ networkName: body.networkName,
1300
+ };
1165
1301
  sendJson(response, 200, {
1166
1302
  installDirectory: "/docker/n8n-openai-oauth",
1167
1303
  sidecarProject: "n8n-openai-oauth",
@@ -1174,16 +1310,93 @@ async function handleApi(request, response, path, state) {
1174
1310
  return;
1175
1311
  }
1176
1312
 
1177
- if (path === "/api/install") {
1313
+ if (path === "/api/assistant/plan") {
1314
+ rejectActiveVpsMutation(state);
1315
+ const includeSearxng = requireAssistantSearxngSelection(body.includeSearxng);
1316
+ requireDiscoveredNetwork(
1317
+ state,
1318
+ body.containerName,
1319
+ body.networkName,
1320
+ );
1321
+ const instanceAi = requireEnabledInstanceAi(state, body.containerName);
1322
+ state.assistantPlan = {
1323
+ containerName: body.containerName,
1324
+ networkName: body.networkName,
1325
+ includeSearxng,
1326
+ instanceAi,
1327
+ };
1328
+ sendJson(response, 200, {
1329
+ installDirectory: ASSISTANT_ROOT,
1330
+ companionProject: "generated ownership-bound project after confirmation",
1331
+ sandboxUrl: "generated after verified installation",
1332
+ includeSearxng,
1333
+ webSearch: includeSearxng ? "enabled" : "disabled",
1334
+ ...(includeSearxng ? { searxngUrl: "generated after verified installation" } : {}),
1335
+ networkName: body.networkName,
1336
+ instanceAi,
1337
+ existingN8nChanges: [],
1338
+ existingN8nRestarts: 0,
1339
+ publishedPorts: [],
1340
+ privilegedRunner: true,
1341
+ preview: true,
1342
+ });
1343
+ return;
1344
+ }
1345
+
1346
+ if (path === "/api/assistant/install") {
1347
+ rejectActiveVpsMutation(state);
1348
+ enforceRateLimit(state, path);
1349
+ const includeSearxng = requireAssistantSearxngSelection(body.includeSearxng);
1350
+ requireDiscoveredNetwork(
1351
+ state,
1352
+ body.containerName,
1353
+ body.networkName,
1354
+ );
1355
+ const reviewedPlan = state.assistantPlan;
1356
+ requireReviewedAssistantPlan(state, reviewedPlan, body);
1178
1357
  const connection = requireConnection(state);
1179
- let result;
1358
+ state.assistantPlan = null;
1359
+ const releaseVpsMutationLock = acquireVpsMutationLock(state);
1180
1360
  try {
1181
- enforceRateLimit(state, path);
1182
- requireDiscoveredNetwork(
1183
- state,
1361
+ const freshNetworks = await state.services.discoverNetworks(
1362
+ connection,
1184
1363
  body.containerName,
1185
- body.networkName,
1186
1364
  );
1365
+ requireFreshAssistantInstallState(reviewedPlan, freshNetworks, body);
1366
+ const result = await state.services.installAssistant({
1367
+ remote: connection,
1368
+ networkName: body.networkName,
1369
+ confirmed: body.confirmed,
1370
+ includeSearxng,
1371
+ });
1372
+ sendJson(response, 200, result);
1373
+ } finally {
1374
+ try {
1375
+ connection.close();
1376
+ if (state.connection === connection) {
1377
+ state.connection = null;
1378
+ }
1379
+ } finally {
1380
+ releaseVpsMutationLock();
1381
+ }
1382
+ }
1383
+ return;
1384
+ }
1385
+
1386
+ if (path === "/api/install") {
1387
+ rejectActiveVpsMutation(state);
1388
+ enforceRateLimit(state, path);
1389
+ requireDiscoveredNetwork(
1390
+ state,
1391
+ body.containerName,
1392
+ body.networkName,
1393
+ );
1394
+ requireReviewedVpsPlan(state.sidecarPlan, body, "sidecar");
1395
+ const connection = requireConnection(state);
1396
+ state.sidecarPlan = null;
1397
+ const releaseVpsMutationLock = acquireVpsMutationLock(state);
1398
+ let result;
1399
+ try {
1187
1400
  const authStatus = await state.services.getAuthStatus();
1188
1401
  if (!authStatus.exists) {
1189
1402
  throw new Error("Sign in with ChatGPT before installing.");
@@ -1198,9 +1411,13 @@ async function handleApi(request, response, path, state) {
1198
1411
  confirmed: body.confirmed,
1199
1412
  });
1200
1413
  } finally {
1201
- connection.close();
1202
- if (state.connection === connection) {
1203
- state.connection = null;
1414
+ try {
1415
+ connection.close();
1416
+ if (state.connection === connection) {
1417
+ state.connection = null;
1418
+ }
1419
+ } finally {
1420
+ releaseVpsMutationLock();
1204
1421
  }
1205
1422
  }
1206
1423
 
@@ -1209,8 +1426,11 @@ async function handleApi(request, response, path, state) {
1209
1426
  }
1210
1427
 
1211
1428
  if (path === "/api/disconnect") {
1429
+ rejectActiveVpsMutation(state);
1212
1430
  state.connection?.close();
1213
1431
  state.connection = null;
1432
+ state.sidecarPlan = null;
1433
+ state.assistantPlan = null;
1214
1434
  sendJson(response, 200, { disconnected: true });
1215
1435
  return;
1216
1436
  }
@@ -1223,6 +1443,10 @@ function createRequestHandler(state) {
1223
1443
  setSecurityHeaders(response);
1224
1444
 
1225
1445
  try {
1446
+ if (state.closing) {
1447
+ sendJson(response, 503, { error: "The local wizard is closing." });
1448
+ return;
1449
+ }
1226
1450
  const url = new URL(request.url, state.origin);
1227
1451
  const path = url.pathname;
1228
1452
 
@@ -1241,9 +1465,11 @@ function createRequestHandler(state) {
1241
1465
  ? "text/javascript; charset=utf-8"
1242
1466
  : path.endsWith(".svg")
1243
1467
  ? "image/svg+xml; charset=utf-8"
1244
- : path.endsWith(".css")
1245
- ? "text/css; charset=utf-8"
1246
- : "text/html; charset=utf-8";
1468
+ : path.endsWith(".png")
1469
+ ? "image/png"
1470
+ : path.endsWith(".css")
1471
+ ? "text/css; charset=utf-8"
1472
+ : "text/html; charset=utf-8";
1247
1473
  const contents = state.uiFiles[path];
1248
1474
  response.writeHead(200, {
1249
1475
  "Content-Type": contentType,
@@ -1288,6 +1514,11 @@ export async function startWizardServer({
1288
1514
  scannedHost: null,
1289
1515
  discovery: null,
1290
1516
  networksByContainer: new Map(),
1517
+ sidecarPlan: null,
1518
+ assistantPlan: null,
1519
+ vpsMutationInFlight: false,
1520
+ vpsMutationLock: null,
1521
+ vpsMutationCompletion: null,
1291
1522
  oauthLogin: null,
1292
1523
  oauthRetryBlocked: false,
1293
1524
  oauthStartupError: null,
@@ -1323,6 +1554,7 @@ export async function startWizardServer({
1323
1554
  origin: state.origin,
1324
1555
  async close() {
1325
1556
  state.closing = true;
1557
+ const serverClose = new Promise((resolve) => server.close(resolve));
1326
1558
  state.localChatTest.dispose?.();
1327
1559
  await waitForBoundedResult(
1328
1560
  state.oauthLoginStartPromise,
@@ -1346,12 +1578,15 @@ export async function startWizardServer({
1346
1578
  codexLogin?.completion,
1347
1579
  state.oauthShutdownWaitMs,
1348
1580
  );
1349
- state.connection?.close();
1350
- state.connection = null;
1351
- await waitForBoundedResult(
1352
- new Promise((resolve) => server.close(resolve)),
1581
+ const vpsMutationFinished = await waitForBoundedResult(
1582
+ state.vpsMutationCompletion,
1353
1583
  state.oauthShutdownWaitMs,
1354
1584
  );
1585
+ if (vpsMutationFinished) {
1586
+ state.connection?.close();
1587
+ state.connection = null;
1588
+ }
1589
+ await waitForBoundedResult(serverClose, state.oauthShutdownWaitMs);
1355
1590
  },
1356
1591
  };
1357
1592
  }
@@ -1,10 +0,0 @@
1
- <svg
2
- xmlns="http://www.w3.org/2000/svg"
3
- viewBox="0 0 64 64"
4
- role="img"
5
- aria-labelledby="relmio-mark-title"
6
- >
7
- <title id="relmio-mark-title">Relmio mark</title>
8
- <path fill="#137c74" d="M8 13 28 5v38c0 4-2 7-6 9L8 59Z" />
9
- <path fill="#12211f" d="M36 5l11 5c6 3 9 8 9 15v9c0 3 1 5 3 7l3 3c3 3 3 8 0 11s-8 3-11 0L40 44c-3-3-4-6-4-10Z" />
10
- </svg>