fbi-proxy 1.15.0 → 1.17.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/README.md +17 -4
- package/dist/cli.js +9619 -1772
- package/package.json +4 -1
- package/release/fbi-proxy-linux-arm64 +0 -0
- package/release/fbi-proxy-linux-x64 +0 -0
- package/release/fbi-proxy-macos-arm64 +0 -0
- package/release/fbi-proxy-macos-x64 +0 -0
- package/release/fbi-proxy-windows-arm64.exe +0 -0
- package/release/fbi-proxy-windows-x64.exe +0 -0
- package/rs/fbi-proxy.rs +648 -120
- package/rs/lib.rs +1 -0
- package/rs/routes.rs +226 -31
- package/rs/tls.rs +243 -0
- package/ts/adminClient.ts +124 -0
- package/ts/auth/authConfig.ts +19 -1
- package/ts/cli.ts +158 -2
- package/ts/install-port-forward.ts +152 -0
- package/ts/routes.ts +50 -0
- package/ts/rulesCli.ts +166 -0
- package/ts/setup.ts +374 -0
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
import yargs from "yargs";
|
|
5
|
+
import { hideBin } from "yargs/helpers";
|
|
6
|
+
|
|
7
|
+
const ANCHOR_NAME = "com.snomiao.fbi-proxy";
|
|
8
|
+
const ANCHOR_FILE = `/etc/pf.anchors/${ANCHOR_NAME}`;
|
|
9
|
+
const PLIST_FILE = `/Library/LaunchDaemons/${ANCHOR_NAME}-pf.plist`;
|
|
10
|
+
|
|
11
|
+
const argv = await yargs(hideBin(process.argv))
|
|
12
|
+
.option("from", {
|
|
13
|
+
type: "number",
|
|
14
|
+
default: 443,
|
|
15
|
+
description: "Public-facing port to redirect (privileged)",
|
|
16
|
+
})
|
|
17
|
+
.option("to", {
|
|
18
|
+
type: "number",
|
|
19
|
+
default: 8443,
|
|
20
|
+
description: "Backend port the oxmgr-managed proxy listens on",
|
|
21
|
+
})
|
|
22
|
+
.option("uninstall", {
|
|
23
|
+
type: "boolean",
|
|
24
|
+
default: false,
|
|
25
|
+
description: "Remove the pf rule and LaunchDaemon",
|
|
26
|
+
})
|
|
27
|
+
.help().argv;
|
|
28
|
+
|
|
29
|
+
if (process.platform !== "darwin") {
|
|
30
|
+
console.error("install-port-forward: macOS only (pf rules)");
|
|
31
|
+
process.exit(2);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (argv.uninstall) {
|
|
35
|
+
const script = [
|
|
36
|
+
`launchctl unload "${PLIST_FILE}" 2>/dev/null`,
|
|
37
|
+
`rm -f "${PLIST_FILE}" "${ANCHOR_FILE}"`,
|
|
38
|
+
`pfctl -a ${ANCHOR_NAME} -F all 2>/dev/null`,
|
|
39
|
+
"echo uninstalled",
|
|
40
|
+
].join("\n");
|
|
41
|
+
runAsRoot(script);
|
|
42
|
+
process.exit(0);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// pf-rdr is loopback-only here because fbi.com resolves to 127.0.0.1 via local
|
|
46
|
+
// DNS. If you ever expose this on a real interface, widen the rule.
|
|
47
|
+
const anchorContent = `rdr pass on lo0 inet proto tcp from any to any port ${argv.from} -> 127.0.0.1 port ${argv.to}\n`;
|
|
48
|
+
|
|
49
|
+
const plistContent = `<?xml version="1.0" encoding="UTF-8"?>
|
|
50
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
51
|
+
<plist version="1.0">
|
|
52
|
+
<dict>
|
|
53
|
+
<key>Label</key><string>${ANCHOR_NAME}-pf</string>
|
|
54
|
+
<key>ProgramArguments</key>
|
|
55
|
+
<array>
|
|
56
|
+
<string>/bin/sh</string>
|
|
57
|
+
<string>-c</string>
|
|
58
|
+
<string>/sbin/pfctl -E 2>/dev/null; /sbin/pfctl -a ${ANCHOR_NAME} -f ${ANCHOR_FILE}</string>
|
|
59
|
+
</array>
|
|
60
|
+
<key>RunAtLoad</key><true/>
|
|
61
|
+
<key>StandardOutPath</key><string>/var/log/${ANCHOR_NAME}-pf.out.log</string>
|
|
62
|
+
<key>StandardErrorPath</key><string>/var/log/${ANCHOR_NAME}-pf.err.log</string>
|
|
63
|
+
</dict>
|
|
64
|
+
</plist>
|
|
65
|
+
`;
|
|
66
|
+
|
|
67
|
+
if (alreadyInstalledFor(argv.from, argv.to)) {
|
|
68
|
+
console.log(
|
|
69
|
+
`pf forward :${argv.from} -> :${argv.to} already installed and active`,
|
|
70
|
+
);
|
|
71
|
+
process.exit(0);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
console.log(
|
|
75
|
+
`installing pf forward :${argv.from} -> :${argv.to} via macOS auth dialog…`,
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
// Single elevated shell — writes both files, loads the LaunchDaemon, and
|
|
79
|
+
// applies the rule immediately so we don't wait for a reboot.
|
|
80
|
+
const heredoc = (path: string, body: string) =>
|
|
81
|
+
`cat > "${path}" <<'__FBI_PROXY_PF_EOF__'\n${body}__FBI_PROXY_PF_EOF__`;
|
|
82
|
+
|
|
83
|
+
const script = [
|
|
84
|
+
heredoc(ANCHOR_FILE, anchorContent),
|
|
85
|
+
`chmod 644 "${ANCHOR_FILE}"`,
|
|
86
|
+
heredoc(PLIST_FILE, plistContent),
|
|
87
|
+
`chown root:wheel "${PLIST_FILE}"`,
|
|
88
|
+
`chmod 644 "${PLIST_FILE}"`,
|
|
89
|
+
`launchctl unload "${PLIST_FILE}" 2>/dev/null || true`,
|
|
90
|
+
`launchctl load -w "${PLIST_FILE}"`,
|
|
91
|
+
`/sbin/pfctl -E 2>/dev/null || true`,
|
|
92
|
+
`/sbin/pfctl -a ${ANCHOR_NAME} -f "${ANCHOR_FILE}"`,
|
|
93
|
+
`echo OK`,
|
|
94
|
+
].join("\n");
|
|
95
|
+
|
|
96
|
+
const status = runAsRoot(script);
|
|
97
|
+
if (status !== 0) {
|
|
98
|
+
console.error(`pf forward install failed (exit ${status})`);
|
|
99
|
+
process.exit(1);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (alreadyInstalledFor(argv.from, argv.to)) {
|
|
103
|
+
console.log(
|
|
104
|
+
`pf forward :${argv.from} -> :${argv.to} active. LaunchDaemon: ${PLIST_FILE}`,
|
|
105
|
+
);
|
|
106
|
+
} else {
|
|
107
|
+
console.warn(
|
|
108
|
+
`pf forward installed but verification failed — check 'sudo pfctl -a ${ANCHOR_NAME} -s nat'`,
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function runAsRoot(script: string): number {
|
|
113
|
+
const hasTty = !!process.stdin.isTTY;
|
|
114
|
+
if (hasTty && process.getuid?.() !== 0) {
|
|
115
|
+
const result = spawnSync("sudo", ["sh", "-c", script], {
|
|
116
|
+
stdio: "inherit",
|
|
117
|
+
});
|
|
118
|
+
return result.status ?? 1;
|
|
119
|
+
}
|
|
120
|
+
if (process.getuid?.() === 0) {
|
|
121
|
+
const result = spawnSync("sh", ["-c", script], { stdio: "inherit" });
|
|
122
|
+
return result.status ?? 1;
|
|
123
|
+
}
|
|
124
|
+
// GUI password dialog — works without TTY (Claude Code, oxmgr children, etc.)
|
|
125
|
+
const prompt =
|
|
126
|
+
`fbi-proxy needs administrator access to install a pf port-forward ` +
|
|
127
|
+
`(:${argv.from} → :${argv.to}) and its boot LaunchDaemon.`;
|
|
128
|
+
const osascript = `do shell script ${appleScriptQuote(script)} with prompt ${appleScriptQuote(prompt)} with administrator privileges`;
|
|
129
|
+
const result = spawnSync("osascript", ["-e", osascript], {
|
|
130
|
+
stdio: "inherit",
|
|
131
|
+
});
|
|
132
|
+
return result.status ?? 1;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function alreadyInstalledFor(from: number, to: number): boolean {
|
|
136
|
+
if (!existsSync(ANCHOR_FILE) || !existsSync(PLIST_FILE)) return false;
|
|
137
|
+
// -s nat needs root to read the runtime rule table on most setups
|
|
138
|
+
const probe = spawnSync(
|
|
139
|
+
"sudo",
|
|
140
|
+
["-n", "/sbin/pfctl", "-a", ANCHOR_NAME, "-s", "nat"],
|
|
141
|
+
{
|
|
142
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
143
|
+
},
|
|
144
|
+
);
|
|
145
|
+
if (probe.status !== 0) return false;
|
|
146
|
+
const out = probe.stdout?.toString() ?? "";
|
|
147
|
+
return out.includes(`port = ${from}`) && out.includes(`port ${to}`);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function appleScriptQuote(s: string): string {
|
|
151
|
+
return `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
152
|
+
}
|
package/ts/routes.ts
CHANGED
|
@@ -27,6 +27,12 @@ export type RouteConfig = {
|
|
|
27
27
|
* `{name:multi}` (one or more dot-segments — for DNS-passthrough).
|
|
28
28
|
*/
|
|
29
29
|
match: string;
|
|
30
|
+
/**
|
|
31
|
+
* Optional path-prefix matcher. When set, the rule only matches
|
|
32
|
+
* requests whose path falls under this prefix; among host-matching
|
|
33
|
+
* rules the longest matching prefix wins. Forwarded upstream as-is.
|
|
34
|
+
*/
|
|
35
|
+
path?: string;
|
|
30
36
|
/**
|
|
31
37
|
* Target template. Expanded with placeholder captures from `match`.
|
|
32
38
|
* E.g. `"127.0.0.1:{port}"`.
|
|
@@ -46,6 +52,15 @@ export type RoutesFile = {
|
|
|
46
52
|
routes: RouteConfig[];
|
|
47
53
|
};
|
|
48
54
|
|
|
55
|
+
/**
|
|
56
|
+
* Compose-style per-project config (`fbi-proxy.yaml`). The top-level
|
|
57
|
+
* `name` is the namespace (defaults to the directory name when omitted).
|
|
58
|
+
*/
|
|
59
|
+
export type ComposeFile = {
|
|
60
|
+
name?: string;
|
|
61
|
+
routes: RouteConfig[];
|
|
62
|
+
};
|
|
63
|
+
|
|
49
64
|
/** Result type for `validateRoute`. */
|
|
50
65
|
export type ValidationResult =
|
|
51
66
|
| { valid: true }
|
|
@@ -108,9 +123,19 @@ export function parseRoutesYaml(yaml: string): RoutesFile {
|
|
|
108
123
|
headers[hk] = hv;
|
|
109
124
|
}
|
|
110
125
|
}
|
|
126
|
+
let path: string | undefined;
|
|
127
|
+
if (e.path != null) {
|
|
128
|
+
if (typeof e.path !== "string") {
|
|
129
|
+
throw new Error(
|
|
130
|
+
`routes.yaml: entry '${e.name}': \`path\` must be a string`,
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
path = e.path;
|
|
134
|
+
}
|
|
111
135
|
routes.push({
|
|
112
136
|
name: e.name,
|
|
113
137
|
match: e.match,
|
|
138
|
+
...(path != null ? { path } : {}),
|
|
114
139
|
target: e.target,
|
|
115
140
|
headers,
|
|
116
141
|
});
|
|
@@ -118,6 +143,28 @@ export function parseRoutesYaml(yaml: string): RoutesFile {
|
|
|
118
143
|
return { version: 1, routes };
|
|
119
144
|
}
|
|
120
145
|
|
|
146
|
+
/**
|
|
147
|
+
* Parse a compose-style `fbi-proxy.yaml`. Reuses the route validation in
|
|
148
|
+
* `parseRoutesYaml`. Returns the namespace (`name`, possibly undefined)
|
|
149
|
+
* and the parsed routes.
|
|
150
|
+
*/
|
|
151
|
+
export function parseComposeYaml(yaml: string): ComposeFile {
|
|
152
|
+
const raw = YAML.parse(yaml);
|
|
153
|
+
if (raw == null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
154
|
+
throw new Error("fbi-proxy.yaml must be a YAML mapping at the top level");
|
|
155
|
+
}
|
|
156
|
+
const obj = raw as Record<string, unknown>;
|
|
157
|
+
if (obj.name != null && typeof obj.name !== "string") {
|
|
158
|
+
throw new Error("fbi-proxy.yaml: `name` must be a string");
|
|
159
|
+
}
|
|
160
|
+
// parseRoutesYaml validates the `routes` list + each entry; version is
|
|
161
|
+
// optional in a compose file so default it in.
|
|
162
|
+
const { routes } = parseRoutesYaml(
|
|
163
|
+
YAML.stringify({ version: 1, routes: obj.routes ?? [] }),
|
|
164
|
+
);
|
|
165
|
+
return { name: obj.name as string | undefined, routes };
|
|
166
|
+
}
|
|
167
|
+
|
|
121
168
|
const PLACEHOLDER_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
122
169
|
const VALID_KINDS = new Set(["", "int", "slug", "multi"]);
|
|
123
170
|
|
|
@@ -164,6 +211,9 @@ export function validateRoute(r: RouteConfig): ValidationResult {
|
|
|
164
211
|
if (!r.match) return { valid: false, reason: "route `match` is required" };
|
|
165
212
|
if (!r.target) return { valid: false, reason: "route `target` is required" };
|
|
166
213
|
|
|
214
|
+
if (r.path != null && !r.path.startsWith("/"))
|
|
215
|
+
return { valid: false, reason: "route `path` must start with '/'" };
|
|
216
|
+
|
|
167
217
|
if (!bracesBalanced(r.match))
|
|
168
218
|
return { valid: false, reason: "unbalanced braces in `match`" };
|
|
169
219
|
if (!bracesBalanced(r.target))
|
package/ts/rulesCli.ts
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `fbi-proxy up | down | ps | config` — compose-style management of
|
|
3
|
+
* runtime routing rules. Each project ships an `fbi-proxy.yaml` whose
|
|
4
|
+
* top-level `name` is the namespace; rules are stored as conf.d
|
|
5
|
+
* fragments and applied live via the loopback admin API.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import yargs from "yargs";
|
|
11
|
+
import { hideBin } from "yargs/helpers";
|
|
12
|
+
import YAML from "yaml";
|
|
13
|
+
import { parseComposeYaml, validateRoute, type RouteConfig } from "./routes";
|
|
14
|
+
import {
|
|
15
|
+
applyRules,
|
|
16
|
+
deleteRules,
|
|
17
|
+
listRules,
|
|
18
|
+
type RuleInfo,
|
|
19
|
+
} from "./adminClient";
|
|
20
|
+
|
|
21
|
+
const DEFAULT_FILE = "fbi-proxy.yaml";
|
|
22
|
+
|
|
23
|
+
export const RULES_SUBCOMMANDS = new Set(["up", "down", "ps", "config"]);
|
|
24
|
+
|
|
25
|
+
/** Derive the namespace: explicit `-p` > compose `name` > directory name. */
|
|
26
|
+
function resolveNamespace(
|
|
27
|
+
explicit: string | undefined,
|
|
28
|
+
composeName: string | undefined,
|
|
29
|
+
filePath: string,
|
|
30
|
+
): string {
|
|
31
|
+
if (explicit) return explicit;
|
|
32
|
+
if (composeName) return composeName;
|
|
33
|
+
// directory of the compose file (or CWD for stdin) — like docker compose.
|
|
34
|
+
return path.basename(path.dirname(path.resolve(filePath)));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function loadCompose(file: string): { name?: string; routes: RouteConfig[] } {
|
|
38
|
+
if (!existsSync(file)) {
|
|
39
|
+
throw new Error(
|
|
40
|
+
`[fbi-proxy] ${file} not found. Create one (compose-style):\n` +
|
|
41
|
+
` name: my-app\n routes:\n - name: web\n match: fbi.com\n path: /\n target: localhost:3000`,
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
const compose = parseComposeYaml(readFileSync(file, "utf8"));
|
|
45
|
+
for (const r of compose.routes) {
|
|
46
|
+
const v = validateRoute(r);
|
|
47
|
+
if (!v.valid) {
|
|
48
|
+
throw new Error(`[fbi-proxy] ${file}: rule '${r.name}': ${v.reason}`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return compose;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function printRulesTable(rules: RuleInfo[]): void {
|
|
55
|
+
if (rules.length === 0) {
|
|
56
|
+
console.log("(no rules)");
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const rows = rules.map((r) => ({
|
|
60
|
+
NAMESPACE: r.namespace,
|
|
61
|
+
NAME: r.name,
|
|
62
|
+
MATCH: r.match,
|
|
63
|
+
PATH: r.path ?? "*",
|
|
64
|
+
TARGET: r.target,
|
|
65
|
+
}));
|
|
66
|
+
const cols = ["NAMESPACE", "NAME", "MATCH", "PATH", "TARGET"] as const;
|
|
67
|
+
const widths = Object.fromEntries(
|
|
68
|
+
cols.map((c) => [
|
|
69
|
+
c,
|
|
70
|
+
Math.max(c.length, ...rows.map((row) => String(row[c]).length)),
|
|
71
|
+
]),
|
|
72
|
+
) as Record<(typeof cols)[number], number>;
|
|
73
|
+
const fmt = (row: Record<string, string>) =>
|
|
74
|
+
cols.map((c) => String(row[c]).padEnd(widths[c])).join(" ");
|
|
75
|
+
console.log(fmt(Object.fromEntries(cols.map((c) => [c, c]))));
|
|
76
|
+
for (const row of rows) console.log(fmt(row));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Entry point dispatched from cli.ts when the first positional arg is one
|
|
81
|
+
* of `up|down|ps|config`. Returns the process exit code.
|
|
82
|
+
*/
|
|
83
|
+
export async function runRulesCli(rawArgs: string[]): Promise<number> {
|
|
84
|
+
const argv = await yargs(rawArgs)
|
|
85
|
+
.scriptName("fbi-proxy")
|
|
86
|
+
.command("up", "Apply this project's fbi-proxy.yaml to the running proxy")
|
|
87
|
+
.command("down", "Remove this project's rules from the running proxy")
|
|
88
|
+
.command("ps", "List active rules across all namespaces")
|
|
89
|
+
.command("config", "Print the merged resolved routing table")
|
|
90
|
+
.option("file", {
|
|
91
|
+
alias: "f",
|
|
92
|
+
type: "string",
|
|
93
|
+
default: DEFAULT_FILE,
|
|
94
|
+
description: "Path to the compose file",
|
|
95
|
+
})
|
|
96
|
+
.option("project", {
|
|
97
|
+
alias: "p",
|
|
98
|
+
type: "string",
|
|
99
|
+
description:
|
|
100
|
+
"Override the namespace (defaults to compose `name` or dir name)",
|
|
101
|
+
})
|
|
102
|
+
.option("output", {
|
|
103
|
+
alias: "o",
|
|
104
|
+
type: "string",
|
|
105
|
+
choices: ["table", "json", "yaml"] as const,
|
|
106
|
+
default: "table",
|
|
107
|
+
description: "Output format for ps/config",
|
|
108
|
+
})
|
|
109
|
+
.help().argv;
|
|
110
|
+
|
|
111
|
+
const cmd = String(argv._[0]);
|
|
112
|
+
|
|
113
|
+
try {
|
|
114
|
+
switch (cmd) {
|
|
115
|
+
case "up": {
|
|
116
|
+
const compose = loadCompose(argv.file);
|
|
117
|
+
const ns = resolveNamespace(argv.project, compose.name, argv.file);
|
|
118
|
+
const body = YAML.stringify({ version: 1, routes: compose.routes });
|
|
119
|
+
const applied = await applyRules(ns, body);
|
|
120
|
+
console.log(
|
|
121
|
+
`[fbi-proxy] up: namespace '${ns}' (${compose.routes.length} rule(s))`,
|
|
122
|
+
);
|
|
123
|
+
printRulesTable(applied.filter((r) => r.namespace === ns));
|
|
124
|
+
return 0;
|
|
125
|
+
}
|
|
126
|
+
case "down": {
|
|
127
|
+
// Namespace can come from -p, or the compose file if present.
|
|
128
|
+
let ns = argv.project;
|
|
129
|
+
if (!ns) {
|
|
130
|
+
const compose = existsSync(argv.file) ? loadCompose(argv.file) : null;
|
|
131
|
+
ns = resolveNamespace(undefined, compose?.name, argv.file);
|
|
132
|
+
}
|
|
133
|
+
const res = await deleteRules(ns);
|
|
134
|
+
console.log(
|
|
135
|
+
res.removed
|
|
136
|
+
? `[fbi-proxy] down: removed namespace '${ns}'`
|
|
137
|
+
: `[fbi-proxy] down: namespace '${ns}' was not present`,
|
|
138
|
+
);
|
|
139
|
+
return 0;
|
|
140
|
+
}
|
|
141
|
+
case "ps":
|
|
142
|
+
case "config": {
|
|
143
|
+
const rules = await listRules();
|
|
144
|
+
if (argv.output === "json") {
|
|
145
|
+
console.log(JSON.stringify(rules, null, 2));
|
|
146
|
+
} else if (argv.output === "yaml") {
|
|
147
|
+
console.log(YAML.stringify(rules));
|
|
148
|
+
} else {
|
|
149
|
+
printRulesTable(rules);
|
|
150
|
+
}
|
|
151
|
+
return 0;
|
|
152
|
+
}
|
|
153
|
+
default:
|
|
154
|
+
console.error(`[fbi-proxy] unknown command '${cmd}'`);
|
|
155
|
+
return 2;
|
|
156
|
+
}
|
|
157
|
+
} catch (e) {
|
|
158
|
+
console.error(e instanceof Error ? e.message : String(e));
|
|
159
|
+
return 1;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Convenience for standalone invocation/testing. */
|
|
164
|
+
if (import.meta.main) {
|
|
165
|
+
runRulesCli(hideBin(process.argv)).then((code) => process.exit(code));
|
|
166
|
+
}
|