relmio 0.2.12 → 0.2.14

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/CHANGELOG.md CHANGED
@@ -7,6 +7,49 @@ checks the registry separately after publication.
7
7
 
8
8
  ## Unreleased
9
9
 
10
+ ## [0.2.14] - 2026-08-04
11
+
12
+ ### Added
13
+
14
+ - Add staged Homebrew formula and WinGet portable-package generation with
15
+ x64/ARM64 manifests, installed-command smoke tests, and review-only CI
16
+ artifacts for package-manager publication.
17
+ - Add `relmio --version` and `relmio -v` for noninteractive installer and
18
+ package-manager verification.
19
+
20
+ ### Changed
21
+
22
+ - Report Homebrew and WinGet publication status in the hosted install page and
23
+ documentation, while keeping unapproved commands out of the primary picker.
24
+
25
+ ### Fixed
26
+
27
+ - Replace the hosted Command Prompt installer route's PowerShell launch with a
28
+ PowerShell-free, non-admin native batch bootstrap that reuses Node.js 22+
29
+ when available or verifies a pinned official Windows runtime before use.
30
+ - Keep downloaded checksum-manifest text out of CMD evaluation and use reviewed
31
+ Node.js 22.23.2 x64/ARM64 digests embedded in the release.
32
+ - Download the CMD bootstrap to a collision-resistant temporary name without
33
+ overwriting an existing `install.cmd`, then clean it after execution.
34
+ - Show deterministic download, checksum-verification, and extraction stages in
35
+ every bootstrap so temporary Node.js runtime setup does not appear stalled.
36
+
37
+ ## [0.2.13] - 2026-08-04
38
+
39
+ ### Changed
40
+
41
+ - Compact the local setup wizard so its active step stays near the top, move
42
+ safety and status notices into dismissible toasts, and collapse the optional
43
+ AI Agent and HTTP Request recipes until users choose to open them.
44
+ - Complete the HTTP Request recipe with authorization, content type, and a
45
+ copyable sample Responses API JSON payload.
46
+
47
+ ### Fixed
48
+
49
+ - Copy credential values reliably in Opera GX on Windows by preserving the
50
+ synchronous user gesture for the selection-based clipboard path before
51
+ falling back to the modern Clipboard API.
52
+
10
53
  ## [0.2.12] - 2026-08-04
11
54
 
12
55
  ### Fixed
package/README.md CHANGED
@@ -105,16 +105,24 @@ irm https://relmio.vercel.app/install.ps1 | iex
105
105
  ### Windows Command Prompt
106
106
 
107
107
  ```bat
108
- "%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -ExecutionPolicy Bypass -Command "irm https://relmio.vercel.app/install.ps1 | iex"
108
+ for /f "delims=" %F in ("%TEMP%\relmio-install-%RANDOM%-%RANDOM%-%RANDOM%.cmd") do @if exist "%~F" (exit /b 80) else curl -fsSL --remove-on-error https://relmio.vercel.app/install.cmd -o "%~F" && set "RELMIO_SELF_DELETE=%~F" && call "%~F"
109
109
  ```
110
110
 
111
111
  No Node.js or Git Bash installation is required first. The
