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
@@ -0,0 +1,69 @@
1
+ import { randomBytes } from "node:crypto";
2
+
3
+ import { startWizardServer } from "../src/web/server.js";
4
+
5
+ const remote = {
6
+ close() {},
7
+ };
8
+ const previewCredentialUpdatedAt = new Date().toISOString();
9
+
10
+ const services = {
11
+ async getAuthStatus() {
12
+ return {
13
+ exists: true,
14
+ path: "/preview/auth.json",
15
+ updatedAt: previewCredentialUpdatedAt,
16
+ };
17
+ },
18
+ async readAuthContents() {
19
+ return Buffer.from('{"preview":true}');
20
+ },
21
+ async scanHostFingerprint() {
22
+ return "SHA256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
23
+ },
24
+ async connectVerified() {
25
+ return remote;
26
+ },
27
+ async discoverN8n() {
28
+ return {
29
+ dockerVersion: "28.3.2",
30
+ composeVersion: "2.38.2",
31
+ containers: [
32
+ {
33
+ id: "preview",
34
+ image: "docker.n8n.io/n8nio/n8n",
35
+ name: "n8n-n8n-1",
36
+ state: "running",
37
+ },
38
+ ],
39
+ };
40
+ },
41
+ async discoverNetworks() {
42
+ return { networks: ["proxy"], recommended: "proxy" };
43
+ },
44
+ async installSidecar() {
45
+ return {
46
+ baseUrl: "http://n8n-openai-oauth:10531/v1",
47
+ apiKeyPlaceholder: "local-only",
48
+ useResponsesApi: true,
49
+ models: ["gpt-5.6-sol", "gpt-5.6-terra"],
50
+ deploymentMode: "created",
51
+ };
52
+ },
53
+ };
54
+
55
+ const sessionToken = randomBytes(32).toString("base64url");
56
+ const wizard = await startWizardServer({
57
+ sessionToken,
58
+ services,
59
+ previewMode: true,
60
+ });
61
+
62
+ console.log(`${wizard.origin}/?session=${sessionToken}`);
63
+ console.log(
64
+ "Sanitized preview data only; live ChatGPT sign-in is disabled. Press Control+C to stop.",
65
+ );
66
+
67
+ process.once("SIGINT", async () => {
68
+ await wizard.close();
69
+ });
package/src/cli.js ADDED
@@ -0,0 +1,57 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { randomBytes } from "node:crypto";
4
+ import { spawn } from "node:child_process";
5
+
6
+ import { startWizardServer } from "./web/server.js";
7
+
8
+ function openBrowser(url) {
9
+ const command =
10
+ process.platform === "darwin"
11
+ ? { file: "open", args: [url] }
12
+ : process.platform === "win32"
13
+ ? { file: "explorer.exe", args: [url] }
14
+ : { file: "xdg-open", args: [url] };
15
+
16
+ const child = spawn(command.file, command.args, {
17
+ detached: true,
18
+ stdio: "ignore",
19
+ shell: false,
20
+ });
21
+ child.once("error", () => {
22
+ // The URL is also printed, so users can open it manually.
23
+ });
24
+ child.unref();
25
+ }
26
+
27
+ const sessionToken = randomBytes(32).toString("base64url");
28
+ const wizard = await startWizardServer({ sessionToken });
29
+ const url = `${wizard.origin}/?session=${sessionToken}`;
30
+
31
+ console.log("");
32
+ console.log("Relmio");
33
+ console.log("---------");
34
+ console.log(`Local wizard: ${url}`);
35
+ console.log("");
36
+ console.log("This creates a separate sidecar and never restarts n8n.");
37
+ console.log("Keep this Terminal window open while using the wizard.");
38
+ console.log("Press Control+C to stop.");
39
+ console.log("");
40
+
41
+ openBrowser(url);
42
+
43
+ let closing = false;
44
+ async function close() {
45
+ if (closing) {
46
+ return;
47
+ }
48
+ closing = true;
49
+ await wizard.close();
50
+ }
51
+
52
+ process.once("SIGINT", async () => {
53
+ await close();
54
+ });
55
+ process.once("SIGTERM", async () => {
56
+ await close();
57
+ });
@@ -0,0 +1,59 @@
1
+ export const INSTALL_ROOT = "/docker/n8n-openai-oauth";
2
+ export const PROJECT_NAME = "n8n-openai-oauth";
3
+ export const SERVICE_NAME = "openai-oauth";
4
+ export const MANAGED_MARKER_PATH = `${INSTALL_ROOT}/.managed-by-n8n-openai-oauth`;
5
+
6
+ const COMPOSE_PREFIX =
7
+ "docker compose --project-name n8n-openai-oauth --file /docker/n8n-openai-oauth/docker-compose.yml";
8
+
9
+ export const PRECHECK_COMMAND = `if [ -e ${INSTALL_ROOT} ]; then if [ -f ${MANAGED_MARKER_PATH} ]; then printf '%s\\n' managed; else exit 42; fi; else printf '%s\\n' new; fi`;
10
+
11
+ const DEPLOYMENT_COMMANDS = Object.freeze([
12
+ `install -d -m 0755 ${INSTALL_ROOT}`,
13
+ `install -d -m 0700 -o 1000 -g 1000 ${INSTALL_ROOT}/auth`,
14
+ `chown 1000:1000 ${INSTALL_ROOT}/auth/auth.json`,
15
+ `chmod 600 ${INSTALL_ROOT}/auth/auth.json`,
16
+ `${COMPOSE_PREFIX} config --quiet`,
17
+ `${COMPOSE_PREFIX} build ${SERVICE_NAME}`,
18
+ `${COMPOSE_PREFIX} up -d --wait --wait-timeout 60 --no-deps ${SERVICE_NAME}`,
19
+ ]);
20
+
21
+ const VERIFICATION_COMMANDS = Object.freeze({
22
+ runningService: `${COMPOSE_PREFIX} ps --status running --services`,
23
+ publicationState: `${COMPOSE_PREFIX} ps --format json ${SERVICE_NAME}`,
24
+ models: `${COMPOSE_PREFIX} exec -T ${SERVICE_NAME} node -e 'fetch("http://127.0.0.1:10531/v1/models").then(async (response) => { console.log(await response.text()); process.exit(response.ok ? 0 : 1); }).catch(() => process.exit(1))'`,
25
+ cleanup: `${COMPOSE_PREFIX} rm --force --stop ${SERVICE_NAME}`,
26
+ });
27
+
28
+ const ALLOWED_SIDECAR_COMMANDS = new Set([
29
+ PRECHECK_COMMAND,
30
+ ...DEPLOYMENT_COMMANDS,
31
+ ...Object.values(VERIFICATION_COMMANDS),
32
+ ]);
33
+
34
+ export function createDeploymentCommands() {
35
+ return [...DEPLOYMENT_COMMANDS];
36
+ }
37
+
38
+ export function createVerificationCommands() {
39
+ return { ...VERIFICATION_COMMANDS };
40
+ }
41
+
42
+ export function assertSidecarOnlyCommands(commands) {
43
+ if (!Array.isArray(commands) || commands.length === 0) {
44
+ throw new TypeError("A sidecar command list is required.");
45
+ }
46
+
47
+ for (const command of commands) {
48
+ if (
49
+ typeof command !== "string" ||
50
+ !ALLOWED_SIDECAR_COMMANDS.has(command)
51
+ ) {
52
+ throw new Error(
53
+ "Command rejected: only the installer-managed sidecar may be changed; n8n must remain untouched.",
54
+ );
55
+ }
56
+ }
57
+
58
+ return commands;
59
+ }
@@ -0,0 +1,65 @@
1
+ import { validateDockerName } from "./validation.js";
2
+
3
+ export const SIDECAR_HOSTNAME = "n8n-openai-oauth";
4
+
5
+ export function createDockerfile() {
6
+ return `FROM node:22-bookworm-slim
7
+
8
+ RUN npm install --global --ignore-scripts openai-oauth@2.0.0 \\
9
+ && npm cache clean --force
10
+
11
+ USER node
12
+
13
+ ENTRYPOINT ["openai-oauth"]
14
+ CMD ["--host", "0.0.0.0", "--port", "10531", "--oauth-file", "/home/node/.codex/auth.json"]
15
+ `;
16
+ }
17
+
18
+ export function createComposeFile({ networkName }) {
19
+ const safeNetworkName = validateDockerName(networkName);
20
+
21
+ return `services:
22
+ openai-oauth:
23
+ build:
24
+ context: .
25
+ dockerfile: Dockerfile
26
+ restart: unless-stopped
27
+ init: true
28
+ volumes:
29
+ - ./auth:/home/node/.codex
30
+ expose:
31
+ - "10531"
32
+ networks:
33
+ n8n-shared:
34
+ aliases:
35
+ - ${SIDECAR_HOSTNAME}
36
+ security_opt:
37
+ - no-new-privileges:true
38
+ cap_drop:
39
+ - ALL
40
+ read_only: true
41
+ tmpfs:
42
+ - /tmp:size=16m,mode=1777
43
+ - /home/node/.local:uid=1000,gid=1000,mode=0700
44
+ pids_limit: 128
45
+ mem_limit: 512m
46
+ cpus: 1.0
47
+ healthcheck:
48
+ test:
49
+ - CMD
50
+ - node
51
+ - -e
52
+ - 'fetch("http://127.0.0.1:10531/health").then((response) => process.exit(response.ok ? 0 : 1)).catch(() => process.exit(1))'
53
+ interval: 30s
54
+ timeout: 5s
55
+ retries: 3
56
+ start_period: 20s
57
+ labels:
58
+ io.n8n-openai-oauth.managed: "true"
59
+
60
+ networks:
61
+ n8n-shared:
62
+ external: true
63
+ name: ${safeNetworkName}
64
+ `;
65
+ }
@@ -0,0 +1,76 @@
1
+ import { isIP } from "node:net";
2
+
3
+ function requireString(value, label, maxLength) {
4
+ if (typeof value !== "string") {
5
+ throw new TypeError(`${label} is invalid.`);
6
+ }
7
+
8
+ const normalized = value.trim();
9
+ if (
10
+ normalized.length === 0 ||
11
+ normalized.length > maxLength ||
12
+ normalized.startsWith("--")
13
+ ) {
14
+ throw new TypeError(`${label} is invalid.`);
15
+ }
16
+
17
+ return normalized;
18
+ }
19
+
20
+ export function validateHostname(value) {
21
+ const hostname = requireString(value, "Hostname", 253);
22
+ if (isIP(hostname)) {
23
+ return hostname;
24
+ }
25
+ if (/^[0-9.]+$/.test(hostname)) {
26
+ throw new TypeError("Hostname is invalid.");
27
+ }
28
+
29
+ const labels = hostname.split(".");
30
+ const valid = labels.every(
31
+ (label) =>
32
+ label.length <= 63 &&
33
+ /^[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?$/.test(label),
34
+ );
35
+
36
+ if (!valid) {
37
+ throw new TypeError("Hostname is invalid.");
38
+ }
39
+
40
+ return hostname.toLowerCase();
41
+ }
42
+
43
+ export function validatePort(value) {
44
+ let port = value;
45
+ if (typeof value !== "number") {
46
+ const text = requireString(value, "Port", 5);
47
+ if (!/^\d{1,5}$/.test(text)) {
48
+ throw new TypeError("Port is invalid.");
49
+ }
50
+ port = Number(text);
51
+ }
52
+
53
+ if (!Number.isInteger(port) || port < 1 || port > 65_535) {
54
+ throw new TypeError("Port is invalid.");
55
+ }
56
+
57
+ return port;
58
+ }
59
+
60
+ export function validateUsername(value) {
61
+ const username = requireString(value, "Username", 32);
62
+ if (!/^[a-z_][a-z0-9_-]{0,31}$/i.test(username)) {
63
+ throw new TypeError("Username is invalid.");
64
+ }
65
+
66
+ return username;
67
+ }
68
+
69
+ export function validateDockerName(value) {
70
+ const name = requireString(value, "Docker name", 128);
71
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}$/.test(name)) {
72
+ throw new TypeError("Docker name is invalid.");
73
+ }
74
+
75
+ return name;
76
+ }
@@ -0,0 +1,268 @@
1
+ import { timingSafeEqual } from "node:crypto";
2
+ import { posix as path } from "node:path";
3
+ import ssh2 from "ssh2";
4
+
5
+ import { INSTALL_ROOT } from "../domain/safety.js";
6
+ import {
7
+ validateHostname,
8
+ validatePort,
9
+ validateUsername,
10
+ } from "../domain/validation.js";
11
+
12
+ const { Client } = ssh2;
13
+ const MAX_COMMAND_OUTPUT_BYTES = 1_000_000;
14
+ const FINGERPRINT_PATTERN = /^SHA256:[A-Za-z0-9+/]{43}$/u;
15
+
16
+ function fingerprintsMatch(left, right) {
17
+ const leftBuffer = Buffer.from(left);
18
+ const rightBuffer = Buffer.from(right);
19
+ return (
20
+ leftBuffer.length === rightBuffer.length &&
21
+ timingSafeEqual(leftBuffer, rightBuffer)
22
+ );
23
+ }
24
+
25
+ function validateExpectedFingerprint(value) {
26
+ if (typeof value !== "string" || !FINGERPRINT_PATTERN.test(value)) {
27
+ throw new TypeError("SSH host fingerprint is invalid.");
28
+ }
29
+ return value;
30
+ }
31
+
32
+ function validatePassword(value) {
33
+ if (typeof value !== "string" || value.length === 0 || value.length > 1024) {
34
+ throw new TypeError("An SSH password or agent is required.");
35
+ }
36
+ return value;
37
+ }
38
+
39
+ function validateManagedPath(value) {
40
+ if (typeof value !== "string") {
41
+ throw new TypeError("Remote path is invalid.");
42
+ }
43
+
44
+ const normalized = path.normalize(value);
45
+ if (
46
+ normalized !== value ||
47
+ !normalized.startsWith(`${INSTALL_ROOT}/`) ||
48
+ normalized.includes("\0")
49
+ ) {
50
+ throw new TypeError("Remote path must stay inside the sidecar directory.");
51
+ }
52
+ return normalized;
53
+ }
54
+
55
+ export function formatSha256Fingerprint(hexDigest) {
56
+ if (
57
+ typeof hexDigest !== "string" ||
58
+ !/^[a-f0-9]{64}$/iu.test(hexDigest)
59
+ ) {
60
+ throw new TypeError("SSH fingerprint digest is invalid.");
61
+ }
62
+
63
+ const base64 = Buffer.from(hexDigest, "hex")
64
+ .toString("base64")
65
+ .replace(/=+$/u, "");
66
+ return `SHA256:${base64}`;
67
+ }
68
+
69
+ export function buildVerifiedConnectionConfig({
70
+ host,
71
+ port,
72
+ username,
73
+ password,
74
+ agent,
75
+ expectedFingerprint,
76
+ }) {
77
+ const fingerprint = validateExpectedFingerprint(expectedFingerprint);
78
+ const auth =
79
+ typeof password === "string" && password.length > 0
80
+ ? { password: validatePassword(password) }
81
+ : typeof agent === "string" && agent.length > 0
82
+ ? { agent }
83
+ : null;
84
+
85
+ if (!auth) {
86
+ throw new TypeError("An SSH password or agent is required.");
87
+ }
88
+
89
+ return {
90
+ host: validateHostname(host),
91
+ port: validatePort(port),
92
+ username: validateUsername(username),
93
+ ...auth,
94
+ hostHash: "sha256",
95
+ hostVerifier(hexDigest) {
96
+ const actual = formatSha256Fingerprint(hexDigest);
97
+ return fingerprintsMatch(actual, fingerprint);
98
+ },
99
+ readyTimeout: 15_000,
100
+ keepaliveInterval: 10_000,
101
+ keepaliveCountMax: 3,
102
+ tryKeyboard: false,
103
+ };
104
+ }
105
+
106
+ class SshConnection {
107
+ constructor(client) {
108
+ this.client = client;
109
+ this.lastError = null;
110
+ client.on("error", (error) => {
111
+ this.lastError = error;
112
+ });
113
+ }
114
+
115
+ exec(command) {
116
+ if (typeof command !== "string" || command.length === 0) {
117
+ return Promise.reject(new TypeError("Remote command is invalid."));
118
+ }
119
+
120
+ return new Promise((resolve, reject) => {
121
+ this.client.exec(command, (error, stream) => {
122
+ if (error) {
123
+ reject(new Error("The VPS refused to start a remote command."));
124
+ return;
125
+ }
126
+
127
+ const stdout = [];
128
+ const stderr = [];
129
+ let byteCount = 0;
130
+ let settled = false;
131
+
132
+ const append = (target, chunk) => {
133
+ byteCount += chunk.length;
134
+ if (byteCount > MAX_COMMAND_OUTPUT_BYTES) {
135
+ settled = true;
136
+ stream.destroy();
137
+ reject(new Error("Remote command output exceeded the safety limit."));
138
+ return;
139
+ }
140
+ target.push(Buffer.from(chunk));
141
+ };
142
+
143
+ stream.on("data", (chunk) => {
144
+ if (!settled) {
145
+ append(stdout, chunk);
146
+ }
147
+ });
148
+ stream.stderr.on("data", (chunk) => {
149
+ if (!settled) {
150
+ append(stderr, chunk);
151
+ }
152
+ });
153
+ stream.once("error", () => {
154
+ if (!settled) {
155
+ settled = true;
156
+ reject(new Error("The SSH command stream failed."));
157
+ }
158
+ });
159
+ stream.once("close", (code) => {
160
+ if (!settled) {
161
+ settled = true;
162
+ resolve({
163
+ stdout: Buffer.concat(stdout).toString("utf8"),
164
+ stderr: Buffer.concat(stderr).toString("utf8"),
165
+ code: Number.isInteger(code) ? code : 1,
166
+ });
167
+ }
168
+ });
169
+ });
170
+ });
171
+ }
172
+
173
+ upload(remotePath, contents, mode = 0o600) {
174
+ const safePath = validateManagedPath(remotePath);
175
+ const data = Buffer.isBuffer(contents) ? contents : Buffer.from(contents);
176
+
177
+ return new Promise((resolve, reject) => {
178
+ this.client.sftp((sftpError, sftp) => {
179
+ if (sftpError) {
180
+ reject(new Error("The VPS did not allow an SFTP upload."));
181
+ return;
182
+ }
183
+
184
+ sftp.writeFile(safePath, data, { mode, flag: "w" }, (writeError) => {
185
+ sftp.end();
186
+ if (writeError) {
187
+ reject(new Error("The installer could not upload a sidecar file."));
188
+ } else {
189
+ resolve();
190
+ }
191
+ });
192
+ });
193
+ });
194
+ }
195
+
196
+ close() {
197
+ this.client.end();
198
+ }
199
+ }
200
+
201
+ export async function connectVerified(
202
+ options,
203
+ { createClient = () => new Client() } = {},
204
+ ) {
205
+ const config = buildVerifiedConnectionConfig(options);
206
+
207
+ return await new Promise((resolve, reject) => {
208
+ const client = createClient();
209
+ let settled = false;
210
+
211
+ client.once("ready", () => {
212
+ settled = true;
213
+ resolve(new SshConnection(client));
214
+ });
215
+ client.once("error", () => {
216
+ if (!settled) {
217
+ settled = true;
218
+ reject(
219
+ new Error(
220
+ "SSH connection failed. Check the address, password, firewall, and confirmed fingerprint.",
221
+ ),
222
+ );
223
+ }
224
+ });
225
+ client.connect(config);
226
+ });
227
+ }
228
+
229
+ export function scanHostFingerprint(
230
+ { host, port },
231
+ { createClient = () => new Client() } = {},
232
+ ) {
233
+ const safeHost = validateHostname(host);
234
+ const safePort = validatePort(port);
235
+
236
+ return new Promise((resolve, reject) => {
237
+ const client = createClient();
238
+ let settled = false;
239
+
240
+ client.once("error", () => {
241
+ if (!settled) {
242
+ settled = true;
243
+ reject(
244
+ new Error(
245
+ "The VPS did not answer on the SSH port. Check its IP address and firewall.",
246
+ ),
247
+ );
248
+ }
249
+ });
250
+
251
+ client.connect({
252
+ host: safeHost,
253
+ port: safePort,
254
+ username: "fingerprint-scan",
255
+ hostHash: "sha256",
256
+ hostVerifier(hexDigest) {
257
+ if (!settled) {
258
+ settled = true;
259
+ resolve(formatSha256Fingerprint(hexDigest));
260
+ queueMicrotask(() => client.end());
261
+ }
262
+ return false;
263
+ },
264
+ readyTimeout: 10_000,
265
+ tryKeyboard: false,
266
+ });
267
+ });
268
+ }
@@ -0,0 +1,96 @@
1
+ import { validateDockerName } from "../domain/validation.js";
2
+
3
+ const DOCKER_VERSION_COMMAND =
4
+ "docker version --format '{{.Server.Version}}'";
5
+ const COMPOSE_VERSION_COMMAND = "docker compose version --short";
6
+ const RUNNING_CONTAINERS_COMMAND =
7
+ "docker ps --filter status=running --format '{{json .}}'";
8
+
9
+ function isOfficialN8nImage(image) {
10
+ return /(?:^|\/)n8nio\/n8n(?:[:@]|$)/.test(image);
11
+ }
12
+
13
+ async function runReadOnly(remote, command, label) {
14
+ const result = await remote.exec(command);
15
+ if (result.code !== 0) {
16
+ throw new Error(`${label} failed. Check Docker access on the VPS.`);
17
+ }
18
+ return result.stdout.trim();
19
+ }
20
+
21
+ export function parseDockerPsOutput(output) {
22
+ if (typeof output !== "string" || output.trim() === "") {
23
+ return [];
24
+ }
25
+
26
+ try {
27
+ return output
28
+ .trim()
29
+ .split("\n")
30
+ .map((line) => JSON.parse(line))
31
+ .filter(
32
+ (container) =>
33
+ container.State === "running" &&
34
+ typeof container.Image === "string" &&
35
+ isOfficialN8nImage(container.Image),
36
+ )
37
+ .map((container) => ({
38
+ id: String(container.ID),
39
+ image: container.Image,
40
+ name: validateDockerName(container.Names),
41
+ state: container.State,
42
+ }));
43
+ } catch {
44
+ throw new Error(
45
+ "The installer could not understand Docker's container list.",
46
+ );
47
+ }
48
+ }
49
+
50
+ export async function discoverN8n(remote) {
51
+ const dockerVersion = await runReadOnly(
52
+ remote,
53
+ DOCKER_VERSION_COMMAND,
54
+ "Docker check",
55
+ );
56
+ const composeVersion = await runReadOnly(
57
+ remote,
58
+ COMPOSE_VERSION_COMMAND,
59
+ "Docker Compose check",
60
+ );
61
+ const containerOutput = await runReadOnly(
62
+ remote,
63
+ RUNNING_CONTAINERS_COMMAND,
64
+ "n8n discovery",
65
+ );
66
+
67
+ return {
68
+ dockerVersion,
69
+ composeVersion,
70
+ containers: parseDockerPsOutput(containerOutput),
71
+ };
72
+ }
73
+
74
+ export function createInspectNetworksCommand(containerName) {
75
+ const safeName = validateDockerName(containerName);
76
+ return `docker inspect ${safeName} --format '{{range $name, $_ := .NetworkSettings.Networks}}{{println $name}}{{end}}'`;
77
+ }
78
+
79
+ export async function discoverNetworks(remote, containerName) {
80
+ const command = createInspectNetworksCommand(containerName);
81
+ const output = await runReadOnly(remote, command, "Docker network discovery");
82
+ const networks = [
83
+ ...new Set(
84
+ output
85
+ .split("\n")
86
+ .map((name) => name.trim())
87
+ .filter(Boolean)
88
+ .map(validateDockerName),
89
+ ),
90
+ ];
91
+
92
+ return {
93
+ networks,
94
+ recommended: networks.includes("proxy") ? "proxy" : (networks[0] ?? null),
95
+ };
96
+ }