relmio 0.2.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.
Files changed (39) hide show
  1. package/CHANGELOG.md +190 -0
  2. package/LICENSE +21 -0
  3. package/README.md +122 -0
  4. package/SPEC.md +140 -0
  5. package/docs/architecture.md +127 -0
  6. package/docs/brand.md +43 -0
  7. package/docs/images/brand/relmio-concept-source.png +0 -0
  8. package/docs/images/brand/relmio-mark.svg +16 -0
  9. package/docs/images/setup/01-local-sign-in-ready.png +0 -0
  10. package/docs/images/setup/02-vps-identity-confirmed.png +0 -0
  11. package/docs/images/setup/03-n8n-detected.png +0 -0
  12. package/docs/images/setup/04-install-plan.png +0 -0
  13. package/docs/images/setup/05-bridge-ready.png +0 -0
  14. package/docs/maintenance.md +157 -0
  15. package/docs/manual-install.md +282 -0
  16. package/docs/n8n-configuration.md +199 -0
  17. package/docs/npm-publish.md +285 -0
  18. package/docs/roadmap.md +109 -0
  19. package/docs/security.md +105 -0
  20. package/docs/troubleshooting.md +193 -0
  21. package/docs/video-outline.md +152 -0
  22. package/package.json +45 -0
  23. package/scripts/build-npm-package.js +112 -0
  24. package/scripts/check-release-metadata.js +100 -0
  25. package/scripts/check-syntax.js +59 -0
  26. package/scripts/preview.js +69 -0
  27. package/src/cli.js +57 -0
  28. package/src/domain/safety.js +59 -0
  29. package/src/domain/templates.js +65 -0
  30. package/src/domain/validation.js +76 -0
  31. package/src/infrastructure/ssh.js +268 -0
  32. package/src/services/discovery.js +96 -0
  33. package/src/services/installer.js +239 -0
  34. package/src/services/oauth.js +351 -0
  35. package/src/ui/app.js +526 -0
  36. package/src/ui/index.html +440 -0
  37. package/src/ui/styles.css +645 -0
  38. package/src/ui/time.js +15 -0
  39. package/src/web/server.js +489 -0
