ai-fly 0.0.0 → 0.1.1

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 ADDED
@@ -0,0 +1,105 @@
1
+ # ai-fly
2
+
3
+ Peer-to-peer network bridge with AI-ready presets, built on [OpenDWeb](https://github.com/jixoai/opendweb).
4
+
5
+ Share any HTTP/WebSocket upstream (ollama / vllm / LM Studio, an internal gateway, or a keyed third-party API) with friends over an invite-based fabric. Consumers get plain endpoints on `127.0.0.1` — point agents' base URLs (or any WS client) at them. No root certificates, no system-level interception, no proxy.
6
+
7
+ ```
8
+ Consumer machine Provider machine
9
+ Agent ──► http://127.0.0.1:11434/… ollama :11434 (or any HTTP/WS upstream)
10
+ ──► ws://127.0.0.1:11434/… ▲ provider engine
11
+ consumer engine │ match set + rewrite ($env keys)
12
+ │ AUTH keyring / catalog │ quotas (per key)
13
+ └────── OpenDWeb fabric ──────┘ (identity / invites / QUIC + relay)
14
+ ```
15
+
16
+ - **Two-layer credentials.** `dweb1.` invite tokens admit *devices* (fabric layer);
17
+ `sk-aifly-` keys grant *group* access (app layer). Keys are device-independent —
18
+ revoke a key to cut every holder, revoke a token to eject one device. Share links
19
+ bundle one of each for first-time onboarding.
20
+ - **Groups & services.** Providers define services (upstream + rewrite + display
21
+ domains), group them, and issue multiple keys per group. Same-named services in
22
+ different groups stay distinct (routing is always by serviceId).
23
+ - **Streaming-first.** SSE relays chunk-by-chunk; WebSocket upgrades pass through as
24
+ a bidirectional byte channel (OpenAI Responses API WS works).
25
+ - **Credentials never cross the wire** in either direction: provider keys live in
26
+ `$env` indirection; consumer-side auth headers are stripped at the protocol layer.
27
+ - **Full-disclosure, minus secrets.** Consumers see each service's complete rule
28
+ (upstream, rewrites, domains) on expand; injected credential values render as `●`.
29
+
30
+ **Status: M1 + M2 (engine + product).** The headless engine landed with the
31
+ `net-fly-core` change; `m2-productize` added the OpenTray desktop shell
32
+ (tray + window + one-time token UI), the Web UI (Dashboard / share wizard /
33
+ connect wizard / advanced), the AI preset library (curated providers +
34
+ models.dev long tail), and agent config writers (codex / claude-code /
35
+ cursor / cline / continue). Proxy mode is permanently out of scope (TLS makes
36
+ credential injection impossible without MITM).
37
+
38
+ ```bash
39
+ # Provider
40
+ ai-fly serve --upstream http://127.0.0.1:11434 # run + manage services/groups
41
+ ai-fly key issue --group friends # sk-aifly-… keys
42
+ ai-fly share --group friends --ttl 30m # aifly1.… link (token + key)
43
+
44
+ # Consumer
45
+ ai-fly import <link> --run # join + keyring + start gateway
46
+ ai-fly key add <sk-aifly-…> --provider <id> # bare key into an existing ring
47
+ ai-fly status --verbose # ports, providers, service details
48
+ ```
49
+
50
+ ## Development
51
+
52
+ ```bash
53
+ pnpm install
54
+ pnpm test # vitest (unit) + node --test (integration/e2e)
55
+ pnpm typecheck # tsc --noEmit (strict)
56
+ pnpm build # tsdown -> dist/ai-fly.js
57
+ pnpm dev # tsx src/bin.ts
58
+ ```
59
+
60
+ Node >= 20. License: MIT OR Apache-2.0. The fabric dependency
61
+ `@jixo/opendweb-client-sdk` ships native binaries for darwin-arm64 and win32-x64
62
+ (no Linux yet).
63
+
64
+ ## Manual regression checklist
65
+
66
+ Desktop-shell behaviors that automated suites do not cover. Run before any
67
+ release build. Dev form takes **two terminals**: `pnpm webui:dev` (vite on
68
+ 127.0.0.1:5190, proxying `/ws` and the token gate to the UI daemon) AND
69
+ `pnpm app:dev` (tray + window; the window loads the vite entry). Packaged
70
+ form: `pnpm app:build` + `pnpm app:start`. Each line is pass/fail; English
71
+ strings below are exactly what the UI shows.
72
+
73
+ 1. **Tray & window** — app starts with a tray icon; the tray menu's primary
74
+ item toggles the window (Open/Hide); `Quit ai-fly` exits cleanly (tray icon
75
+ disappears, ports released).
76
+ 2. **Token gate** — the UI only loads from the app window. Opening the daemon
77
+ URL directly shows the guidance page; a reused link token reports
78
+ "The link token is invalid or was already used."
79
+ 3. **Theme toggle** — dark/light switch persists across a window close/reopen.
80
+ 4. **Share wizard (3 steps)** — pick a preset (provider logos render, search
81
+ filters; local presets first), name & group, generate link. The final card
82
+ shows the `aifly1.` link with a copy field and the raw key once.
83
+ 5. **Connect wizard (3 steps)** — paste the link from another machine (or the
84
+ same one with a fresh data dir), confirm ports, pick an agent writer; the
85
+ preview diff matches the written file.
86
+ 6. **Key revoke while connected** — revoke the key on the provider (Advanced >
87
+ keys); the consumer's dashboard flips its provider row to an error state and
88
+ requests start failing with `key_revoked` within seconds.
89
+ 7. **Restart recovery** — quit and relaunch on both sides: services, groups,
90
+ keys, imported providers and custom ports all come back; the gateway
91
+ resumes and serves traffic without re-importing.
92
+ 8. **Overlay safe area & drag** (macOS overlay window) — the traffic lights
93
+ never overlap the brand block or content; dragging the strip at the very
94
+ top of the window moves the window; resize keeps the offset correct. In a
95
+ plain browser tab the layout must look identical (zero inset fallback).
96
+ 9. **Secrets panel** — Advanced > secrets: add a key-value pair, pick it in
97
+ the share wizard's "api key" selector, generate a service; the saved
98
+ rewrite shows `authorization: $secret:<name>`; values never re-display
99
+ after saving; removing a secret referenced by a service is allowed but the
100
+ service then fails with `secret_missing` until re-added.
101
+ 10. **Connectivity test** — in the share wizard (with a secret picked) or a
102
+ service row in Advanced, press `test`: the cheapest priced chat model is
103
+ picked by default, the dropdown lists models sorted by price, and the
104
+ result line shows `ok · <ms> · <model>` or the upstream error (an
105
+ upstream 401 with an invalid key is a PASS — the network is proven).
@@ -0,0 +1 @@
1
+ export {}
package/dist/ai-fly.js ADDED
@@ -0,0 +1,76 @@
1
+ #!/usr/bin/env node
2
+ import { n as UsageError, r as reportCliError } from "./errors-BGtxPkM7.mjs";
3
+ import { existsSync } from "node:fs";
4
+ import { homedir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { match } from "ts-pattern";
7
+ //#region src/bin.ts
8
+ const USAGE = `ai-fly — peer-to-peer HTTP/WebSocket bridge with AI-ready presets (OpenDWeb fabric)
9
+
10
+ Provider:
11
+ ai-fly serve [--data <dir>] [--relay <url>]... run the provider daemon
12
+ ai-fly service add|list|remove ... manage services
13
+ ai-fly group add|set-services|list ... manage groups
14
+ ai-fly key issue|list|revoke --group <name> manage group keys
15
+ ai-fly share --group <name> [--ttl <dur>] mint a share link (token + key)
16
+ ai-fly revoke <endpointId> eject a device (fabric-level)
17
+ ai-fly status [provider|consumer] [--verbose] snapshot (auto: both if present)
18
+
19
+ Consumer:
20
+ ai-fly join <dweb1-token> [--data <dir>] admit this device (fabric layer)
21
+ ai-fly key add <sk-aifly-key> --provider <id> bare key into an existing ring
22
+ ai-fly import <aifly1-link> [--run] [--preview] bundle link: join + keyring
23
+ ai-fly run [--data <dir>] [--strict-ports] run the local gateway
24
+ ai-fly ports [--data <dir>] [<serviceId> --port <n>] view/override mapped ports
25
+ ai-fly status [provider|consumer] [--verbose] snapshot (auto: both if present)
26
+ ai-fly forget <endpointId|8-char-prefix> drop an imported provider
27
+
28
+ Options:
29
+ --data <dir> storage location (tilde expanded; role-specific default)
30
+ --relay <url> relay entry URL (repeatable; flag > AIFLY_RELAY env > ~/.aifly/config.json)
31
+ --help, -h show this help`;
32
+ async function dispatch(command, rest) {
33
+ const providerDirExists = () => existsSync(join(homedir(), ".aifly", "provider"));
34
+ const consumersRootExists = () => {
35
+ try {
36
+ return existsSync(join(homedir(), ".aifly", "consumers"));
37
+ } catch {
38
+ return false;
39
+ }
40
+ };
41
+ const lazy = (load) => {
42
+ return (argv, ctx) => load().then((m) => m.run([...argv], ctx));
43
+ };
44
+ return match(command).with("serve", () => lazy(() => import("./serve-R03mDBwd.mjs"))(rest)).with("service", () => lazy(() => import("./service-Cd4R47n4.mjs"))(rest)).with("group", () => lazy(() => import("./group-Csy9VmFb.mjs"))(rest)).with("share", () => lazy(() => import("./share-DGM2TOft.mjs"))(rest)).with("revoke", () => lazy(() => import("./revoke-CyrOzc8t.mjs"))(rest)).with("join", () => lazy(() => import("./join-E1_-Rt-7.mjs"))(rest)).with("import", () => lazy(() => import("./import-BVAFw5Tx.mjs"))(rest)).with("run", () => lazy(() => import("./run-Be8H-IRf.mjs"))(rest)).with("ports", () => lazy(() => import("./ports-BQukx7wS.mjs"))(rest)).with("forget", () => lazy(() => import("./forget-BEbNwPip.mjs"))(rest)).with("key", () => {
45
+ if (rest[0] === "add") return lazy(() => import("./key-Bt4EWiuO.mjs"))(rest);
46
+ return lazy(() => import("./key-CK77IYXp.mjs"))(rest);
47
+ }).with("status", async () => {
48
+ const [scope, ...tail] = rest;
49
+ const wantProvider = scope === "provider" || scope !== "consumer" && providerDirExists();
50
+ const wantConsumer = scope === "consumer" || scope !== "provider" && consumersRootExists();
51
+ const argv = scope === "provider" || scope === "consumer" ? tail : rest;
52
+ let code = 0;
53
+ if (wantProvider) code = Math.max(code, await lazy(() => import("./status-W2OS7WB6.mjs"))(argv));
54
+ if (wantConsumer) code = Math.max(code, await lazy(() => import("./status-DRaKNvqz.mjs"))(argv));
55
+ if (!wantProvider && !wantConsumer) process.stdout.write("nothing to report: no provider storage and no imported consumers\n");
56
+ return code;
57
+ }).otherwise(() => {
58
+ throw new UsageError(`error: unknown command '${command}' (see ai-fly --help)`);
59
+ });
60
+ }
61
+ async function main(argv) {
62
+ const first = argv[0];
63
+ if (first === void 0 || first === "--help" || first === "-h") {
64
+ process.stdout.write(`${USAGE}\n`);
65
+ return first === void 0 ? 2 : 0;
66
+ }
67
+ return dispatch(first, argv.slice(1));
68
+ }
69
+ try {
70
+ const code = await main(process.argv.slice(2));
71
+ process.exitCode = code;
72
+ } catch (err) {
73
+ process.exitCode = reportCliError(err);
74
+ }
75
+ //#endregion
76
+ export {};
@@ -0,0 +1,347 @@
1
+ import { t as CliError } from "./errors-BGtxPkM7.mjs";
2
+ import { i as randomZ32, n as resolveRelayUrls, t as loadConfig } from "./config-CO3WyW2H.mjs";
3
+ import { t as loadSdk, u as SERVICE_ENTRY_SCHEMA } from "./sdk-C6xDRbzg.mjs";
4
+ import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
5
+ import { homedir } from "node:os";
6
+ import { join } from "node:path";
7
+ import { z } from "zod";
8
+ //#region src/consumer/store.ts
9
+ const DIR_MODE = 448;
10
+ const FILE_MODE = 384;
11
+ const KEYRING_SCHEMA = z.object({
12
+ alias: z.string().min(1).max(256),
13
+ endpointId: z.string().min(8).max(128),
14
+ relayUrls: z.array(z.string().min(1).max(2048)),
15
+ keys: z.array(z.strictObject({
16
+ keyId: z.string().max(128),
17
+ key: z.string().min(8).max(256),
18
+ group: z.string().max(256)
19
+ })),
20
+ services: z.array(SERVICE_ENTRY_SCHEMA),
21
+ ports: z.record(z.string(), z.number().int().min(0).max(65535))
22
+ });
23
+ /** 消费侧数据根:默认 ~/.aifly/consumers;--data <dir> 时以 <dir> 为根(CLI 语义)。 */
24
+ function consumersRoot(dataDir, base = homedir()) {
25
+ if (dataDir !== void 0 && dataDir !== "") return dataDir;
26
+ return join(base, ".aifly", "consumers");
27
+ }
28
+ /** 提供者钥环目录:<root>/<endpointId 前 8 字符>。 */
29
+ function keyringDir(root, endpointId) {
30
+ return join(root, endpointId.slice(0, 8));
31
+ }
32
+ /** 该提供者的 Fabric dataDir(SDK 自管;本层只负责路径形状)。 */
33
+ function fabricDir(root, endpointId) {
34
+ return join(keyringDir(root, endpointId), "fabric");
35
+ }
36
+ /** 本机是否已有该提供者的 fabric 身份目录(import 老设备判定)。 */
37
+ function hasFabricIdentity(root, endpointId) {
38
+ return existsSync(fabricDir(root, endpointId));
39
+ }
40
+ function ensureDirMode(dir) {
41
+ mkdirSync(dir, {
42
+ recursive: true,
43
+ mode: DIR_MODE
44
+ });
45
+ try {
46
+ chmodSync(dir, DIR_MODE);
47
+ } catch {}
48
+ }
49
+ /** 原子写 keyring.json:同目录 tmp 落盘 → chmod 0600 → rename 覆盖。 */
50
+ function saveKeyring(root, ring) {
51
+ const dir = keyringDir(root, ring.endpointId);
52
+ ensureDirMode(root);
53
+ ensureDirMode(dir);
54
+ const finalPath = join(dir, "keyring.json");
55
+ const tmpPath = join(dir, `.keyring.json.tmp-${randomZ32(4)}`);
56
+ writeFileSync(tmpPath, `${JSON.stringify(ring, null, 2)}\n`, { mode: FILE_MODE });
57
+ try {
58
+ chmodSync(tmpPath, FILE_MODE);
59
+ } catch {}
60
+ renameSync(tmpPath, finalPath);
61
+ return finalPath;
62
+ }
63
+ function readRingAt(dir) {
64
+ const p = join(dir, "keyring.json");
65
+ if (!existsSync(p)) return void 0;
66
+ const raw = readFileSync(p, "utf8");
67
+ const parsed = JSON.parse(raw);
68
+ const result = KEYRING_SCHEMA.safeParse(parsed);
69
+ if (!result.success) throw new CliError(`error: keyring ${p} failed validation: ${result.error.message}`);
70
+ return result.data;
71
+ }
72
+ /** 严格加载单个钥环(按 endpointId 或 ≥8 字符前缀或别名定位);不存在返回 undefined。 */
73
+ function loadKeyring(root, ref) {
74
+ const found = findKeyringDir(root, ref);
75
+ if (found === void 0) return void 0;
76
+ return readRingAt(found);
77
+ }
78
+ /** 列出全部钥环;无 keyring.json 的目录静默跳过,损坏/非法的计入 warnings。 */
79
+ function listKeyrings(root) {
80
+ const rings = [];
81
+ const warnings = [];
82
+ if (!existsSync(root)) return {
83
+ rings,
84
+ warnings
85
+ };
86
+ for (const ent of readdirSync(root, { withFileTypes: true })) {
87
+ if (!ent.isDirectory() || ent.name.startsWith(".")) continue;
88
+ let ring;
89
+ try {
90
+ ring = readRingAt(join(root, ent.name));
91
+ } catch (err) {
92
+ warnings.push(err.message);
93
+ continue;
94
+ }
95
+ if (ring === void 0) continue;
96
+ rings.push(ring);
97
+ }
98
+ rings.sort((a, b) => a.alias.localeCompare(b.alias));
99
+ return {
100
+ rings,
101
+ warnings
102
+ };
103
+ }
104
+ /**
105
+ * 定位钥环目录:完整 endpointId 精确匹配 → 目录名(endpointId 前 8 字符)前缀匹配
106
+ * (≥8 字符,歧义报错)→ 别名精确匹配。未命中返回 undefined。
107
+ */
108
+ function findKeyringDir(root, ref) {
109
+ if (!existsSync(root)) return void 0;
110
+ const dirs = readdirSync(root, { withFileTypes: true }).filter((e) => e.isDirectory() && !e.name.startsWith(".")).map((e) => e.name);
111
+ for (const name of dirs) {
112
+ const dir = join(root, name);
113
+ if (ref.startsWith(name)) try {
114
+ const ring = readRingAt(dir);
115
+ if (ring !== void 0 && ring.endpointId === ref) return dir;
116
+ } catch {}
117
+ }
118
+ if (ref.length >= 8) {
119
+ const hasRing = (name) => {
120
+ try {
121
+ return readRingAt(join(root, name)) !== void 0;
122
+ } catch {
123
+ return false;
124
+ }
125
+ };
126
+ const hits = dirs.filter((name) => name.length <= ref.length && ref.startsWith(name) && hasRing(name));
127
+ if (hits.length > 1) throw new CliError(`error: ambiguous provider prefix '${ref}' (matches: ${hits.join(", ")})`);
128
+ if (hits.length === 1) return join(root, hits[0]);
129
+ }
130
+ for (const name of dirs) try {
131
+ const ring = readRingAt(join(root, name));
132
+ if (ring !== void 0 && ring.alias === ref) return join(root, name);
133
+ } catch {}
134
+ }
135
+ /**
136
+ * 密钥入环(幂等):
137
+ * - keyId 非空:按 keyId 定位,命中则原位更新(key/group 覆盖);
138
+ * - keyId 空(裸密钥):按 key 原文定位,命中则仅在环内元数据为空时回填;
139
+ * - 未命中:追加。
140
+ */
141
+ function upsertKey(ring, entry) {
142
+ const keys = [...ring.keys];
143
+ let idx = -1;
144
+ if (entry.keyId !== "") idx = keys.findIndex((k) => k.keyId === entry.keyId);
145
+ if (idx < 0) idx = keys.findIndex((k) => k.key === entry.key);
146
+ if (idx >= 0) {
147
+ const prev = keys[idx];
148
+ keys[idx] = {
149
+ keyId: entry.keyId !== "" ? entry.keyId : prev.keyId,
150
+ key: entry.key,
151
+ group: entry.group !== "" ? entry.group : prev.group
152
+ };
153
+ return {
154
+ ring: {
155
+ ...ring,
156
+ keys
157
+ },
158
+ added: false
159
+ };
160
+ }
161
+ keys.push(entry);
162
+ return {
163
+ ring: {
164
+ ...ring,
165
+ keys
166
+ },
167
+ added: true
168
+ };
169
+ }
170
+ /**
171
+ * import 链接视图并入:alias/relayUrls 以链接为准(更新元数据),services 按
172
+ * serviceId 并入(链接版本胜出,保留环内其它分组服务),key 走 upsertKey。
173
+ */
174
+ function mergeImportView(base, payload, key) {
175
+ const ring = base ?? {
176
+ alias: payload.alias,
177
+ endpointId: payload.endpointId,
178
+ relayUrls: [],
179
+ keys: [],
180
+ services: [],
181
+ ports: {}
182
+ };
183
+ const merged = new Map(ring.services.map((s) => [s.serviceId, s]));
184
+ for (const s of payload.services) merged.set(s.serviceId, s);
185
+ const { ring: withKey, added } = upsertKey({
186
+ ...ring,
187
+ alias: payload.alias,
188
+ relayUrls: [...payload.relayUrls],
189
+ services: [...merged.values()]
190
+ }, key);
191
+ return {
192
+ ring: withKey,
193
+ keyAdded: added
194
+ };
195
+ }
196
+ /**
197
+ * 目录全量替换(AUTH_OK 初次/refresh 同构):services、relayUrls 整体替换,
198
+ * alias 可选更新;ports 修剪到存活服务(被删服务的端口记录一并移除)。
199
+ */
200
+ function applyCatalog(ring, patch) {
201
+ const alive = new Set(patch.services.map((s) => s.serviceId));
202
+ const ports = {};
203
+ for (const [serviceId, port] of Object.entries(ring.ports)) if (alive.has(serviceId)) ports[serviceId] = port;
204
+ const next = {
205
+ ...ring,
206
+ relayUrls: [...patch.relayUrls],
207
+ services: [...patch.services],
208
+ ports
209
+ };
210
+ if (patch.alias !== void 0 && patch.alias !== "") next.alias = patch.alias;
211
+ return next;
212
+ }
213
+ /** AUTH_OK 回填:groups[].{keyId,group} 对位到环内密钥(含裸密钥占位回填)。 */
214
+ function reconcileKeyMetadata(ring, groups) {
215
+ const known = new Set(ring.keys.filter((k) => k.keyId !== "").map((k) => k.keyId));
216
+ const unmatched = groups.filter((g) => !known.has(g.keyId));
217
+ const empties = ring.keys.filter((k) => k.keyId === "");
218
+ if (unmatched.length === 0 || unmatched.length !== empties.length) return ring;
219
+ let i = 0;
220
+ const keys = ring.keys.map((k) => {
221
+ if (k.keyId !== "") return k;
222
+ const g = unmatched[i++];
223
+ return {
224
+ keyId: g.keyId,
225
+ key: k.key,
226
+ group: g.group
227
+ };
228
+ });
229
+ return {
230
+ ...ring,
231
+ keys
232
+ };
233
+ }
234
+ /** 记录服务端口偏好(ports 命令写路径)。 */
235
+ function setPort(root, endpointId, serviceId, port) {
236
+ const existing = loadKeyring(root, endpointId);
237
+ if (existing === void 0) throw new CliError(`error: provider '${endpointId}' not found`);
238
+ if (!existing.services.some((s) => s.serviceId === serviceId)) throw new CliError(`error: unknown service '${serviceId}' for provider '${existing.alias}'`);
239
+ const next = {
240
+ ...existing,
241
+ ports: {
242
+ ...existing.ports,
243
+ [serviceId]: port
244
+ }
245
+ };
246
+ saveKeyring(root, next);
247
+ return next;
248
+ }
249
+ /** forget:整环删除(钥环 + fabric 身份目录;提供方侧撤销需提供方操作)。 */
250
+ function removeKeyring(root, ref) {
251
+ const dir = findKeyringDir(root, ref);
252
+ if (dir === void 0) throw new CliError(`error: provider '${ref}' not found`);
253
+ const stat = statSync(dir, { throwIfNoEntry: false });
254
+ if (stat === void 0 || !stat.isDirectory()) throw new CliError(`error: provider '${ref}' not found`);
255
+ const ring = readRingAt(dir);
256
+ rmSync(dir, {
257
+ recursive: true,
258
+ force: true
259
+ });
260
+ if (ring !== void 0) return {
261
+ dir,
262
+ ring
263
+ };
264
+ return {
265
+ dir,
266
+ ring: {
267
+ alias: ref,
268
+ endpointId: ref,
269
+ relayUrls: [],
270
+ keys: [],
271
+ services: [],
272
+ ports: {}
273
+ }
274
+ };
275
+ }
276
+ //#endregion
277
+ //#region src/cli/commands/consumer/common.ts
278
+ function ctxOut(ctx) {
279
+ return ctx.out ?? ((line) => process.stdout.write(`${line}\n`));
280
+ }
281
+ function ctxHomedir(ctx) {
282
+ return ctx.homedir ?? homedir();
283
+ }
284
+ /**
285
+ * 真实 Fabric 工厂(CLI 层唯一触 SDK 的位置):动态 import 规避 vitest worker 与
286
+ * 原生模块的不兼容;relay 配置映射为 SDK FabricOptions.relay(custom urls),
287
+ * 未配置时缺省走 SDK 默认(n0)。
288
+ * 解析层级(Owner 裁决 2026-09-13:链接带来的会合点优先于环境/配置默认——
289
+ * provider 在那个 relay 上,不拨它就永远会不了面):逐次解析为
290
+ * flag > ring/链接内嵌 relay(opts.relayUrls,调用侧按 ring 传入)> env > file。
291
+ * @param linkRelayUrls aifly1. 链接内嵌 relay(import 的 join 阶段用;空 = 缺席)
292
+ */
293
+ async function createSdkFabricFactory(relayFlag, ctx = {}, linkRelayUrls) {
294
+ const sdk = await loadSdk();
295
+ const toOpts = (opts) => {
296
+ const fabricOpts = { dataDir: opts.dataDir };
297
+ const link = opts.relayUrls ?? linkRelayUrls;
298
+ const urls = resolveRelayUrls({
299
+ ...relayFlag !== void 0 ? { flag: relayFlag } : {},
300
+ ...link !== void 0 ? { link } : {},
301
+ env: process.env.AIFLY_RELAY,
302
+ file: loadConfig(ctxHomedir(ctx))
303
+ });
304
+ if (urls !== void 0 && urls.length > 0) fabricOpts.relay = {
305
+ mode: "custom",
306
+ urls
307
+ };
308
+ return fabricOpts;
309
+ };
310
+ return {
311
+ open: async (opts) => await sdk.Fabric.open(toOpts(opts)),
312
+ joinWithToken: async (opts, token) => await sdk.Fabric.joinWithToken(toOpts(opts), token)
313
+ };
314
+ }
315
+ /** 等待 SIGINT/SIGTERM(run / import --run 长驻阻塞;测试不触达)。 */
316
+ function waitForSignals() {
317
+ return new Promise((resolve) => {
318
+ let done = false;
319
+ const finish = () => {
320
+ if (done) return;
321
+ done = true;
322
+ process.off("SIGINT", finish);
323
+ process.off("SIGTERM", finish);
324
+ resolve();
325
+ };
326
+ process.on("SIGINT", finish);
327
+ process.on("SIGTERM", finish);
328
+ });
329
+ }
330
+ /** 打印引擎监听清单(run 启动横幅与 status 共用)。 */
331
+ function printListeners(out, info) {
332
+ if (info.length === 0) {
333
+ out("local endpoints: (none - no authorized services yet)");
334
+ return;
335
+ }
336
+ out("local endpoints:");
337
+ for (const l of info) {
338
+ const note = l.autoAssigned ? ` [NOTICE: requested ${l.requested} was unavailable]` : "";
339
+ out(` 127.0.0.1:${l.port} <- ${l.alias} / ${l.name} [${l.serviceId}]${note}`);
340
+ }
341
+ }
342
+ /** 引擎所需钥环集合加载(run/import --run/status 共用)。 */
343
+ function ringsForRun(rings) {
344
+ return rings.filter((r) => r.keys.length > 0 || r.services.length > 0);
345
+ }
346
+ //#endregion
347
+ export { removeKeyring as _, ringsForRun as a, upsertKey as b, consumersRoot as c, hasFabricIdentity as d, keyringDir as f, reconcileKeyMetadata as g, mergeImportView as h, printListeners as i, fabricDir as l, loadKeyring as m, ctxHomedir as n, waitForSignals as o, listKeyrings as p, ctxOut as r, applyCatalog as s, createSdkFabricFactory as t, findKeyringDir as u, saveKeyring as v, setPort as y };