112
112
  [POSIX](https://github.com/Demonbane18/relmio/blob/main/web/public/install.sh)
113
113
  and [Windows PowerShell](https://github.com/Demonbane18/relmio/blob/main/web/public/install.ps1)
114
- bootstraps reuse Node.js 22 or newer when available. Otherwise they download
115
- the matching current official Node.js 22 runtime to a private temporary
116
- directory, verify its SHA-256 checksum, run Relmio with npm lifecycle scripts
117
- disabled, and remove the temporary runtime when the wizard closes.
114
+ bootstraps, plus the PowerShell-free [Command Prompt](https://github.com/Demonbane18/relmio/blob/main/web/public/install.cmd)
115
+ bootstrap, reuse Node.js 22 or newer when available. Otherwise they show
116
+ staged **Please wait** messages while they download the matching current
117
+ official Node.js 22 runtime to a private temporary directory, verify its
118
+ SHA-256 checksum, run Relmio with npm lifecycle scripts disabled, and remove
119
+ the temporary runtime when the wizard closes. The Command Prompt path runs as
120
+ the current user and does not request administrator access or change Windows
121
+ security policy.
122
+
123
+ Homebrew tap publication and WinGet catalog approval are pending. Their install
124
+ commands will be added only after each public package passes a real
125
+ package-manager installation test. Use a direct installer above today.
118
126
 
119
127
  Users who already have Node.js 22 or newer can run the npm package directly:
120
128
 
@@ -137,8 +145,9 @@ a browser.
137
145
 
138
146
  - On macOS/Linux/WSL/Git Bash: `curl`, `awk`, `tar`, and either `sha256sum` or
139
147
  `shasum`; Git Bash also needs `unzip`
140
- - On native Windows: Windows PowerShell 5.1 or PowerShell 7; Command Prompt can
141
- launch the included PowerShell bootstrap
148
+ - On native Windows: Command Prompt uses its built-in `curl`, `certutil`, and
149
+ `tar` tools; Windows PowerShell 5.1 or PowerShell 7 remains an alternative
150
+ bootstrap
142
151
  - A browser and an eligible ChatGPT/Codex account
143
152
  - A self-hosted n8n Docker deployment on a VPS
144
153
  - Docker Compose v2, SSH access, and a Docker network shared with n8n
@@ -49,13 +49,19 @@ irm https://relmio.vercel.app/install.ps1 | iex
49
49
  Windows Command Prompt:
50
50
 
51
51
  ```bat
52
- "%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -ExecutionPolicy Bypass -Command "irm https://relmio.vercel.app/install.ps1 | iex"
52
+ for /f "delims=" %F in ("%TEMP%\relmio-install-%RANDOM%-%RANDOM%-%RANDOM%.cmd") do @if exist "%~F" (exit /b 80) else curl -fsSL --remove-on-error https://relmio.vercel.app/install.cmd -o "%~F" && set "RELMIO_SELF_DELETE=%~F" && call "%~F"
53
53
  ```
54
54
 
55
55
  These commands do not require Node.js to be installed. The Windows options do
56
56
  not require Git Bash. Each bootstrap reuses Node.js 22 or newer when available,
57
- or downloads an official temporary runtime and verifies its SHA-256 checksum
58
- before execution.
57
+ or shows staged **Please wait** messages while it downloads an official
58
+ temporary runtime and verifies its SHA-256 checksum before execution. The
59
+ Command Prompt route is PowerShell-free, non-admin, and does not change Windows
60
+ security policy.
61
+
62
+ Homebrew tap publication and WinGet catalog approval are pending. Until each
63
+ public package passes its real install test, use one of the direct bootstrap
64
+ commands on this page.
59
65
 
60
66
  If you choose the existing-Node fallback, confirm Node is version 22 or newer
61
67
  and check the published package version first:
@@ -117,8 +123,10 @@ bypassed.
117
123
  | Symptom | Meaning | Fix |
118
124
  |---|---|---|
119
125
  | `node: command not found`, `node is not recognized`, or Node is older than 22 | The NPX fallback cannot use the local runtime. | Use the macOS/Linux curl command or the native Windows PowerShell/Command Prompt command above. Either can run with a verified temporary runtime. Do not install Node.js on the VPS for the wizard. |
120
- | `curl` or `sh` is not recognized on Windows | The macOS/Linux command was pasted into a native Windows terminal. | Use the PowerShell command in PowerShell, or the `"%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile ...` command in Command Prompt. Git Bash is not required. |
121
- | A bootstrap reports a checksum mismatch | The Node.js download did not match the official SHA-256 manifest, so it was not executed. | Retry on a trusted connection. Do not bypass the check. If it repeats, use an existing Node.js 22+ installation and report the sanitized error. |
126
+ | `curl` or `sh` is not recognized on Windows | The macOS/Linux command was pasted into a native Windows terminal. | Use the PowerShell command in PowerShell or the collision-safe temporary-file command shown above in Command Prompt. Git Bash is not required. If Command Prompt does not have `curl`, update Windows or use the PowerShell route. |
127
+ | Command Prompt says it cannot create `powershell.exe` | A Windows policy denied PowerShell before the old CMD route could start its installer. | Use the current PowerShell-free Command Prompt command above. It downloads `install.cmd`, runs as your current user, and does not request elevation or change execution policy. |
128
+ | The bootstrap stays on a `Please wait` stage | Node.js is missing or older than 22, so the bootstrap is downloading, checking, or extracting a temporary Node.js 22 runtime. | Keep the terminal open while the deterministic stage messages advance. The runtime is verified before it runs, is removed after the wizard exits, and is not installed system-wide. |
129
+ | A bootstrap reports a checksum mismatch | The Node.js download did not match its reviewed official SHA-256 checksum, so it was not executed. | Retry on a trusted connection. Do not bypass the check. If it repeats, use an existing Node.js 22+ installation and report the sanitized error. |
122
130
  | Windows PowerShell shows `[eval]:1` before the wizard starts | An older bootstrap passed a JavaScript expression through `node -p`; PowerShell native-argument handling can alter that expression. | Update to the latest `relmio@latest` and rerun the same PowerShell or Command Prompt command. The current bootstrap parses the literal `node --version` output and reuses Node.js 22 or newer. |
123
131
  | Windows reports `spawn EINVAL` when starting ChatGPT sign-in | An older wizard tried to execute `npx.cmd` directly; Windows requires the current Node runtime to launch npm's JavaScript CLI. | Update to the latest `relmio@latest` release and restart the setup command. The current wizard keeps the macOS/Linux/WSL/Git Bash `npx` path unchanged. |
124
132
  | The browser did not open | The automatic browser launch failed, but the local server may still be running. | Keep the newest terminal open and press Enter to ask an interactive wizard terminal to open it again. If the terminal is noninteractive or the launcher still fails, copy its newest `127.0.0.1` setup URL into the browser. Do not reuse a URL from a closed terminal. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "relmio",
3
- "version": "0.2.12",
3
+ "version": "0.2.14",
4
4
  "description": "Turn a supported ChatGPT/Codex OAuth sign-in into a private OpenAI-compatible endpoint, starting with self-hosted n8n.",
5
5
  "keywords": [
6
6
  "relmio",
@@ -37,7 +37,10 @@
37
37
  },
38
38
  "files": [
39
39
  "src",
40
- "scripts",
40
+ "scripts/build-npm-package.js",
41
+ "scripts/check-release-metadata.js",
42
+ "scripts/check-syntax.js",
43
+ "scripts/preview.js",
41
44
  "docs",
42
45
  "README.md",
43
46
  "SPEC.md",
package/src/cli.js CHANGED
@@ -1,40 +1,86 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import { randomBytes } from "node:crypto";
4
+ import { realpathSync } from "node:fs";
5
+ import { readFile } from "node:fs/promises";
6
+ import { fileURLToPath } from "node:url";
4
7
 
5
8
  import { attachBrowserReopenOnEnter, openBrowser } from "./browser.js";
6
9
  import { startWizardServer } from "./web/server.js";
7
10
 
8
- const sessionToken = randomBytes(32).toString("base64url");
9
- const wizard = await startWizardServer({ sessionToken });
10
- const url = `${wizard.origin}/?session=${sessionToken}`;
11
-
12
- console.log("");
13
- console.log("Relmio");
14
- console.log("---------");
15
- console.log(`Local wizard: ${url}`);
16
- console.log("");
17
- console.log("This creates a separate sidecar and never restarts n8n.");
18
- console.log("Keep this Terminal window open while using the wizard.");
19
- console.log("Press Control+C to stop.");
20
- console.log("");
21
-
22
- openBrowser(url);
23
- const detachBrowserReopen = attachBrowserReopenOnEnter({ url });
24
-
25
- let closing = false;
26
- async function close() {
27
- if (closing) {
11
+ const cliPath = fileURLToPath(import.meta.url);
12
+ const packageJsonUrl = new URL("../package.json", import.meta.url);
13
+
14
+ export function isCliEntryPath(entryPath, realpath = realpathSync) {
15
+ if (typeof entryPath !== "string" || entryPath === "") {
16
+ return false;
17
+ }
18
+ try {
19
+ return realpath(entryPath) === realpath(cliPath);
20
+ } catch {
21
+ return false;
22
+ }
23
+ }
24
+
25
+ export function cliMode(argumentsList) {
26
+ return argumentsList.length === 1 &&
27
+ (argumentsList[0] === "--version" || argumentsList[0] === "-v")
28
+ ? "version"
29
+ : "wizard";
30
+ }
31
+
32
+ export async function runCli({
33
+ argumentsList = process.argv.slice(2),
34
+ log = console.log,
35
+ readPackage = () => readFile(packageJsonUrl, "utf8"),
36
+ } = {}) {
37
+ if (cliMode(argumentsList) === "version") {
38
+ const packageJson = JSON.parse(await readPackage());
39
+ if (typeof packageJson.version !== "string" || packageJson.version === "") {
40
+ throw new Error("Relmio package metadata does not contain a version.");
41
+ }
42
+ log(packageJson.version);
28
43
  return;
29
44
  }
30
- closing = true;
31
- detachBrowserReopen();
32
- await wizard.close();
45
+
46
+ const sessionToken = randomBytes(32).toString("base64url");
47
+ const wizard = await startWizardServer({ sessionToken });
48
+ const url = `${wizard.origin}/?session=${sessionToken}`;
49
+
50
+ log("");
51
+ log("Relmio");
52
+ log("---------");
53
+ log(`Local wizard: ${url}`);
54
+ log("");
55
+ log("This creates a separate sidecar and never restarts n8n.");
56
+ log("Keep this Terminal window open while using the wizard.");
57
+ log("Press Control+C to stop.");
58
+ log("");
59
+
60
+ openBrowser(url);
61
+ const detachBrowserReopen = attachBrowserReopenOnEnter({ url });
62
+
63
+ let closing = false;
64
+ async function close() {
65
+ if (closing) {
66
+ return;
67
+ }
68
+ closing = true;
69
+ detachBrowserReopen();
70
+ await wizard.close();
71
+ }
72
+
73
+ process.once("SIGINT", async () => {
74
+ await close();
75
+ });
76
+ process.once("SIGTERM", async () => {
77
+ await close();
78
+ });
33
79
  }
34
80
 
35
- process.once("SIGINT", async () => {
36
- await close();
37
- });
38
- process.once("SIGTERM", async () => {
39
- await close();
40
- });
81
+ if (isCliEntryPath(process.argv[1])) {
82
+ runCli().catch((error) => {
83
+ console.error(error.message);
84
+ process.exitCode = 1;
85
+ });
86
+ }
package/src/ui/app.js CHANGED
@@ -13,8 +13,16 @@ const state = {
13
13
  };
14
14
 
15
15
  const element = (id) => document.getElementById(id);
16
- const message = element("global-message");
16
+ const messageToast = element("global-message");
17
+ const message = element("global-message-text");
17
18
  const errorBox = element("global-error");
19
+ const errorMessage = element("global-error-text");
20
+ const toastTimers = new WeakMap();
21
+
22
+ function dismissToast(toast) {
23
+ window.clearTimeout(toastTimers.get(toast));
24
+ toast.hidden = true;
25
+ }
18
26
 
19
27
  function resetFingerprint() {
20
28
  state.fingerprint = null;
@@ -26,18 +34,24 @@ function resetFingerprint() {
26
34
  }
27
35
 
28
36
  function setMessage(text) {
37
+ messageToast.hidden = false;
29
38
  message.textContent = text;
39
+ window.clearTimeout(toastTimers.get(messageToast));
40
+ toastTimers.set(
41
+ messageToast,
42
+ window.setTimeout(() => dismissToast(messageToast), 6_000),
43
+ );
30
44
  }
31
45
 
32
46
  function showError(error) {
33
- errorBox.textContent = error.message ?? "Something went wrong.";
47
+ errorMessage.textContent = error.message ?? "Something went wrong.";
34
48
  errorBox.hidden = false;
35
49
  errorBox.focus();
36
50
  }
37
51
 
38
52
  function clearError() {
39
53
  errorBox.hidden = true;
40
- errorBox.textContent = "";
54
+ errorMessage.textContent = "";
41
55
  }
42
56
 
43
57
  function setBusy(button, busy, busyText) {
@@ -68,32 +82,124 @@ function flashCopied(button) {
68
82
  }
69
83
 
70
84
  async function copyText(value) {
85
+ const previouslyFocused = document.activeElement;
86
+ const textarea = document.createElement("textarea");
87
+ textarea.value = value;
88
+ textarea.readOnly = true;
89
+ textarea.setAttribute("aria-hidden", "true");
90
+ textarea.style.position = "fixed";
91
+ textarea.style.opacity = "0";
92
+
93
+ let copied = false;
71
94
  try {
72
- await navigator.clipboard.writeText(value);
73
- return;
74
- } catch {
75
- const previouslyFocused = document.activeElement;
76
- const textarea = document.createElement("textarea");
77
- textarea.value = value;
78
- textarea.readOnly = true;
79
- textarea.setAttribute("aria-hidden", "true");
80
- textarea.style.position = "fixed";
81
- textarea.style.opacity = "0";
82
95
  document.body.append(textarea);
96
+ textarea.focus();
83
97
  textarea.select();
84
- let copied = false;
98
+ textarea.setSelectionRange?.(0, textarea.value.length);
99
+ copied = document.execCommand("copy");
100
+ } catch {
101
+ copied = false;
102
+ } finally {
103
+ textarea.remove();
104
+ previouslyFocused?.focus?.();
105
+ }
106
+
107
+ if (copied) {
108
+ return;
109
+ }
110
+
111
+ if (navigator.clipboard?.writeText) {
85
112
  try {
86
- copied = document.execCommand("copy");
87
- } finally {
88
- textarea.remove();
89
- previouslyFocused?.focus?.();
90
- }
91
- if (!copied) {
92
- throw new Error("The browser refused clipboard access.");
113
+ await navigator.clipboard.writeText(value);
114
+ return;
115
+ } catch {
116
+ // The generic error below avoids exposing copied configuration values.
93
117
  }
94
118
  }
119
+
120
+ throw new Error("The browser refused clipboard access.");
121
+ }
122
+
123
+ function renderHttpRequestBody(model) {
124
+ element("result-http-body").textContent = JSON.stringify(
125
+ {
126
+ model,
127
+ input: "Reply with exactly: bridge works",
128
+ },
129
+ null,
130
+ 2,
131
+ );
132
+ }
133
+
134
+ function copyCredentialSettings() {
135
+ return [
136
+ `Base URL: ${element("result-url").textContent}`,
137
+ `API Key: ${element("result-key").textContent}`,
138
+ "Organization ID: leave empty",
139
+ "Add Custom Header: off",
140
+ ].join("\n");
141
+ }
142
+
143
+ function copyHttpRecipe() {
144
+ return [
145
+ "Method: POST",
146
+ `URL: ${element("result-http-url").textContent}`,
147
+ "Authorization: Bearer local-only",
148
+ "Content-Type: application/json",
149
+ "Authentication: None",
150
+ "JSON body:",
151
+ element("result-http-body").textContent,
152
+ ].join("\n");
153
+ }
154
+
155
+ function copyValueFor(button) {
156
+ if (button.dataset.copyTarget) {
157
+ return element(button.dataset.copyTarget).textContent;
158
+ }
159
+ if (button.dataset.copyGroup === "credential") {
160
+ return copyCredentialSettings();
161
+ }
162
+ if (button.dataset.copyGroup === "http") {
163
+ return copyHttpRecipe();
164
+ }
165
+ return "";
166
+ }
167
+
168
+ function createCopyClickHandler({
169
+ copyValueFor,
170
+ clearError,
171
+ copyText,
172
+ flashCopied,
173
+ setMessage,
174
+ showError,
175
+ }) {
176
+ return async function handleCopyClick(event) {
177
+ const button = event.currentTarget;
178
+ const label = button.dataset.copyLabel;
179
+ const value = copyValueFor(button);
180
+ clearError();
181
+ try {
182
+ if (!value) {
183
+ throw new Error("No displayed value is available to copy.");
184
+ }
185
+ await copyText(value);
186
+ flashCopied(button);
187
+ setMessage(`${label} copied.`);
188
+ } catch {
189
+ showError(new Error(`Copy failed. Select the ${label} manually.`));
190
+ }
191
+ };
95
192
  }
96
193
 
194
+ const handleCopyClick = createCopyClickHandler({
195
+ copyValueFor,
196
+ clearError,
197
+ copyText,
198
+ flashCopied,
199
+ setMessage,
200
+ showError,
201
+ });
202
+
97
203
  const delay = (milliseconds) =>
98
204
  new Promise((resolve) => window.setTimeout(resolve, milliseconds));
99
205
 
@@ -126,6 +232,11 @@ async function waitForOAuthCompletion() {
126
232
 
127
233
  function showStep(step) {
128
234
  state.step = step;
235
+ document.body.dataset.currentStep = String(step);
236
+ if (step === 5) {
237
+ dismissToast(element("global-safety"));
238
+ dismissToast(element("global-backup"));
239
+ }
129
240
  for (const panel of document.querySelectorAll("[data-step]")) {
130
241
  const active = Number(panel.dataset.step) === step;
131
242
  panel.hidden = !active;
@@ -483,10 +594,12 @@ element("install-button").addEventListener("click", async (event) => {
483
594
  });
484
595
  element("result-url").textContent = result.baseUrl;
485
596
  element("result-key").textContent = result.apiKeyPlaceholder;
486
- element("result-model").textContent = result.models[0] ?? "";
597
+ const firstModel = result.models[0] ?? "Not detected";
598
+ element("result-model").textContent = firstModel;
487
599
  element("result-models").textContent = result.models.join(", ");
488
600
  element("result-http-url").textContent =
489
601
  `${result.baseUrl.replace(/\/$/u, "")}/responses`;
602
+ renderHttpRequestBody(firstModel);
490
603
  showStep(5);
491
604
  setMessage(
492
605
  result.deploymentMode === "updated"
@@ -500,36 +613,15 @@ element("install-button").addEventListener("click", async (event) => {
500
613
  }
501
614
  });
502
615
 
503
- element("copy-settings").addEventListener("click", async (event) => {
504
- const settings = [
505
- `Base URL: ${element("result-url").textContent}`,
506
- `API Key: ${element("result-key").textContent}`,
507
- "Organization ID: leave empty",
508
- "Add Custom Header: off",
509
- ].join("\n");
510
- clearError();
511
- try {
512
- await copyText(settings);
513
- flashCopied(event.currentTarget);
514
- setMessage("OpenAI credential settings copied.");
515
- } catch {
516
- showError(new Error("Copy failed. Select the values manually."));
517
- }
518
- });
616
+ for (const button of document.querySelectorAll(
617
+ "[data-copy-target], [data-copy-group]",
618
+ )) {
619
+ button.addEventListener("click", handleCopyClick);
620
+ }
519
621
 
520
- for (const button of document.querySelectorAll("[data-copy-target]")) {
521
- button.addEventListener("click", async (event) => {
522
- const target = element(event.currentTarget.dataset.copyTarget);
523
- const label = event.currentTarget.dataset.copyLabel;
524
- const value = target.textContent;
525
- clearError();
526
- try {
527
- await copyText(value);
528
- flashCopied(event.currentTarget);
529
- setMessage(`${label} copied.`);
530
- } catch {
531
- showError(new Error(`Copy failed. Select the ${label} manually.`));
532
- }
622
+ for (const button of document.querySelectorAll("[data-dismiss-toast]")) {
623
+ button.addEventListener("click", (event) => {
624
+ dismissToast(element(event.currentTarget.dataset.dismissToast));
533
625
  });
534
626
  }
535
627
 
@@ -545,4 +637,5 @@ for (const button of document.querySelectorAll(".back-button")) {
545
637
  });
546
638
  }
547
639
 
640
+ renderHttpRequestBody(element("result-model").textContent);
548
641
  refreshAuthStatus().catch(showError);
package/src/ui/index.html CHANGED
@@ -16,7 +16,7 @@
16
16
  <script src="/theme.js" type="module"></script>
17
17
  <script src="/app.js" type="module"></script>
18
18
  </head>
19
- <body>
19
+ <body data-current-step="1">
20
20
  <a class="skip-link" href="#main-content">Skip to setup</a>
21
21
 
22
22
  <header class="site-header">
@@ -55,32 +55,93 @@
55
55
  </div>
56
56
  </header>
57
57
 
58
- <main id="main-content" class="shell">
59
- <section class="intro" aria-labelledby="page-title">
60
- <p class="eyebrow">Guided VPS setup</p>
61
- <h1 id="page-title">Connect n8n without changing n8n</h1>
62
- <p class="lede">
63
- This wizard installs one separate Docker sidecar. Your existing n8n
64
- image, Compose file, container, and workflows stay untouched.
65
- </p>
66
- </section>
67
-
68
- <aside class="safety-note" aria-label="Safety guarantee">
58
+ <div class="toast-stack" aria-label="Wizard notifications">
59
+ <aside
60
+ id="global-safety"
61
+ class="toast safety-note"
62
+ aria-label="Safety guarantee"
63
+ >
69
64
  <strong>Sidecar-only guarantee</strong>
70
65
  <span>
71
66
  No n8n restart. No n8n rebuild. No public port. You approve the exact
72
67
  plan before anything is written.
73
68
  </span>
69
+ <button
70
+ class="toast-close"
71
+ type="button"
72
+ data-dismiss-toast="global-safety"
73
+ aria-label="Dismiss safety guarantee"
74
+ >
75
+ <span aria-hidden="true">×</span>
76
+ </button>
74
77
  </aside>
75
78
 
76
- <aside class="safety-note backup-note" aria-label="Backup reminder">
79
+ <aside
80
+ id="global-backup"
81
+ class="toast safety-note backup-note"
82
+ aria-label="Backup reminder"
83
+ >
77
84
  <strong>Back up first</strong>
78
85
  <span>
79
86
  Export your n8n workflows before connecting. This wizard uses
80
87
  sidecar-only commands, but it still writes to your VPS.
81
88
  </span>
89
+ <button
90
+ class="toast-close"
91
+ type="button"
92
+ data-dismiss-toast="global-backup"
93
+ aria-label="Dismiss backup reminder"
94
+ >
95
+ <span aria-hidden="true">×</span>
96
+ </button>
82
97
  </aside>
83
98
 
99
+ <div
100
+ id="global-message"
101
+ class="toast message"
102
+ role="status"
103
+ aria-live="polite"
104
+ >
105
+ <span id="global-message-text">Checking your local sign-in…</span>
106
+ <button
107
+ class="toast-close"
108
+ type="button"
109
+ data-dismiss-toast="global-message"
110
+ aria-label="Dismiss status message"
111
+ >
112
+ <span aria-hidden="true">×</span>
113
+ </button>
114
+ </div>
115
+
116
+ <div
117
+ id="global-error"
118
+ class="toast error"
119
+ role="alert"
120
+ tabindex="-1"
121
+ hidden
122
+ >
123
+ <span id="global-error-text"></span>
124
+ <button
125
+ class="toast-close"
126
+ type="button"
127
+ data-dismiss-toast="global-error"
128
+ aria-label="Dismiss error"
129
+ >
130
+ <span aria-hidden="true">×</span>
131
+ </button>
132
+ </div>
133
+ </div>
134
+
135
+ <main id="main-content" class="shell">
136
+ <section class="intro" aria-labelledby="page-title">
137
+ <p class="eyebrow">Guided VPS setup</p>
138
+ <h1 id="page-title">Connect n8n without changing n8n</h1>
139
+ <p class="lede">
140
+ This wizard installs one separate Docker sidecar. Your existing n8n
141
+ image, Compose file, container, and workflows stay untouched.
142
+ </p>
143
+ </section>
144
+
84
145
  <nav class="steps" aria-label="Setup progress">
85
146
  <ol>
86
147
  <li data-step-marker="1" aria-current="step">
@@ -93,11 +154,6 @@
93
154
  </ol>
94
155
  </nav>
95
156
 
96
- <div id="global-message" class="message" role="status" aria-live="polite">
97
- Checking your local sign-in…
98
- </div>
99
- <div id="global-error" class="error" role="alert" tabindex="-1" hidden></div>
100
-
101
157
  <section class="panel" data-step="1" aria-labelledby="signin-title">
102
158
  <div class="panel-heading">
103
159
  <span class="step-kicker">Step 1 of 5</span>
@@ -377,70 +433,112 @@
377
433
  </div>
378
434
  </dl>
379
435
 
380
- <button id="copy-settings" class="button secondary" type="button">
436
+ <button
437
+ id="copy-settings"
438
+ class="button secondary"
439
+ type="button"
440
+ data-copy-group="credential"
441
+ data-copy-label="OpenAI credential settings"
442
+ >
381
443
  Copy credential settings
382
444
  </button>
383
445
 
384
- <div class="model-box">
385
- <h3>2. OpenAI Chat Model for AI Agent or Basic LLM Chain</h3>
386
- <dl class="result-list">
387
- <div>
388
- <dt>Model ID</dt>
389
- <dd class="result-value">
390
- <code id="result-model">Not detected</code>
391
- <button
392
- class="button secondary copy-value"
393
- type="button"
394
- data-copy-target="result-model"
395
- data-copy-label="model ID"
396
- aria-label="Copy model ID"
397
- >
398
- Copy
399
- </button>
400
- </dd>
401
- </div>
402
- <div>
403
- <dt>Responses API</dt>
404
- <dd><strong>On</strong> for Chat Model node version 1.3</dd>
405
- </div>
406
- </dl>
407
- <p>All models detected: <span id="result-models">Not detected</span></p>
408
- <p>
409
- If the Responses API switch is absent, keep the node's default
410
- Chat Completions behavior; the bridge supports that route too.
411
- </p>
412
- </div>
413
-
414
- <div class="model-box">
415
- <h3>3. HTTP Request node</h3>
416
- <dl class="result-list">
417
- <div>
418
- <dt>Method</dt>
419
- <dd><code>POST</code></dd>
420
- </div>
421
- <div>
422
- <dt>URL</dt>
423
- <dd class="result-value">
424
- <code id="result-http-url">
425
- http://n8n-openai-oauth:10531/v1/responses
426
- </code>
427
- <button
428
- class="button secondary copy-value"
429
- type="button"
430
- data-copy-target="result-http-url"
431
- data-copy-label="HTTP endpoint"
432
- aria-label="Copy HTTP endpoint"
433
- >
434
- Copy
435
- </button>
436
- </dd>
437
- </div>
438
- <div>
439
- <dt>Authentication</dt>
440
- <dd>None</dd>
441
- </div>
442
- </dl>
443
- </div>
446
+ <details class="recipe-disclosure">
447
+ <summary>
448
+ <span>2. OpenAI Chat Model for AI Agent or Basic LLM Chain</span>
449
+ </summary>
450
+ <div class="model-box recipe-content">
451
+ <dl class="result-list">
452
+ <div>
453
+ <dt>Model ID</dt>
454
+ <dd class="result-value">
455
+ <code id="result-model">Not detected</code>
456
+ <button
457
+ class="button secondary copy-value"
458
+ type="button"
459
+ data-copy-target="result-model"
460
+ data-copy-label="model ID"
461
+ aria-label="Copy model ID"
462
+ >
463
+ Copy
464
+ </button>
465
+ </dd>
466
+ </div>
467
+ <div>
468
+ <dt>Responses API</dt>
469
+ <dd><strong>On</strong> for Chat Model node version 1.3</dd>
470
+ </div>
471
+ </dl>
472
+ <p>All models detected: <span id="result-models">Not detected</span></p>
473
+ <p>
474
+ If the Responses API switch is absent, keep the node's default
475
+ Chat Completions behavior; the bridge supports that route too.
476
+ </p>
477
+ </div>
478
+ </details>
479
+
480
+ <details class="recipe-disclosure">
481
+ <summary><span>3. HTTP Request node</span></summary>
482
+ <div class="model-box recipe-content">
483
+ <dl class="result-list">
484
+ <div>
485
+ <dt>Method</dt>
486
+ <dd><code>POST</code></dd>
487
+ </div>
488
+ <div>
489
+ <dt>URL</dt>
490
+ <dd class="result-value">
491
+ <code id="result-http-url">http://n8n-openai-oauth:10531/v1/responses</code>
492
+ <button
493
+ class="button secondary copy-value"
494
+ type="button"
495
+ data-copy-target="result-http-url"
496
+ data-copy-label="HTTP endpoint"
497
+ aria-label="Copy HTTP endpoint"
498
+ >
499
+ Copy
500
+ </button>
501
+ </dd>
502
+ </div>
503
+ <div>
504
+ <dt>Authorization</dt>
505
+ <dd><code>Bearer local-only</code></dd>
506
+ </div>
507
+ <div>
508
+ <dt>Content-Type</dt>
509
+ <dd><code>application/json</code></dd>
510
+ </div>
511
+ <div>
512
+ <dt>Authentication</dt>
513
+ <dd>None</dd>
514
+ </div>
515
+ <div class="sample-body-row">
516
+ <dt>JSON body</dt>
517
+ <dd class="result-value">
518
+ <pre class="sample-json"><code id="result-http-body"></code></pre>
519
+ <button
520
+ class="button secondary copy-value"
521
+ type="button"
522
+ data-copy-target="result-http-body"
523
+ data-copy-label="HTTP JSON body"
524
+ aria-label="Copy HTTP JSON body"
525
+ >
526
+ Copy
527
+ </button>
528
+ </dd>
529
+ </div>
530
+ </dl>
531
+ <button
532
+ id="copy-http-recipe"
533
+ class="button secondary"
534
+ type="button"
535
+ data-copy-group="http"
536
+ data-copy-label="HTTP request recipe"
537
+ >
538
+ Copy HTTP request recipe
539
+ </button>
540
+ </div>
541
+ </details>
444
542
 
445
543
  <p class="final-note">
446
544
  The README includes complete AI Agent, Basic LLM Chain, JSON, and
package/src/ui/styles.css CHANGED
@@ -29,6 +29,7 @@
29
29
  --radius-sm: 0.5rem;
30
30
  --radius: 0.875rem;
31
31
  --radius-lg: 1.25rem;
32
+ --header-height: 3.75rem;
32
33
  }
33
34
 
34
35
  :root[data-theme="dark"] {
@@ -160,8 +161,8 @@ a:focus-visible {
160
161
  display: flex;
161
162
  align-items: center;
162
163
  justify-content: space-between;
163
- min-height: 4.25rem;
164
- padding: 0.75rem clamp(1rem, 4vw, 3rem);
164
+ min-height: var(--header-height);
165
+ padding: 0.5rem clamp(1rem, 4vw, 3rem);
165
166
  border-bottom: 1px solid rgb(221 227 223 / 75%);
166
167
  background: color-mix(in srgb, var(--background) 82%, transparent);
167
168
  backdrop-filter: blur(14px) saturate(1.4);
@@ -179,8 +180,8 @@ a:focus-visible {
179
180
 
180
181
  .brand-mark {
181
182
  display: block;
182
- width: 2rem;
183
- height: 2rem;
183
+ width: 1.75rem;
184
+ height: 1.75rem;
184
185
  flex: none;
185
186
  border-radius: 0.4375rem;
186
187
  transition:
@@ -302,15 +303,23 @@ a:focus-visible {
302
303
  /* ---------- Shell & intro ---------- */
303
304
 
304
305
  .shell {
305
- width: min(100% - 2rem, 54rem);
306
+ width: min(100% - 2rem, 62rem);
306
307
  margin: 0 auto;
307
- padding: 3.5rem 0 5rem;
308
+ padding: 1.75rem 0 3.5rem;
308
309
  }
309
310
 
310
311
  .intro {
311
312
  max-width: 44rem;
312
313
  }
313
314
 
315
+ body:not([data-current-step="1"]) .intro {
316
+ display: none;
317
+ }
318
+
319
+ body:not([data-current-step="1"]) .shell {
320
+ padding-top: 1rem;
321
+ }
322
+
314
323
  .eyebrow,
315
324
  .step-kicker {
316
325
  margin: 0 0 0.5rem;
@@ -330,8 +339,8 @@ p {
330
339
 
331
340
  h1 {
332
341
  max-width: 42rem;
333
- margin-bottom: 1rem;
334
- font-size: clamp(2.1rem, 6.5vw, 3.4rem);
342
+ margin-bottom: 0.75rem;
343
+ font-size: clamp(1.9rem, 5vw, 3rem);
335
344
  line-height: 1.04;
336
345
  letter-spacing: -0.045em;
337
346
  }
@@ -355,19 +364,37 @@ h3 {
355
364
 
356
365
  .lede {
357
366
  max-width: 42rem;
358
- font-size: 1.0625rem;
367
+ margin-bottom: 0;
368
+ font-size: 1rem;
369
+ }
370
+
371
+ /* ---------- Toasts ---------- */
372
+
373
+ .toast-stack {
374
+ position: fixed;
375
+ top: calc(var(--header-height) + 0.5rem);
376
+ right: clamp(0.75rem, 3vw, 2rem);
377
+ z-index: 15;
378
+ display: grid;
379
+ gap: 0.5rem;
380
+ width: min(25rem, calc(100vw - 1.5rem));
381
+ max-height: calc(100svh - var(--header-height) - 1rem);
382
+ overflow-y: auto;
383
+ pointer-events: none;
359
384
  }
360
385
 
361
- /* ---------- Safety notes ---------- */
386
+ .toast {
387
+ pointer-events: auto;
388
+ }
362
389
 
363
390
  .safety-note {
364
391
  position: relative;
365
392
  display: grid;
366
- grid-template-columns: 2.25rem 1fr;
393
+ grid-template-columns: 2rem minmax(0, 1fr) auto;
367
394
  gap: 0.25rem 0.875rem;
368
395
  align-items: start;
369
- margin: 1.25rem 0 0;
370
- padding: 1rem 1.125rem;
396
+ margin: 0;
397
+ padding: 0.875rem 0.875rem 0.875rem 1rem;
371
398
  border: 1px solid var(--accent-border);
372
399
  border-radius: var(--radius);
373
400
  background: var(--accent-soft);
@@ -376,8 +403,8 @@ h3 {
376
403
 
377
404
  .safety-note::before {
378
405
  display: grid;
379
- width: 2.25rem;
380
- height: 2.25rem;
406
+ width: 2rem;
407
+ height: 2rem;
381
408
  grid-row: span 2;
382
409
  place-items: center;
383
410
  border-radius: 0.625rem;
@@ -394,6 +421,7 @@ h3 {
394
421
 
395
422
  .safety-note span {
396
423
  color: var(--accent-copy);
424
+ font-size: 0.875rem;
397
425
  }
398
426
 
399
427
  .backup-note {
@@ -411,10 +439,41 @@ h3 {
411
439
  color: var(--warning);
412
440
  }
413
441
 
442
+ .toast-close {
443
+ display: inline-grid;
444
+ width: 1.75rem;
445
+ min-height: 1.75rem;
446
+ padding: 0;
447
+ place-items: center;
448
+ border: 0;
449
+ border-radius: 50%;
450
+ color: var(--text-muted);
451
+ background: transparent;
452
+ box-shadow: none;
453
+ cursor: pointer;
454
+ font-size: 1.25rem;
455
+ line-height: 1;
456
+ }
457
+
458
+ .toast-close:hover {
459
+ color: var(--text);
460
+ background: color-mix(in srgb, var(--text) 9%, transparent);
461
+ }
462
+
463
+ .toast-close:focus-visible {
464
+ outline: 3px solid #f2a94a;
465
+ outline-offset: 1px;
466
+ }
467
+
468
+ .safety-note .toast-close {
469
+ grid-column: 3;
470
+ grid-row: 1;
471
+ }
472
+
414
473
  /* ---------- Stepper ---------- */
415
474
 
416
475
  .steps {
417
- margin: 2.5rem 0 1.25rem;
476
+ margin: 1.5rem 0 0.75rem;
418
477
  }
419
478
 
420
479
  .steps ol {
@@ -509,12 +568,13 @@ h3 {
509
568
 
510
569
  .message,
511
570
  .error {
512
- display: flex;
513
- align-items: center;
571
+ display: grid;
572
+ grid-template-columns: auto minmax(0, 1fr) auto;
573
+ align-items: start;
514
574
  gap: 0.625rem;
515
- min-height: 2.875rem;
516
- margin: 1rem 0;
517
- padding: 0.75rem 1rem;
575
+ min-height: 3rem;
576
+ margin: 0;
577
+ padding: 0.75rem;
518
578
  border-radius: var(--radius-sm);
519
579
  font-size: 0.9375rem;
520
580
  }
@@ -527,6 +587,7 @@ h3 {
527
587
  }
528
588
 
529
589
  .message::before {
590
+ align-self: center;
530
591
  flex: 0 0 auto;
531
592
  width: 0.4375rem;
532
593
  height: 0.4375rem;
@@ -536,6 +597,7 @@ h3 {
536
597
  }
537
598
 
538
599
  .error {
600
+ grid-template-columns: minmax(0, 1fr) auto;
539
601
  border: 1px solid var(--danger-border);
540
602
  color: var(--danger);
541
603
  background: var(--danger-soft);
@@ -546,7 +608,7 @@ h3 {
546
608
  /* ---------- Panels ---------- */
547
609
 
548
610
  .panel {
549
- padding: clamp(1.5rem, 4vw, 2.25rem);
611
+ padding: clamp(1.25rem, 3vw, 1.75rem);
550
612
  border: 1px solid var(--border);
551
613
  border-radius: var(--radius-lg);
552
614
  background: var(--surface);
@@ -555,7 +617,7 @@ h3 {
555
617
  }
556
618
 
557
619
  .panel-heading {
558
- margin-bottom: 1.5rem;
620
+ margin-bottom: 1.125rem;
559
621
  }
560
622
 
561
623
  .panel-heading p {
@@ -955,7 +1017,7 @@ code {
955
1017
 
956
1018
  .model-box,
957
1019
  .final-note {
958
- margin-top: 1.5rem;
1020
+ margin-top: 1rem;
959
1021
  padding: 1.125rem;
960
1022
  border: 1px solid var(--border);
961
1023
  border-radius: var(--radius);
@@ -971,6 +1033,71 @@ code {
971
1033
  color: var(--text-muted);
972
1034
  }
973
1035
 
1036
+ .recipe-disclosure {
1037
+ margin-top: 1rem;
1038
+ border: 1px solid var(--border);
1039
+ border-radius: var(--radius);
1040
+ background: var(--surface-muted);
1041
+ }
1042
+
1043
+ .recipe-disclosure summary {
1044
+ display: flex;
1045
+ align-items: center;
1046
+ justify-content: space-between;
1047
+ gap: 0.75rem;
1048
+ padding: 0.875rem 1rem;
1049
+ cursor: pointer;
1050
+ font-weight: 750;
1051
+ list-style: none;
1052
+ }
1053
+
1054
+ .recipe-disclosure summary::-webkit-details-marker {
1055
+ display: none;
1056
+ }
1057
+
1058
+ .recipe-disclosure summary::after {
1059
+ flex: none;
1060
+ color: var(--accent);
1061
+ content: "+";
1062
+ font-size: 1.25rem;
1063
+ line-height: 1;
1064
+ }
1065
+
1066
+ .recipe-disclosure[open] summary::after {
1067
+ content: "−";
1068
+ }
1069
+
1070
+ .recipe-disclosure summary:focus-visible {
1071
+ outline: 3px solid #f2a94a;
1072
+ outline-offset: -3px;
1073
+ }
1074
+
1075
+ .recipe-content {
1076
+ margin: 0;
1077
+ border: 0;
1078
+ border-top: 1px solid var(--border);
1079
+ border-radius: 0;
1080
+ }
1081
+
1082
+ .sample-body-row {
1083
+ align-items: start !important;
1084
+ }
1085
+
1086
+ .sample-json {
1087
+ max-width: 100%;
1088
+ margin: 0;
1089
+ overflow-x: auto;
1090
+ white-space: pre-wrap;
1091
+ }
1092
+
1093
+ .sample-json code {
1094
+ display: block;
1095
+ padding: 0.75rem;
1096
+ border: 1px solid var(--border);
1097
+ background: var(--surface);
1098
+ line-height: 1.45;
1099
+ }
1100
+
974
1101
  footer {
975
1102
  padding: 1.5rem 1rem;
976
1103
  border-top: 1px solid var(--border);
@@ -1072,8 +1199,15 @@ footer p {
1072
1199
  }
1073
1200
 
1074
1201
  .shell {
1075
- width: min(100% - 1rem, 54rem);
1076
- padding-top: 2rem;
1202
+ width: min(100% - 1rem, 62rem);
1203
+ padding: 1.25rem 0 2.5rem;
1204
+ }
1205
+
1206
+ .toast-stack {
1207
+ top: calc(var(--header-height) + 0.375rem);
1208
+ right: 0.5rem;
1209
+ width: calc(100vw - 1rem);
1210
+ gap: 0.375rem;
1077
1211
  }
1078
1212
 
1079
1213
  .safety-note,
@@ -1086,6 +1220,19 @@ footer p {
1086
1220
  grid-row: auto;
1087
1221
  }
1088
1222
 
1223
+ .toast-stack .safety-note {
1224
+ grid-template-columns: 2rem minmax(0, 1fr) auto;
1225
+ align-items: center;
1226
+ }
1227
+
1228
+ .toast-stack .safety-note::before {
1229
+ grid-row: auto;
1230
+ }
1231
+
1232
+ .toast-stack .safety-note > span {
1233
+ display: none;
1234
+ }
1235
+
1089
1236
  .steps li em {
1090
1237
  font-size: 0.6875rem;
1091
1238
  }
@@ -1108,6 +1255,10 @@ footer p {
1108
1255
  grid-template-columns: 1fr;
1109
1256
  gap: 0.25rem;
1110
1257
  }
1258
+
1259
+ .sample-body-row dd.result-value {
1260
+ align-items: stretch;
1261
+ }
1111
1262
  }
1112
1263
 
1113
1264
  @media (prefers-reduced-motion: reduce) {