package/src/ui/app.js ADDED
@@ -0,0 +1,526 @@
1
+ import { formatAuthUpdatedAt } from "./time.js";
2
+
3
+ const token = new URLSearchParams(window.location.search).get("session");
4
+ window.history.replaceState(null, "", window.location.pathname);
5
+
6
+ const state = {
7
+ step: 1,
8
+ fingerprint: null,
9
+ discovery: null,
10
+ networks: null,
11
+ installAttempted: false,
12
+ };
13
+
14
+ const element = (id) => document.getElementById(id);
15
+ const message = element("global-message");
16
+ const errorBox = element("global-error");
17
+
18
+ function resetFingerprint() {
19
+ state.fingerprint = null;
20
+ element("fingerprint-box").hidden = true;
21
+ element("fingerprint-confirm").checked = false;
22
+ element("password").value = "";
23
+ element("password").disabled = true;
24
+ element("connect-button").disabled = true;
25
+ }
26
+
27
+ function setMessage(text) {
28
+ message.textContent = text;
29
+ }
30
+
31
+ function showError(error) {
32
+ errorBox.textContent = error.message ?? "Something went wrong.";
33
+ errorBox.hidden = false;
34
+ errorBox.focus();
35
+ }
36
+
37
+ function clearError() {
38
+ errorBox.hidden = true;
39
+ errorBox.textContent = "";
40
+ }
41
+
42
+ function setBusy(button, busy, busyText) {
43
+ if (!button.dataset.label) {
44
+ button.dataset.label = button.textContent;
45
+ }
46
+ button.disabled = busy;
47
+ button.setAttribute("aria-busy", String(busy));
48
+ button.textContent = busy ? busyText : button.dataset.label;
49
+ }
50
+
51
+ async function copyText(value) {
52
+ try {
53
+ await navigator.clipboard.writeText(value);
54
+ return;
55
+ } catch {
56
+ const previouslyFocused = document.activeElement;
57
+ const textarea = document.createElement("textarea");
58
+ textarea.value = value;
59
+ textarea.readOnly = true;
60
+ textarea.setAttribute("aria-hidden", "true");
61
+ textarea.style.position = "fixed";
62
+ textarea.style.opacity = "0";
63
+ document.body.append(textarea);
64
+ textarea.select();
65
+ let copied = false;
66
+ try {
67
+ copied = document.execCommand("copy");
68
+ } finally {
69
+ textarea.remove();
70
+ previouslyFocused?.focus?.();
71
+ }
72
+ if (!copied) {
73
+ throw new Error("The browser refused clipboard access.");
74
+ }
75
+ }
76
+ }
77
+
78
+ const delay = (milliseconds) =>
79
+ new Promise((resolve) => window.setTimeout(resolve, milliseconds));
80
+
81
+ function validateAuthorizationUrl(value) {
82
+ const url = new URL(value);
83
+ if (
84
+ url.origin !== "https://auth.openai.com" ||
85
+ url.pathname !== "/oauth/authorize"
86
+ ) {
87
+ throw new Error("The wizard refused an unexpected sign-in destination.");
88
+ }
89
+ return url.toString();
90
+ }
91
+
92
+ async function waitForOAuthCompletion() {
93
+ for (let attempt = 0; attempt < 330; attempt += 1) {
94
+ const result = await api("/api/oauth/status");
95
+ if (result.status === "success") {
96
+ return;
97
+ }
98
+ if (result.status === "error") {
99
+ throw new Error(
100
+ result.error ?? "ChatGPT sign-in did not finish. Start again.",
101
+ );
102
+ }
103
+ await delay(attempt < 40 ? 250 : 1_000);
104
+ }
105
+ throw new Error("The sign-in request expired. Start a fresh login.");
106
+ }
107
+
108
+ function showStep(step) {
109
+ state.step = step;
110
+ for (const panel of document.querySelectorAll("[data-step]")) {
111
+ const active = Number(panel.dataset.step) === step;
112
+ panel.hidden = !active;
113
+ if (active) {
114
+ panel.querySelector("h2")?.focus({ preventScroll: true });
115
+ }
116
+ }
117
+ for (const marker of document.querySelectorAll("[data-step-marker]")) {
118
+ const markerStep = Number(marker.dataset.stepMarker);
119
+ marker.classList.toggle("complete", markerStep < step);
120
+ if (markerStep === step) {
121
+ marker.setAttribute("aria-current", "step");
122
+ } else {
123
+ marker.removeAttribute("aria-current");
124
+ }
125
+ }
126
+ window.scrollTo({ top: 0, behavior: "smooth" });
127
+ }
128
+
129
+ async function api(path, { method = "GET", body } = {}) {
130
+ if (!token) {
131
+ throw new Error("This wizard link is incomplete. Restart the setup command.");
132
+ }
133
+
134
+ let response;
135
+ try {
136
+ response = await fetch(path, {
137
+ method,
138
+ headers: {
139
+ "Content-Type": "application/json",
140
+ "X-Setup-Token": token,
141
+ },
142
+ body: body === undefined ? undefined : JSON.stringify(body),
143
+ });
144
+ } catch {
145
+ throw new Error(
146
+ "The local wizard server is not reachable. Keep its terminal window open and restart the latest command.",
147
+ );
148
+ }
149
+
150
+ let result;
151
+ try {
152
+ result = await response.json();
153
+ } catch {
154
+ throw new Error(
155
+ "The wizard returned an unreadable response. Restart the setup command and try again.",
156
+ );
157
+ }
158
+ if (!result || typeof result !== "object" || Array.isArray(result)) {
159
+ throw new Error(
160
+ "The wizard returned an unexpected response. Restart the setup command and try again.",
161
+ );
162
+ }
163
+ if (!response.ok) {
164
+ throw new Error(result.error ?? "The request failed.");
165
+ }
166
+ return result;
167
+ }
168
+
169
+ function renderAuthUpdatedAt(value) {
170
+ const row = element("auth-updated");
171
+ const time = element("auth-updated-time");
172
+ const formatted = formatAuthUpdatedAt(value);
173
+
174
+ if (!formatted) {
175
+ row.hidden = true;
176
+ time.textContent = "";
177
+ time.removeAttribute("datetime");
178
+ return null;
179
+ }
180
+
181
+ time.textContent = formatted;
182
+ time.setAttribute("datetime", value);
183
+ row.hidden = false;
184
+ return formatted;
185
+ }
186
+
187
+ async function refreshAuthStatus({ fresh = false } = {}) {
188
+ clearError();
189
+ const status = await api("/api/status");
190
+ const indicator = element("auth-indicator");
191
+ const loginButton = element("login-button");
192
+ const next = element("signin-next");
193
+ const formattedUpdatedAt = renderAuthUpdatedAt(status.authUpdatedAt);
194
+
195
+ if (status.previewMode) {
196
+ indicator.classList.add("ready");
197
+ element("auth-title").textContent = "Sanitized preview credential";
198
+ element("auth-detail").textContent =
199
+ "Preview mode uses sample data and cannot start a real ChatGPT sign-in.";
200
+ loginButton.textContent = "Preview sign-in disabled";
201
+ loginButton.dataset.label = "Preview sign-in disabled";
202
+ loginButton.disabled = true;
203
+ next.disabled = false;
204
+ setMessage("Sanitized preview mode: no live ChatGPT sign-in will open.");
205
+ return;
206
+ }
207
+
208
+ loginButton.disabled = false;
209
+ if (status.authExists) {
210
+ indicator.classList.add("ready");
211
+ element("auth-title").textContent = fresh
212
+ ? "Fresh credential saved"
213
+ : "Local credential found";
214
+ element("auth-detail").textContent =
215
+ "Continue uses it as-is. Refresh the sign-in if it is expired or was created by another client.";
216
+ loginButton.textContent = "Refresh ChatGPT sign-in";
217
+ loginButton.dataset.label = "Refresh ChatGPT sign-in";
218
+ next.disabled = false;
219
+ setMessage(
220
+ fresh && formattedUpdatedAt
221
+ ? `Fresh sign-in saved at ${formattedUpdatedAt} (local time).`
222
+ : "Local sign-in is ready.",
223
+ );
224
+ } else {
225
+ indicator.classList.remove("ready");
226
+ element("auth-title").textContent = "Sign-in needed";
227
+ element("auth-detail").textContent =
228
+ "A browser sign-in will open and wait for up to five minutes.";
229
+ loginButton.textContent = "Sign in with ChatGPT";
230
+ loginButton.dataset.label = "Sign in with ChatGPT";
231
+ next.disabled = true;
232
+ setMessage("Sign in with ChatGPT to continue.");
233
+ }
234
+ }
235
+
236
+ function fillSelect(select, items, selectedValue) {
237
+ select.replaceChildren();
238
+ for (const item of items) {
239
+ const option = document.createElement("option");
240
+ option.value = item.value;
241
+ option.textContent = item.label;
242
+ option.selected = item.value === selectedValue;
243
+ select.append(option);
244
+ }
245
+ }
246
+
247
+ async function loadNetworks() {
248
+ clearError();
249
+ const containerName = element("container-select").value;
250
+ const result = await api("/api/networks", {
251
+ method: "POST",
252
+ body: { containerName },
253
+ });
254
+ state.networks = result;
255
+ fillSelect(
256
+ element("network-select"),
257
+ result.networks.map((network) => ({ value: network, label: network })),
258
+ result.recommended,
259
+ );
260
+ }
261
+
262
+ async function discover() {
263
+ setMessage("Inspecting Docker with read-only commands…");
264
+ const result = await api("/api/discover", {
265
+ method: "POST",
266
+ body: {},
267
+ });
268
+ if (result.containers.length === 0) {
269
+ throw new Error("No running official n8n container was found.");
270
+ }
271
+
272
+ state.discovery = result;
273
+ element("docker-version").textContent = result.dockerVersion;
274
+ element("compose-version").textContent = result.composeVersion;
275
+ fillSelect(
276
+ element("container-select"),
277
+ result.containers.map((container) => ({
278
+ value: container.name,
279
+ label: `${container.name} — ${container.image}`,
280
+ })),
281
+ result.containers[0].name,
282
+ );
283
+ await loadNetworks();
284
+ showStep(3);
285
+ setMessage("n8n was found. Choose the network it shares with the sidecar.");
286
+ }
287
+
288
+ element("login-button").addEventListener("click", async (event) => {
289
+ const button = event.currentTarget;
290
+ const loginLink = element("login-link");
291
+ const loginWindow = window.open("about:blank", "_blank");
292
+ if (loginWindow) {
293
+ loginWindow.opener = null;
294
+ }
295
+ clearError();
296
+ loginLink.hidden = true;
297
+ loginLink.removeAttribute("href");
298
+ setBusy(button, true, "Waiting for browser sign-in…");
299
+ setMessage(
300
+ "Creating one fresh OpenAI sign-in link. The existing local credential will be replaced only after sign-in succeeds.",
301
+ );
302
+ try {
303
+ const result = await api("/api/oauth/login", {
304
+ method: "POST",
305
+ body: {},
306
+ });
307
+ const authorizationUrl = validateAuthorizationUrl(result.authorizationUrl);
308
+ loginLink.href = authorizationUrl;
309
+ loginLink.hidden = false;
310
+ if (loginWindow) {
311
+ loginWindow.location.replace(authorizationUrl);
312
+ }
313
+ setMessage(
314
+ "Complete the newly opened sign-in within five minutes. If no tab opened, use “Open fresh ChatGPT sign-in” below. If an OpenAI OAuth browser extension intercepts the callback, disable it temporarily and start again.",
315
+ );
316
+ await waitForOAuthCompletion();
317
+ loginLink.hidden = true;
318
+ loginLink.removeAttribute("href");
319
+ await refreshAuthStatus({ fresh: true });
320
+ } catch (error) {
321
+ if (loginWindow && loginWindow.location.href === "about:blank") {
322
+ loginWindow.close();
323
+ }
324
+ showError(error);
325
+ } finally {
326
+ setBusy(button, false);
327
+ }
328
+ });
329
+
330
+ element("signin-next").addEventListener("click", () => {
331
+ clearError();
332
+ showStep(2);
333
+ setMessage("Enter the VPS address exactly as Hostinger shows it.");
334
+ });
335
+
336
+ element("fingerprint-button").addEventListener("click", async (event) => {
337
+ const button = event.currentTarget;
338
+ clearError();
339
+ setBusy(button, true, "Checking identity…");
340
+ try {
341
+ const result = await api("/api/ssh/fingerprint", {
342
+ method: "POST",
343
+ body: {
344
+ host: element("host").value,
345
+ port: element("port").value,
346
+ },
347
+ });
348
+ state.fingerprint = result.fingerprint;
349
+ element("fingerprint-value").textContent = result.fingerprint;
350
+ element("fingerprint-box").hidden = false;
351
+ element("fingerprint-confirm").checked = false;
352
+ element("password").value = "";
353
+ element("password").disabled = true;
354
+ element("connect-button").disabled = true;
355
+ setMessage("Confirm the VPS identity before sending a password.");
356
+ } catch (error) {
357
+ showError(error);
358
+ } finally {
359
+ setBusy(button, false);
360
+ }
361
+ });
362
+
363
+ element("fingerprint-confirm").addEventListener("change", (event) => {
364
+ const confirmed = event.currentTarget.checked;
365
+ element("password").disabled = !confirmed;
366
+ element("connect-button").disabled = !confirmed;
367
+ if (confirmed) {
368
+ element("password").focus();
369
+ } else {
370
+ element("password").value = "";
371
+ }
372
+ });
373
+
374
+ element("host").addEventListener("input", resetFingerprint);
375
+ element("port").addEventListener("input", resetFingerprint);
376
+
377
+ element("vps-form").addEventListener("submit", async (event) => {
378
+ event.preventDefault();
379
+ const button = element("connect-button");
380
+ clearError();
381
+ setBusy(button, true, "Connecting safely…");
382
+ try {
383
+ await api("/api/ssh/connect", {
384
+ method: "POST",
385
+ body: {
386
+ host: element("host").value,
387
+ port: element("port").value,
388
+ username: element("username").value,
389
+ password: element("password").value,
390
+ expectedFingerprint: state.fingerprint,
391
+ },
392
+ });
393
+ element("password").value = "";
394
+ await discover();
395
+ } catch (error) {
396
+ element("password").value = "";
397
+ showError(error);
398
+ } finally {
399
+ setBusy(button, false);
400
+ }
401
+ });
402
+
403
+ element("container-select").addEventListener("change", async () => {
404
+ try {
405
+ await loadNetworks();
406
+ } catch (error) {
407
+ element("install-confirm").checked = false;
408
+ button.disabled = true;
409
+ showStep(2);
410
+ setMessage(
411
+ "The install stopped and the VPS connection was closed. Reconnect to inspect the sidecar before retrying.",
412
+ );
413
+ showError(error);
414
+ }
415
+ });
416
+
417
+ element("review-button").addEventListener("click", async (event) => {
418
+ const button = event.currentTarget;
419
+ clearError();
420
+ setBusy(button, true, "Preparing plan…");
421
+ try {
422
+ const networkName = element("network-select").value;
423
+ const plan = await api("/api/plan", {
424
+ method: "POST",
425
+ body: {
426
+ containerName: element("container-select").value,
427
+ networkName,
428
+ },
429
+ });
430
+ element("review-network").textContent = networkName;
431
+ element("review-endpoint").textContent = plan.endpointHostname;
432
+ element("install-confirm").checked = false;
433
+ element("install-button").disabled = true;
434
+ showStep(4);
435
+ setMessage("Review the plan. The VPS has not been changed.");
436
+ } catch (error) {
437
+ showError(error);
438
+ } finally {
439
+ setBusy(button, false);
440
+ }
441
+ });
442
+
443
+ element("install-confirm").addEventListener("change", (event) => {
444
+ element("install-button").disabled = !event.currentTarget.checked;
445
+ });
446
+
447
+ element("install-button").addEventListener("click", async (event) => {
448
+ const button = event.currentTarget;
449
+ clearError();
450
+ setBusy(button, true, "Building the sidecar…");
451
+ setMessage("Installing only the separate OAuth sidecar. This can take a minute.");
452
+ state.installAttempted = true;
453
+ try {
454
+ const result = await api("/api/install", {
455
+ method: "POST",
456
+ body: {
457
+ containerName: element("container-select").value,
458
+ networkName: element("network-select").value,
459
+ confirmed: element("install-confirm").checked,
460
+ },
461
+ });
462
+ element("result-url").textContent = result.baseUrl;
463
+ element("result-key").textContent = result.apiKeyPlaceholder;
464
+ element("result-model").textContent = result.models[0] ?? "";
465
+ element("result-models").textContent = result.models.join(", ");
466
+ element("result-http-url").textContent =
467
+ `${result.baseUrl.replace(/\/$/u, "")}/responses`;
468
+ showStep(5);
469
+ setMessage(
470
+ result.deploymentMode === "updated"
471
+ ? "OAuth refreshed on the existing wizard-managed sidecar. n8n was not restarted."
472
+ : "Installation verified. Your existing n8n was not restarted.",
473
+ );
474
+ } catch (error) {
475
+ showError(error);
476
+ } finally {
477
+ setBusy(button, false);
478
+ }
479
+ });
480
+
481
+ element("copy-settings").addEventListener("click", async (event) => {
482
+ const settings = [
483
+ `Base URL: ${element("result-url").textContent}`,
484
+ `API Key: ${element("result-key").textContent}`,
485
+ "Organization ID: leave empty",
486
+ "Add Custom Header: off",
487
+ ].join("\n");
488
+ clearError();
489
+ try {
490
+ await copyText(settings);
491
+ event.currentTarget.textContent = "Copied";
492
+ setMessage("OpenAI credential settings copied.");
493
+ } catch {
494
+ showError(new Error("Copy failed. Select the values manually."));
495
+ }
496
+ });
497
+
498
+ for (const button of document.querySelectorAll("[data-copy-target]")) {
499
+ button.addEventListener("click", async (event) => {
500
+ const target = element(event.currentTarget.dataset.copyTarget);
501
+ const label = event.currentTarget.dataset.copyLabel;
502
+ const value = target.textContent;
503
+ clearError();
504
+ try {
505
+ await copyText(value);
506
+ event.currentTarget.textContent = "Copied";
507
+ setMessage(`${label} copied.`);
508
+ } catch {
509
+ showError(new Error(`Copy failed. Select the ${label} manually.`));
510
+ }
511
+ });
512
+ }
513
+
514
+ for (const button of document.querySelectorAll(".back-button")) {
515
+ button.addEventListener("click", () => {
516
+ clearError();
517
+ showStep(Number(button.dataset.back));
518
+ setMessage(
519
+ state.installAttempted
520
+ ? "The install was attempted. Reconnect to inspect the sidecar; n8n was not restarted."
521
+ : "No VPS changes have been made.",
522
+ );
523
+ });
524
+ }
525
+
526
+ refreshAuthStatus().catch(showError);