@fiodos/bridge 0.1.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/Dockerfile ADDED
@@ -0,0 +1,20 @@
1
+ # Fiodos bridge as a container: for a dashboard that lives on a server nobody
2
+ # logs into. Pair once (interactive), then run forever with the same volume.
3
+ #
4
+ # docker build -t fiodos/bridge packages/bridge
5
+ # docker run --rm -it --network host -v fiodos-bridge:/data fiodos/bridge \
6
+ # pair ABCD-EFGH --target http://localhost:3947 --no-run
7
+ # docker run -d --restart unless-stopped --network host -v fiodos-bridge:/data fiodos/bridge run
8
+ #
9
+ # `--network host` is what lets it reach `localhost:<port>` of the machine;
10
+ # without it, point --target at the service's name on the compose network.
11
+ FROM node:22-alpine
12
+ ENV FIODOS_BRIDGE_HOME=/data
13
+ WORKDIR /app
14
+ COPY package.json README.md LICENSE ./
15
+ COPY dist ./dist
16
+ RUN mkdir -p /data && chown node:node /data
17
+ USER node
18
+ VOLUME ["/data"]
19
+ ENTRYPOINT ["node", "dist/cli.js"]
20
+ CMD ["run"]
package/LICENSE ADDED
@@ -0,0 +1,77 @@
1
+ Fiodos SDK License Agreement
2
+
3
+ Copyright (c) 2026 Fiodos / Rodrigo Martínez. All rights reserved.
4
+
5
+ This is a proprietary, commercial software license. It is NOT an open-source
6
+ license. Please read it carefully before installing or using the Software.
7
+
8
+ 1. Definitions.
9
+ "Software" means the Fiodos SDK packages (including, without limitation, the
10
+ @fiodos/* npm packages and the fiodos_flutter package) together with any
11
+ accompanying files, documentation, and updates provided by the Licensor.
12
+ "Service" means the hosted Fiodos backend, APIs, and related services
13
+ operated by the Licensor.
14
+ "Licensor" means Fiodos / Rodrigo Martínez (and, once incorporated, the
15
+ company that succeeds to these rights).
16
+ "You" means the individual or legal entity that installs or uses the
17
+ Software.
18
+
19
+ 2. Grant of License.
20
+ Subject to Your continued compliance with this Agreement, the Licensor grants
21
+ You a limited, non-exclusive, non-transferable, non-sublicensable, and
22
+ revocable license to install and use the Software solely for the purpose of
23
+ integrating with and accessing the Fiodos Service from within Your own
24
+ applications.
25
+
26
+ 3. Restrictions.
27
+ Except as expressly permitted in Section 2, You may NOT, in whole or in part:
28
+ (a) copy the Software, except for the copying strictly necessary to install
29
+ and run it for its intended purpose;
30
+ (b) modify, adapt, translate, or create derivative works of the Software;
31
+ (c) redistribute, publish, sublicense, sell, rent, lease, lend, or otherwise
32
+ make the Software available to any third party as a standalone item;
33
+ (d) reverse engineer, decompile, or disassemble the Software, or attempt to
34
+ derive its source code, except and only to the extent that this
35
+ restriction is expressly prohibited by applicable law;
36
+ (e) remove, obscure, or alter any copyright, trademark, or other proprietary
37
+ notices contained in the Software; or
38
+ (f) use the Software, or any portion of it, to develop, train, or assist in
39
+ developing a product or service that competes with Fiodos.
40
+
41
+ 4. Reservation of Rights.
42
+ The Software is licensed, not sold. All right, title, and interest in and to
43
+ the Software, including all related intellectual property rights, are and
44
+ shall remain the exclusive property of the Licensor. All rights not expressly
45
+ granted to You under this Agreement are reserved by the Licensor.
46
+
47
+ 5. Termination.
48
+ This license terminates automatically and immediately if You breach any term
49
+ of this Agreement. Upon termination, You must cease all use of the Software
50
+ and delete all copies in Your possession or control. Sections 3, 4, 6, and 7
51
+ survive termination.
52
+
53
+ 6. Disclaimer of Warranty.
54
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
55
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
56
+ FITNESS FOR A PARTICULAR PURPOSE, AND NONINFRINGEMENT.
57
+
58
+ 7. Limitation of Liability.
59
+ TO THE MAXIMUM EXTENT PERMITTED BY APPLICABLE LAW, IN NO EVENT SHALL THE
60
+ LICENSOR BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER LIABILITY, WHETHER IN AN
61
+ ACTION OF CONTRACT, TORT, OR OTHERWISE, ARISING FROM, OUT OF, OR IN CONNECTION
62
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
63
+
64
+ 8. Contact.
65
+ For licensing inquiries, contact Rodrigo Martínez (Fiodos).
66
+
67
+ --------------------------------------------------------------------------------
68
+
69
+ Nota (ES) — Licencia propietaria:
70
+ Se permite INSTALAR y USAR el SDK únicamente para integrar y acceder al servicio
71
+ Fiodos desde tus propias aplicaciones. Quedan PROHIBIDOS: copiar el código (salvo
72
+ la copia imprescindible para instalarlo y ejecutarlo), modificarlo, redistribuirlo,
73
+ sublicenciarlo, venderlo, hacer ingeniería inversa o crear obras derivadas o un
74
+ producto que compita con Fiodos. El software se licencia, no se vende; todos los
75
+ derechos no concedidos expresamente quedan reservados al Licenciante. Este texto
76
+ corresponde a la fase de pruebas y será revisado por un abogado al constituir la
77
+ sociedad.
package/README.md ADDED
@@ -0,0 +1,64 @@
1
+ # @fiodos/bridge
2
+
3
+ Lets your Fiodos company agent reach a dashboard or API that lives **on your own
4
+ network** (`localhost:3947`, an intranet host) without opening a port, without a
5
+ third-party tunnel and without giving Fiodos your local key.
6
+
7
+ How it works: the bridge is a small process on a machine that can see that
8
+ dashboard. It opens **one outbound** connection to Fiodos and holds it. When the
9
+ agent needs to call the dashboard, Fiodos sends the request down that
10
+ connection, the bridge judges it against the allow-list you set, executes it
11
+ locally with your local credentials, and returns the answer.
12
+
13
+ ```
14
+ npx -y --loglevel=error @fiodos/bridge pair ABCD-EFGH --target http://localhost:3947
15
+ ```
16
+
17
+ That is the whole install. The code comes from the Fiodos dashboard (internal
18
+ orb → connections → "dashboard on my network") and is valid for 15 minutes,
19
+ once.
20
+
21
+ ## What stays here, what travels
22
+
23
+ | Kept on this machine (`~/.fiodos/bridge/config.json`, mode 0600) | Never leaves it |
24
+ | --- | --- |
25
+ | The pairing token (talks to Fiodos) | Your dashboard's key (`--header`) |
26
+ | The target origin and allowed path prefixes | Any code, any copy of a response |
27
+ | Header **names** you added (values are only in the file) | — |
28
+
29
+ Fiodos keeps the API document of the dashboard (read once through the bridge,
30
+ or uploaded as a file) so the agent knows what it can call — never the code.
31
+
32
+ ## Commands
33
+
34
+ ```
35
+ pair <code> --target <url> [--header "Name: value"]... [--header-file path]
36
+ [--allow </prefix>]... [--name <label>] [--api-url <url>] [--no-run]
37
+ run serve every paired bridge (what the service runs)
38
+ status what is paired, where it points; never prints a key
39
+ unpair [--bridge <id>]
40
+ service install|uninstall|show launchd (macOS) / systemd --user (Linux); Windows prints instructions
41
+ ```
42
+
43
+ - `--header "Authorization: Bearer …"` — how the bridge authenticates to **your**
44
+ dashboard. Use `--header-file` if the value must not appear in shell history.
45
+ - `--allow /v1/owner` (repeatable) — restrict the bridge to those path prefixes.
46
+ Default is everything on the target origin, nothing off it.
47
+ - The token is sent to Fiodos as the first WebSocket frame, never in the URL.
48
+
49
+ ## Run it always
50
+
51
+ ```
52
+ fiodos-bridge service install # starts with the machine, restarts if it dies
53
+ ```
54
+
55
+ Or as a container (see `Dockerfile`): pair once with the volume, then `run`.
56
+
57
+ ## Requirements
58
+
59
+ Node.js 22.5 or newer (uses the built-in WebSocket and fetch; no dependencies).
60
+
61
+ ## Revoking
62
+
63
+ From the Fiodos dashboard, "revoke bridge" ends the pairing: the running bridge
64
+ receives the notice, deletes its token and exits. A new code re-pairs.
@@ -0,0 +1,32 @@
1
+ /**
2
+ * The allow-list: what the backend may ask this bridge to touch.
3
+ *
4
+ * A bridge compromised from our side must not be a door into the customer's
5
+ * network — only to the API they chose to expose. So every request is judged
6
+ * here, on the customer's machine, before anything is sent: one origin (the
7
+ * paired target), the path prefixes they allowed, the HTTP verbs an API uses.
8
+ */
9
+ import type { BridgeTarget } from './config.js';
10
+ export declare const ALLOWED_METHODS: Set<string>;
11
+ export interface IncomingRequest {
12
+ method: string;
13
+ path: string;
14
+ query?: Record<string, string>;
15
+ headers?: Record<string, string>;
16
+ body?: string | null;
17
+ }
18
+ export type Verdict = {
19
+ ok: true;
20
+ url: string;
21
+ method: string;
22
+ } | {
23
+ ok: false;
24
+ reason: string;
25
+ };
26
+ export declare function normalizeOrigin(raw: string): string;
27
+ export declare function normalizePrefix(raw: string): string;
28
+ export declare function judge(target: BridgeTarget, request: IncomingRequest): Verdict;
29
+ /** Headers that travel with the request: what the backend asked for (content
30
+ * negotiation only), then the customer's own — theirs win. Hop-by-hop and
31
+ * identity headers from the wire are dropped. */
32
+ export declare function outboundHeaders(target: BridgeTarget, incoming: Record<string, string> | undefined): Record<string, string>;
@@ -0,0 +1,73 @@
1
+ export const ALLOWED_METHODS = new Set(['GET', 'POST', 'PATCH', 'PUT', 'DELETE']);
2
+ export function normalizeOrigin(raw) {
3
+ let text = String(raw || '').trim();
4
+ if (!text)
5
+ throw new Error('target is required (e.g. http://localhost:3947)');
6
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(text)) {
7
+ if (!/^https?:\/\//i.test(text))
8
+ throw new Error('target must be http or https');
9
+ }
10
+ else {
11
+ text = `http://${text}`;
12
+ }
13
+ const url = new URL(text);
14
+ if (url.protocol !== 'http:' && url.protocol !== 'https:')
15
+ throw new Error('target must be http or https');
16
+ return `${url.protocol}//${url.host}`;
17
+ }
18
+ export function normalizePrefix(raw) {
19
+ let p = String(raw || '').trim();
20
+ if (!p)
21
+ return '/';
22
+ if (!p.startsWith('/'))
23
+ p = `/${p}`;
24
+ return p;
25
+ }
26
+ export function judge(target, request) {
27
+ const method = String(request.method || '').toUpperCase();
28
+ if (!ALLOWED_METHODS.has(method))
29
+ return { ok: false, reason: `method ${method || '(none)'} not allowed` };
30
+ const path = String(request.path || '');
31
+ if (!path.startsWith('/'))
32
+ return { ok: false, reason: 'path must be absolute' };
33
+ // A path that climbs (`/a/../admin`) is judged by where it lands, not by
34
+ // how it was written.
35
+ let resolved;
36
+ try {
37
+ resolved = new URL(path, target.origin);
38
+ }
39
+ catch {
40
+ return { ok: false, reason: 'path is not valid' };
41
+ }
42
+ if (`${resolved.protocol}//${resolved.host}` !== target.origin) {
43
+ return { ok: false, reason: 'request left the paired target' };
44
+ }
45
+ const allowed = target.allowPaths.length ? target.allowPaths : ['/'];
46
+ const landed = resolved.pathname;
47
+ const inside = allowed.some((prefix) => {
48
+ const p = normalizePrefix(prefix);
49
+ if (p === '/')
50
+ return true;
51
+ return landed === p || landed.startsWith(p.endsWith('/') ? p : `${p}/`);
52
+ });
53
+ if (!inside)
54
+ return { ok: false, reason: `path ${landed} is outside the allowed prefixes (${allowed.join(', ')})` };
55
+ const query = request.query && typeof request.query === 'object' ? request.query : {};
56
+ for (const [k, v] of Object.entries(query))
57
+ resolved.searchParams.set(k, String(v));
58
+ return { ok: true, url: resolved.toString(), method };
59
+ }
60
+ /** Headers that travel with the request: what the backend asked for (content
61
+ * negotiation only), then the customer's own — theirs win. Hop-by-hop and
62
+ * identity headers from the wire are dropped. */
63
+ export function outboundHeaders(target, incoming) {
64
+ const out = {};
65
+ for (const [k, v] of Object.entries(incoming || {})) {
66
+ const key = k.toLowerCase();
67
+ if (key === 'accept' || key === 'content-type')
68
+ out[key] = String(v);
69
+ }
70
+ for (const [k, v] of Object.entries(target.headers || {}))
71
+ out[k] = v;
72
+ return out;
73
+ }
package/dist/cli.d.ts ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env node
2
+ interface Flags {
3
+ positional: string[];
4
+ values: Record<string, string[]>;
5
+ }
6
+ export declare function parseArgs(argv: string[]): Flags;
7
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,162 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * fiodos-bridge — the command the dashboard tells you to paste.
4
+ *
5
+ * npx -y --loglevel=error @fiodos/bridge pair ABCD-EFGH --target http://localhost:3947
6
+ * fiodos-bridge run
7
+ * fiodos-bridge status
8
+ * fiodos-bridge service install | uninstall | show
9
+ * fiodos-bridge unpair [--bridge <id>]
10
+ */
11
+ import { readFileSync } from 'node:fs';
12
+ import { parseHeaderFlag, pair } from './pair.js';
13
+ import { readConfig, redacted, removeBridge, upsertBridge, writeConfig, configPath } from './config.js';
14
+ import { runBridge } from './runner.js';
15
+ import { activityLogPath, dim, say } from './log.js';
16
+ import * as service from './service.js';
17
+ export function parseArgs(argv) {
18
+ const positional = [];
19
+ const values = {};
20
+ for (let i = 0; i < argv.length; i += 1) {
21
+ const arg = argv[i];
22
+ if (arg.startsWith('--')) {
23
+ const eq = arg.indexOf('=');
24
+ const key = eq > 0 ? arg.slice(2, eq) : arg.slice(2);
25
+ let value = eq > 0 ? arg.slice(eq + 1) : '';
26
+ if (eq < 0 && i + 1 < argv.length && !argv[i + 1].startsWith('--')) {
27
+ value = argv[i + 1];
28
+ i += 1;
29
+ }
30
+ (values[key] ||= []).push(value);
31
+ }
32
+ else {
33
+ positional.push(arg);
34
+ }
35
+ }
36
+ return { positional, values };
37
+ }
38
+ function one(flags, key) {
39
+ const list = flags.values[key];
40
+ return list && list.length ? list[list.length - 1] : undefined;
41
+ }
42
+ function usage() {
43
+ process.stderr.write(`fiodos-bridge — reach a dashboard on your own network from your Fiodos company agent
44
+
45
+ pair <code> --target <http://host:port> [--header "Name: value"]... [--allow </prefix>]... [--name <label>] [--api-url <url>]
46
+ Spend the one-time code from the dashboard, keep the pairing here, and start serving.
47
+ run Serve every paired bridge (what the service runs).
48
+ status What is paired, where it points, last activity. Never prints a key.
49
+ unpair [--bridge <id>]
50
+ service install | uninstall | show
51
+
52
+ Config: ${configPath()} Log: ${activityLogPath()}
53
+ `);
54
+ }
55
+ async function runAll(bridges) {
56
+ if (!bridges.length) {
57
+ say('nothing is paired yet. Run the pair command your dashboard shows.', 'warn');
58
+ process.exitCode = 1;
59
+ return;
60
+ }
61
+ const runs = bridges.map((bridge) => runBridge(bridge, {
62
+ onRevoked: (b) => writeConfig(removeBridge(readConfig(), b.bridgeId)),
63
+ }));
64
+ await Promise.all(runs);
65
+ say('no bridge left to serve — exiting.', 'info');
66
+ }
67
+ async function main(argv) {
68
+ const flags = parseArgs(argv);
69
+ const [command, ...rest] = flags.positional;
70
+ switch (command) {
71
+ case 'pair': {
72
+ const code = rest[0] || one(flags, 'code');
73
+ const target = one(flags, 'target');
74
+ if (!code || !target) {
75
+ say('pair needs the code and --target, e.g. pair ABCD-EFGH --target http://localhost:3947', 'error');
76
+ process.exitCode = 2;
77
+ return;
78
+ }
79
+ const headers = {};
80
+ for (const raw of flags.values.header || []) {
81
+ const [name, value] = parseHeaderFlag(raw);
82
+ headers[name] = value;
83
+ }
84
+ const headerFile = one(flags, 'header-file');
85
+ if (headerFile) {
86
+ // "Name: value" per line — for a key that must not appear in shell history.
87
+ for (const line of readFileSync(headerFile, 'utf8').split('\n')) {
88
+ if (!line.trim())
89
+ continue;
90
+ const [name, value] = parseHeaderFlag(line);
91
+ headers[name] = value;
92
+ }
93
+ }
94
+ say(`pairing with Fiodos…`);
95
+ const paired = await pair({
96
+ code,
97
+ target,
98
+ apiUrl: one(flags, 'api-url'),
99
+ name: one(flags, 'name'),
100
+ headers,
101
+ allowPaths: flags.values.allow || [],
102
+ });
103
+ writeConfig(upsertBridge(readConfig(), paired));
104
+ say(`paired — ${paired.name || paired.bridgeId} → ${paired.target.origin}`, 'ok');
105
+ dim(`kept here: ${configPath()} (your local key never leaves this machine)`);
106
+ if (!Object.keys(headers).length)
107
+ dim('no --header given: calls to the local dashboard go without credentials. Add one with --header "Authorization: Bearer …" if it needs it.');
108
+ if (one(flags, 'no-run') !== undefined)
109
+ return;
110
+ await runAll([paired]);
111
+ return;
112
+ }
113
+ case undefined:
114
+ case 'run': {
115
+ await runAll(readConfig().bridges);
116
+ return;
117
+ }
118
+ case 'status': {
119
+ const config = readConfig();
120
+ if (!config.bridges.length) {
121
+ say('nothing paired.', 'warn');
122
+ return;
123
+ }
124
+ for (const b of config.bridges)
125
+ process.stdout.write(`${JSON.stringify(redacted(b), null, 2)}\n`);
126
+ dim(`activity: ${activityLogPath()}`);
127
+ return;
128
+ }
129
+ case 'unpair': {
130
+ const config = readConfig();
131
+ const id = one(flags, 'bridge');
132
+ const victims = id ? config.bridges.filter((b) => b.bridgeId === id) : config.bridges;
133
+ let next = config;
134
+ for (const v of victims)
135
+ next = removeBridge(next, v.bridgeId);
136
+ writeConfig(next);
137
+ say(`removed ${victims.length} pairing(s) from this machine. Revoke it in the dashboard too.`, 'ok');
138
+ return;
139
+ }
140
+ case 'service': {
141
+ const sub = rest[0] || 'show';
142
+ const p = sub === 'install' ? service.install() : sub === 'uninstall' ? service.uninstall() : service.plan();
143
+ if (p.path)
144
+ say(`${sub === 'uninstall' ? 'removed' : sub === 'install' ? 'installed' : 'would write'} ${p.path}`, 'ok');
145
+ if (sub === 'show' && p.contents)
146
+ process.stdout.write(p.contents);
147
+ for (const n of p.notes)
148
+ dim(n);
149
+ return;
150
+ }
151
+ default:
152
+ usage();
153
+ process.exitCode = 2;
154
+ }
155
+ }
156
+ const invokedDirectly = process.argv[1] && /cli\.(js|ts)$/.test(process.argv[1]);
157
+ if (invokedDirectly) {
158
+ main(process.argv.slice(2)).catch((err) => {
159
+ say(err.message || String(err), 'error');
160
+ process.exitCode = 1;
161
+ });
162
+ }
@@ -0,0 +1,30 @@
1
+ export interface BridgeTarget {
2
+ /** `http://localhost:3947` — scheme, host and port only; paths come per request. */
3
+ origin: string;
4
+ /** Path prefixes the bridge may call on this origin. `['/']` means everything. */
5
+ allowPaths: string[];
6
+ /** Headers added to every call — how the local dashboard authenticates the bridge. */
7
+ headers: Record<string, string>;
8
+ }
9
+ export interface PairedBridge {
10
+ bridgeId: string;
11
+ name: string;
12
+ apiUrl: string;
13
+ wsUrl: string;
14
+ token: string;
15
+ target: BridgeTarget;
16
+ pairedAt: string;
17
+ }
18
+ export interface BridgeConfig {
19
+ version: 1;
20
+ bridges: PairedBridge[];
21
+ }
22
+ export declare function bridgeHome(): string;
23
+ export declare function configPath(): string;
24
+ export declare function emptyConfig(): BridgeConfig;
25
+ export declare function readConfig(): BridgeConfig;
26
+ export declare function writeConfig(config: BridgeConfig): void;
27
+ export declare function upsertBridge(config: BridgeConfig, bridge: PairedBridge): BridgeConfig;
28
+ export declare function removeBridge(config: BridgeConfig, bridgeId: string): BridgeConfig;
29
+ /** What `status` prints: never the token, never a header value. */
30
+ export declare function redacted(bridge: PairedBridge): Record<string, unknown>;
package/dist/config.js ADDED
@@ -0,0 +1,81 @@
1
+ /**
2
+ * What the bridge keeps on the customer's machine — and nothing else.
3
+ *
4
+ * Per paired bridge: its pairing token (to talk to Fiodos), the local address
5
+ * it may call, the path prefixes it may touch there, and the header(s) that
6
+ * local service authenticates with. No code, no copies of responses, no
7
+ * document (DESIGN_AGENTE_INTERNO D11).
8
+ *
9
+ * Lives in `~/.fiodos/bridge/config.json` (or `$FIODOS_BRIDGE_HOME`), mode
10
+ * 0600: the local key is in it.
11
+ */
12
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
13
+ import { homedir } from 'node:os';
14
+ import { join } from 'node:path';
15
+ export function bridgeHome() {
16
+ const fromEnv = (process.env.FIODOS_BRIDGE_HOME || '').trim();
17
+ return fromEnv || join(homedir(), '.fiodos', 'bridge');
18
+ }
19
+ export function configPath() {
20
+ return join(bridgeHome(), 'config.json');
21
+ }
22
+ export function emptyConfig() {
23
+ return { version: 1, bridges: [] };
24
+ }
25
+ export function readConfig() {
26
+ const path = configPath();
27
+ if (!existsSync(path))
28
+ return emptyConfig();
29
+ try {
30
+ const parsed = JSON.parse(readFileSync(path, 'utf8'));
31
+ const bridges = Array.isArray(parsed.bridges) ? parsed.bridges.filter(isPaired) : [];
32
+ return { version: 1, bridges };
33
+ }
34
+ catch {
35
+ return emptyConfig();
36
+ }
37
+ }
38
+ export function writeConfig(config) {
39
+ const dir = bridgeHome();
40
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
41
+ const path = configPath();
42
+ const tmp = `${path}.tmp`;
43
+ writeFileSync(tmp, JSON.stringify(config, null, 2) + '\n', { mode: 0o600 });
44
+ renameSync(tmp, path);
45
+ try {
46
+ chmodSync(path, 0o600);
47
+ }
48
+ catch {
49
+ // Windows has no POSIX modes; the directory is the user's own.
50
+ }
51
+ }
52
+ export function upsertBridge(config, bridge) {
53
+ const others = config.bridges.filter((b) => b.bridgeId !== bridge.bridgeId);
54
+ return { version: 1, bridges: [...others, bridge] };
55
+ }
56
+ export function removeBridge(config, bridgeId) {
57
+ return { version: 1, bridges: config.bridges.filter((b) => b.bridgeId !== bridgeId) };
58
+ }
59
+ /** What `status` prints: never the token, never a header value. */
60
+ export function redacted(bridge) {
61
+ return {
62
+ bridgeId: bridge.bridgeId,
63
+ name: bridge.name,
64
+ apiUrl: bridge.apiUrl,
65
+ target: bridge.target.origin,
66
+ allowPaths: bridge.target.allowPaths,
67
+ headers: Object.keys(bridge.target.headers),
68
+ pairedAt: bridge.pairedAt,
69
+ };
70
+ }
71
+ function isPaired(value) {
72
+ if (!value || typeof value !== 'object')
73
+ return false;
74
+ const b = value;
75
+ const target = b.target;
76
+ return (typeof b.bridgeId === 'string' &&
77
+ typeof b.token === 'string' &&
78
+ typeof b.wsUrl === 'string' &&
79
+ !!target &&
80
+ typeof target.origin === 'string');
81
+ }
@@ -0,0 +1,7 @@
1
+ export { judge, normalizeOrigin, normalizePrefix, outboundHeaders, ALLOWED_METHODS } from './allowlist.js';
2
+ export type { IncomingRequest, Verdict } from './allowlist.js';
3
+ export { readConfig, writeConfig, upsertBridge, removeBridge, redacted, configPath, bridgeHome } from './config.js';
4
+ export type { BridgeConfig, BridgeTarget, PairedBridge } from './config.js';
5
+ export { pair, parseHeaderFlag, DEFAULT_API_URL } from './pair.js';
6
+ export { runBridge, makeExecutor, SUBPROTOCOL, VERSION } from './runner.js';
7
+ export type { Executor, RunnerEvents } from './runner.js';
package/dist/index.js ADDED
@@ -0,0 +1,4 @@
1
+ export { judge, normalizeOrigin, normalizePrefix, outboundHeaders, ALLOWED_METHODS } from './allowlist.js';
2
+ export { readConfig, writeConfig, upsertBridge, removeBridge, redacted, configPath, bridgeHome } from './config.js';
3
+ export { pair, parseHeaderFlag, DEFAULT_API_URL } from './pair.js';
4
+ export { runBridge, makeExecutor, SUBPROTOCOL, VERSION } from './runner.js';
package/dist/log.d.ts ADDED
@@ -0,0 +1,5 @@
1
+ export declare function activityLogPath(): string;
2
+ export declare function logActivity(line: string): void;
3
+ /** The same face as the rest of the Fiodos installers: orb, name, message. */
4
+ export declare function say(message: string, tone?: 'info' | 'ok' | 'warn' | 'error'): void;
5
+ export declare function dim(message: string): void;
package/dist/log.js ADDED
@@ -0,0 +1,47 @@
1
+ /**
2
+ * A short, rotating local activity log: what Fiodos asked this bridge to do,
3
+ * so the administrator of the machine can see it. Never a body, never a
4
+ * header value — method, path, status, time.
5
+ */
6
+ import { existsSync, mkdirSync, renameSync, statSync, appendFileSync } from 'node:fs';
7
+ import { join } from 'node:path';
8
+ import { bridgeHome } from './config.js';
9
+ const MAX_BYTES = 512 * 1024;
10
+ export function activityLogPath() {
11
+ return join(bridgeHome(), 'activity.log');
12
+ }
13
+ export function logActivity(line) {
14
+ try {
15
+ mkdirSync(bridgeHome(), { recursive: true, mode: 0o700 });
16
+ const path = activityLogPath();
17
+ if (existsSync(path) && statSync(path).size > MAX_BYTES) {
18
+ renameSync(path, `${path}.1`);
19
+ }
20
+ appendFileSync(path, `${new Date().toISOString()} ${line}\n`, { mode: 0o600 });
21
+ }
22
+ catch {
23
+ // The log is a courtesy to the administrator, never a reason to fail.
24
+ }
25
+ }
26
+ const colors = () => {
27
+ const on = Boolean(process.stderr.isTTY) && !process.env.NO_COLOR;
28
+ return {
29
+ blue: on ? '\x1b[34m' : '',
30
+ cyan: on ? '\x1b[36m' : '',
31
+ green: on ? '\x1b[32m' : '',
32
+ yellow: on ? '\x1b[33m' : '',
33
+ red: on ? '\x1b[31m' : '',
34
+ dim: on ? '\x1b[2m' : '',
35
+ reset: on ? '\x1b[0m' : '',
36
+ };
37
+ };
38
+ /** The same face as the rest of the Fiodos installers: orb, name, message. */
39
+ export function say(message, tone = 'info') {
40
+ const c = colors();
41
+ const orb = tone === 'ok' ? `${c.green}◉` : tone === 'warn' ? `${c.yellow}◉` : tone === 'error' ? `${c.red}◉` : `${c.cyan}◉`;
42
+ process.stderr.write(`${orb}${c.reset} ${c.blue}Fiodos${c.reset} ${message}\n`);
43
+ }
44
+ export function dim(message) {
45
+ const c = colors();
46
+ process.stderr.write(` ${c.dim}${message}${c.reset}\n`);
47
+ }
package/dist/pair.d.ts ADDED
@@ -0,0 +1,21 @@
1
+ import type { PairedBridge } from './config.js';
2
+ export declare const DEFAULT_API_URL = "https://api.fiodos.com";
3
+ export interface PairOptions {
4
+ code: string;
5
+ target: string;
6
+ apiUrl?: string;
7
+ name?: string;
8
+ headers?: Record<string, string>;
9
+ allowPaths?: string[];
10
+ fetchImpl?: typeof fetch;
11
+ }
12
+ export interface PairAnswer {
13
+ bridgeId: string;
14
+ token: string;
15
+ name: string;
16
+ target: string;
17
+ wsUrl: string;
18
+ }
19
+ /** `"Authorization: Bearer x"` → `["Authorization", "Bearer x"]`. */
20
+ export declare function parseHeaderFlag(raw: string): [string, string];
21
+ export declare function pair(options: PairOptions): Promise<PairedBridge>;
package/dist/pair.js ADDED
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Spend the one-time code the dashboard showed and keep what comes back.
3
+ */
4
+ import { normalizeOrigin, normalizePrefix } from './allowlist.js';
5
+ import { VERSION } from './runner.js';
6
+ export const DEFAULT_API_URL = 'https://api.fiodos.com';
7
+ /** `"Authorization: Bearer x"` → `["Authorization", "Bearer x"]`. */
8
+ export function parseHeaderFlag(raw) {
9
+ const idx = raw.indexOf(':');
10
+ if (idx <= 0)
11
+ throw new Error(`--header expects "Name: value", got ${JSON.stringify(raw)}`);
12
+ const name = raw.slice(0, idx).trim();
13
+ const value = raw.slice(idx + 1).trim();
14
+ if (!name || !value)
15
+ throw new Error(`--header expects "Name: value", got ${JSON.stringify(raw)}`);
16
+ return [name, value];
17
+ }
18
+ export async function pair(options) {
19
+ const apiUrl = (options.apiUrl || DEFAULT_API_URL).replace(/\/+$/, '');
20
+ const origin = normalizeOrigin(options.target);
21
+ const fetchImpl = options.fetchImpl || fetch;
22
+ const res = await fetchImpl(`${apiUrl}/v1/bridge/pair`, {
23
+ method: 'POST',
24
+ headers: { 'content-type': 'application/json', accept: 'application/json' },
25
+ body: JSON.stringify({ code: options.code.trim(), target: origin, name: options.name || '', version: VERSION }),
26
+ });
27
+ const text = await res.text();
28
+ let data = {};
29
+ try {
30
+ data = JSON.parse(text);
31
+ }
32
+ catch {
33
+ // Not JSON: the status carries the news.
34
+ }
35
+ if (!res.ok) {
36
+ const detail = data.detail || {};
37
+ const message = String(detail.message || detail.error || data.message || `HTTP ${res.status}`);
38
+ throw new Error(message);
39
+ }
40
+ const answer = data;
41
+ if (!answer.token || !answer.bridgeId || !answer.wsUrl)
42
+ throw new Error('Fiodos answered without a token — try a new code.');
43
+ return {
44
+ bridgeId: answer.bridgeId,
45
+ name: answer.name || options.name || '',
46
+ apiUrl,
47
+ wsUrl: answer.wsUrl,
48
+ token: answer.token,
49
+ target: {
50
+ origin,
51
+ allowPaths: (options.allowPaths && options.allowPaths.length ? options.allowPaths : ['/']).map(normalizePrefix),
52
+ headers: { ...(options.headers || {}) },
53
+ },
54
+ pairedAt: new Date().toISOString(),
55
+ };
56
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * The bridge at work: one outbound WebSocket to Fiodos, held open; every
3
+ * `request` frame judged by the allow-list and executed against the local
4
+ * target; the answer sent back under the same id.
5
+ *
6
+ * Frames (see backend/routes/bridge_routes.py):
7
+ * us → Fiodos {type:"hello", token, version, target} · {type:"ping"} ·
8
+ * {type:"response", id, status, headers, body} | {type:"response", id, error}
9
+ * Fiodos → us {type:"welcome", bridgeId, name} · {type:"pong"} ·
10
+ * {type:"request", id, method, path, query, headers, body} ·
11
+ * {type:"revoked"} · {type:"close", reason}
12
+ *
13
+ * The token travels as the first frame, not in the URL (URLs end up in access
14
+ * logs) and not as a header (Node's browser-shaped WebSocket has none).
15
+ */
16
+ import { type IncomingRequest } from './allowlist.js';
17
+ import type { PairedBridge } from './config.js';
18
+ export declare const VERSION = "0.1.0";
19
+ export declare const SUBPROTOCOL = "fiodos-bridge";
20
+ export interface RunnerEvents {
21
+ /** The server revoked this bridge: the caller removes it from the config. */
22
+ onRevoked?: (bridge: PairedBridge) => void;
23
+ /** Another process took this bridge's place. */
24
+ onReplaced?: (bridge: PairedBridge) => void;
25
+ }
26
+ export interface Executor {
27
+ (request: IncomingRequest): Promise<{
28
+ status: number;
29
+ headers: Record<string, string>;
30
+ body: string;
31
+ } | {
32
+ error: string;
33
+ }>;
34
+ }
35
+ /** Execute one judged request against the local target with fetch. */
36
+ export declare function makeExecutor(bridge: PairedBridge, fetchImpl?: typeof fetch): Executor;
37
+ /**
38
+ * Hold the socket for one bridge until it is revoked or replaced. Resolves
39
+ * with `'revoked' | 'replaced'`; a dropped connection reconnects with backoff
40
+ * and never resolves on its own.
41
+ */
42
+ export declare function runBridge(bridge: PairedBridge, events?: RunnerEvents, executor?: Executor): Promise<'revoked' | 'replaced'>;
package/dist/runner.js ADDED
@@ -0,0 +1,206 @@
1
+ /**
2
+ * The bridge at work: one outbound WebSocket to Fiodos, held open; every
3
+ * `request` frame judged by the allow-list and executed against the local
4
+ * target; the answer sent back under the same id.
5
+ *
6
+ * Frames (see backend/routes/bridge_routes.py):
7
+ * us → Fiodos {type:"hello", token, version, target} · {type:"ping"} ·
8
+ * {type:"response", id, status, headers, body} | {type:"response", id, error}
9
+ * Fiodos → us {type:"welcome", bridgeId, name} · {type:"pong"} ·
10
+ * {type:"request", id, method, path, query, headers, body} ·
11
+ * {type:"revoked"} · {type:"close", reason}
12
+ *
13
+ * The token travels as the first frame, not in the URL (URLs end up in access
14
+ * logs) and not as a header (Node's browser-shaped WebSocket has none).
15
+ */
16
+ import { judge, outboundHeaders } from './allowlist.js';
17
+ import { logActivity, say, dim } from './log.js';
18
+ export const VERSION = '0.1.0';
19
+ export const SUBPROTOCOL = 'fiodos-bridge';
20
+ const PING_MS = 20_000;
21
+ const REQUEST_TIMEOUT_MS = 55_000;
22
+ const MAX_BODY_CHARS = 2_000_000;
23
+ const BACKOFF_MIN_MS = 1_000;
24
+ const BACKOFF_MAX_MS = 30_000;
25
+ /** Execute one judged request against the local target with fetch. */
26
+ export function makeExecutor(bridge, fetchImpl = fetch) {
27
+ return async (request) => {
28
+ const verdict = judge(bridge.target, request);
29
+ if (!verdict.ok) {
30
+ logActivity(`REFUSED ${request.method} ${request.path} — ${verdict.reason}`);
31
+ return { error: verdict.reason };
32
+ }
33
+ const controller = new AbortController();
34
+ const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
35
+ const started = Date.now();
36
+ try {
37
+ const hasBody = request.body != null && verdict.method !== 'GET';
38
+ const res = await fetchImpl(verdict.url, {
39
+ method: verdict.method,
40
+ headers: outboundHeaders(bridge.target, request.headers),
41
+ body: hasBody ? String(request.body) : undefined,
42
+ signal: controller.signal,
43
+ redirect: 'manual',
44
+ });
45
+ let body = await res.text();
46
+ if (body.length > MAX_BODY_CHARS)
47
+ body = body.slice(0, MAX_BODY_CHARS);
48
+ const headers = {};
49
+ const ct = res.headers.get('content-type');
50
+ if (ct)
51
+ headers['content-type'] = ct;
52
+ logActivity(`${verdict.method} ${request.path} → ${res.status} (${Date.now() - started}ms)`);
53
+ return { status: res.status, headers, body };
54
+ }
55
+ catch (err) {
56
+ const reason = controller.signal.aborted ? `local service did not answer in ${REQUEST_TIMEOUT_MS / 1000}s` : `local service unreachable: ${err.message}`;
57
+ logActivity(`${verdict.method} ${request.path} → ERROR ${reason}`);
58
+ return { error: reason };
59
+ }
60
+ finally {
61
+ clearTimeout(timer);
62
+ }
63
+ };
64
+ }
65
+ function backoff(attempt) {
66
+ const ms = Math.min(BACKOFF_MAX_MS, BACKOFF_MIN_MS * 2 ** Math.min(attempt, 6));
67
+ return ms + Math.floor(Math.random() * 500);
68
+ }
69
+ /**
70
+ * Hold the socket for one bridge until it is revoked or replaced. Resolves
71
+ * with `'revoked' | 'replaced'`; a dropped connection reconnects with backoff
72
+ * and never resolves on its own.
73
+ */
74
+ export function runBridge(bridge, events = {}, executor = makeExecutor(bridge)) {
75
+ const WebSocketCtor = globalThis.WebSocket;
76
+ if (!WebSocketCtor)
77
+ throw new Error('This Node.js has no WebSocket client. Node 22.5 or newer is required.');
78
+ return new Promise((resolve) => {
79
+ let attempt = 0;
80
+ let settled = false;
81
+ const finish = (why) => {
82
+ if (settled)
83
+ return;
84
+ settled = true;
85
+ if (why === 'revoked')
86
+ events.onRevoked?.(bridge);
87
+ else
88
+ events.onReplaced?.(bridge);
89
+ resolve(why);
90
+ };
91
+ const connect = () => {
92
+ if (settled)
93
+ return;
94
+ let ws;
95
+ try {
96
+ ws = new WebSocketCtor(bridge.wsUrl, [SUBPROTOCOL]);
97
+ }
98
+ catch (err) {
99
+ say(`could not open the connection: ${err.message}`, 'error');
100
+ setTimeout(connect, backoff(attempt++));
101
+ return;
102
+ }
103
+ let pingTimer = null;
104
+ let welcomed = false;
105
+ ws.addEventListener('open', () => {
106
+ ws.send(JSON.stringify({ type: 'hello', token: bridge.token, version: VERSION, target: bridge.target.origin }));
107
+ pingTimer = setInterval(() => {
108
+ try {
109
+ ws.send(JSON.stringify({ type: 'ping' }));
110
+ }
111
+ catch {
112
+ // The close handler will reconnect.
113
+ }
114
+ }, PING_MS);
115
+ });
116
+ ws.addEventListener('message', (event) => {
117
+ let frame;
118
+ try {
119
+ frame = JSON.parse(String(event.data));
120
+ }
121
+ catch {
122
+ return;
123
+ }
124
+ const type = String(frame.type || '');
125
+ if (type === 'welcome') {
126
+ welcomed = true;
127
+ attempt = 0;
128
+ say(`connected — ${bridge.name || bridge.bridgeId} is reachable by your company agent`, 'ok');
129
+ dim(`serving ${bridge.target.origin} · Ctrl-C to stop`);
130
+ logActivity(`CONNECTED ${bridge.wsUrl}`);
131
+ return;
132
+ }
133
+ if (type === 'pong')
134
+ return;
135
+ if (type === 'revoked') {
136
+ say('this bridge was revoked from the Fiodos dashboard — stopping.', 'warn');
137
+ logActivity('REVOKED by the dashboard');
138
+ finish('revoked');
139
+ try {
140
+ ws.close(1000, 'revoked');
141
+ }
142
+ catch {
143
+ // Already closing.
144
+ }
145
+ return;
146
+ }
147
+ if (type === 'close' && String(frame.reason || '') === 'replaced') {
148
+ say('another bridge process connected with this pairing — this one exits.', 'warn');
149
+ logActivity('REPLACED by another process');
150
+ finish('replaced');
151
+ try {
152
+ ws.close(1000, 'replaced');
153
+ }
154
+ catch {
155
+ // Already closing.
156
+ }
157
+ return;
158
+ }
159
+ if (type === 'request') {
160
+ const id = String(frame.id || '');
161
+ const request = {
162
+ method: String(frame.method || 'GET'),
163
+ path: String(frame.path || '/'),
164
+ query: frame.query || {},
165
+ headers: frame.headers || {},
166
+ body: typeof frame.body === 'string' ? frame.body : null,
167
+ };
168
+ void executor(request).then((answer) => {
169
+ try {
170
+ ws.send(JSON.stringify({ type: 'response', id, ...answer }));
171
+ }
172
+ catch {
173
+ // Socket gone; the server times the request out.
174
+ }
175
+ });
176
+ }
177
+ });
178
+ const onGone = (what) => {
179
+ if (pingTimer)
180
+ clearInterval(pingTimer);
181
+ pingTimer = null;
182
+ if (settled)
183
+ return;
184
+ const wait = backoff(attempt++);
185
+ if (welcomed)
186
+ logActivity(`DISCONNECTED (${what})`);
187
+ say(`${what} — reconnecting in ${Math.round(wait / 1000)}s`, 'warn');
188
+ setTimeout(connect, wait);
189
+ };
190
+ ws.addEventListener('close', (raw) => {
191
+ const event = raw;
192
+ if (event.code === 4401) {
193
+ say('Fiodos did not accept this bridge\u2019s token. Pair it again from the dashboard.', 'error');
194
+ logActivity('REFUSED token (4401)');
195
+ finish('revoked');
196
+ return;
197
+ }
198
+ onGone(`connection closed (${event.code})`);
199
+ });
200
+ ws.addEventListener('error', () => {
201
+ // `close` follows an error; the message there is enough.
202
+ });
203
+ };
204
+ connect();
205
+ });
206
+ }
@@ -0,0 +1,11 @@
1
+ export declare const SERVICE_LABEL = "com.fiodos.bridge";
2
+ export interface ServicePlan {
3
+ platform: string;
4
+ path: string | null;
5
+ contents: string | null;
6
+ commands: string[];
7
+ notes: string[];
8
+ }
9
+ export declare function plan(): ServicePlan;
10
+ export declare function install(): ServicePlan;
11
+ export declare function uninstall(): ServicePlan;
@@ -0,0 +1,142 @@
1
+ /**
2
+ * `fiodos-bridge service install` — start with the machine.
3
+ *
4
+ * macOS: a launchd agent in ~/Library/LaunchAgents (runs when the user is
5
+ * logged in — a headless server should use a LaunchDaemon, which needs root
6
+ * and is printed as instructions). Linux: a systemd --user unit, with the
7
+ * `loginctl enable-linger` reminder so it survives logout. Windows: printed
8
+ * instructions (Task Scheduler), because writing a service there needs tools
9
+ * this package does not ship.
10
+ *
11
+ * Every file written is shown, so the administrator can read what runs.
12
+ */
13
+ import { execFileSync } from 'node:child_process';
14
+ import { existsSync, mkdirSync, unlinkSync, writeFileSync } from 'node:fs';
15
+ import { homedir, platform } from 'node:os';
16
+ import { join } from 'node:path';
17
+ import { bridgeHome } from './config.js';
18
+ export const SERVICE_LABEL = 'com.fiodos.bridge';
19
+ function nodeBinary() {
20
+ return process.execPath;
21
+ }
22
+ function bridgeEntry() {
23
+ // The compiled CLI of THIS installation, so the service runs the same code.
24
+ return new URL('./cli.js', import.meta.url).pathname;
25
+ }
26
+ export function plan() {
27
+ const os = platform();
28
+ const node = nodeBinary();
29
+ const entry = bridgeEntry();
30
+ const home = bridgeHome();
31
+ if (os === 'darwin') {
32
+ const path = join(homedir(), 'Library', 'LaunchAgents', `${SERVICE_LABEL}.plist`);
33
+ const contents = `<?xml version="1.0" encoding="UTF-8"?>
34
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
35
+ <plist version="1.0">
36
+ <dict>
37
+ <key>Label</key><string>${SERVICE_LABEL}</string>
38
+ <key>ProgramArguments</key>
39
+ <array>
40
+ <string>${node}</string>
41
+ <string>${entry}</string>
42
+ <string>run</string>
43
+ </array>
44
+ <key>EnvironmentVariables</key>
45
+ <dict><key>FIODOS_BRIDGE_HOME</key><string>${home}</string></dict>
46
+ <key>RunAtLoad</key><true/>
47
+ <key>KeepAlive</key><true/>
48
+ <key>StandardOutPath</key><string>${join(home, 'service.log')}</string>
49
+ <key>StandardErrorPath</key><string>${join(home, 'service.log')}</string>
50
+ </dict>
51
+ </plist>
52
+ `;
53
+ return {
54
+ platform: os,
55
+ path,
56
+ contents,
57
+ commands: [`launchctl unload ${path} 2>/dev/null || true`, `launchctl load -w ${path}`],
58
+ notes: [
59
+ 'Runs while this user is logged in. For a server nobody logs into, copy the plist to /Library/LaunchDaemons (as root) and load it there.',
60
+ ],
61
+ };
62
+ }
63
+ if (os === 'linux') {
64
+ const dir = join(homedir(), '.config', 'systemd', 'user');
65
+ const path = join(dir, 'fiodos-bridge.service');
66
+ const contents = `[Unit]
67
+ Description=Fiodos bridge (reaches a dashboard on this network for the Fiodos company agent)
68
+ After=network-online.target
69
+
70
+ [Service]
71
+ ExecStart=${node} ${entry} run
72
+ Environment=FIODOS_BRIDGE_HOME=${home}
73
+ Restart=always
74
+ RestartSec=5
75
+
76
+ [Install]
77
+ WantedBy=default.target
78
+ `;
79
+ return {
80
+ platform: os,
81
+ path,
82
+ contents,
83
+ commands: ['systemctl --user daemon-reload', 'systemctl --user enable --now fiodos-bridge.service'],
84
+ notes: ['So it keeps running after you log out: sudo loginctl enable-linger $USER'],
85
+ };
86
+ }
87
+ return {
88
+ platform: os,
89
+ path: null,
90
+ contents: null,
91
+ commands: [],
92
+ notes: [
93
+ 'Automatic service install is not available on this platform.',
94
+ `Run at start-up with your scheduler: "${node}" "${entry}" run`,
95
+ `Keep FIODOS_BRIDGE_HOME=${home} in its environment.`,
96
+ 'Or run the container image: docker run -d --restart unless-stopped -v fiodos-bridge:/data --network host fiodos/bridge run',
97
+ ],
98
+ };
99
+ }
100
+ export function install() {
101
+ const p = plan();
102
+ if (!p.path || !p.contents)
103
+ return p;
104
+ mkdirSync(join(p.path, '..'), { recursive: true });
105
+ mkdirSync(bridgeHome(), { recursive: true, mode: 0o700 });
106
+ writeFileSync(p.path, p.contents, { mode: 0o644 });
107
+ for (const command of p.commands) {
108
+ const [bin, ...args] = command.replace(/ 2>\/dev\/null \|\| true$/, '').split(' ');
109
+ try {
110
+ execFileSync(bin, args, { stdio: 'ignore' });
111
+ }
112
+ catch {
113
+ // `unload` of a not-yet-loaded agent fails harmlessly; anything else is
114
+ // reported by `status`.
115
+ }
116
+ }
117
+ return p;
118
+ }
119
+ export function uninstall() {
120
+ const p = plan();
121
+ if (!p.path)
122
+ return p;
123
+ if (p.platform === 'darwin') {
124
+ try {
125
+ execFileSync('launchctl', ['unload', p.path], { stdio: 'ignore' });
126
+ }
127
+ catch {
128
+ // Not loaded.
129
+ }
130
+ }
131
+ else if (p.platform === 'linux') {
132
+ try {
133
+ execFileSync('systemctl', ['--user', 'disable', '--now', 'fiodos-bridge.service'], { stdio: 'ignore' });
134
+ }
135
+ catch {
136
+ // Not enabled.
137
+ }
138
+ }
139
+ if (existsSync(p.path))
140
+ unlinkSync(p.path);
141
+ return p;
142
+ }
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@fiodos/bridge",
3
+ "version": "0.1.0",
4
+ "description": "The Fiodos bridge — a small process that runs beside a dashboard on your own network and lets your Fiodos company agent reach it. It calls Fiodos (one outbound WebSocket); nothing calls in. Pair it with the one-time code your dashboard shows: `npx -y --loglevel=error @fiodos/bridge pair <code> --target http://localhost:3947`.",
5
+ "license": "SEE LICENSE IN LICENSE",
6
+ "publishConfig": {
7
+ "access": "public"
8
+ },
9
+ "type": "module",
10
+ "bin": {
11
+ "fiodos-bridge": "dist/cli.js"
12
+ },
13
+ "main": "dist/index.js",
14
+ "types": "dist/index.d.ts",
15
+ "exports": {
16
+ ".": {
17
+ "types": "./dist/index.d.ts",
18
+ "default": "./dist/index.js"
19
+ }
20
+ },
21
+ "files": [
22
+ "dist",
23
+ "Dockerfile",
24
+ "README.md",
25
+ "LICENSE"
26
+ ],
27
+ "engines": {
28
+ "node": ">=22.5"
29
+ },
30
+ "scripts": {
31
+ "build": "tsc -p tsconfig.build.json",
32
+ "typecheck": "tsc -p tsconfig.json --noEmit",
33
+ "test": "node --import tsx --test test/*.test.ts"
34
+ },
35
+ "devDependencies": {
36
+ "@types/node": "^22.0.0",
37
+ "tsx": "^4.19.0",
38
+ "typescript": "^5.6.0"
39
+ }
40
+ }