ai-fly 0.0.0 → 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/README.md +105 -0
- package/dist/ai-fly.d.ts +1 -0
- package/dist/ai-fly.js +76 -0
- package/dist/common-4VeA_3wo.mjs +347 -0
- package/dist/common-BULbPtYn.mjs +1295 -0
- package/dist/config-CO3WyW2H.mjs +173 -0
- package/dist/errors-BGtxPkM7.mjs +27 -0
- package/dist/forget-BEbNwPip.mjs +22 -0
- package/dist/group-Csy9VmFb.mjs +92 -0
- package/dist/import-BVAFw5Tx.mjs +63 -0
- package/dist/join-B9op0juF.mjs +228 -0
- package/dist/join-E1_-Rt-7.mjs +32 -0
- package/dist/key-Bt4EWiuO.mjs +31 -0
- package/dist/key-CK77IYXp.mjs +67 -0
- package/dist/mux-xHl9Jrbi.mjs +630 -0
- package/dist/ports-BQukx7wS.mjs +46 -0
- package/dist/ports-pI59MtmI.mjs +68 -0
- package/dist/revoke-CyrOzc8t.mjs +34 -0
- package/dist/run-Be8H-IRf.mjs +42 -0
- package/dist/runtime-Dg4en1D8.mjs +1372 -0
- package/dist/sdk-C6xDRbzg.mjs +394 -0
- package/dist/serve-DuyQMeJh.mjs +1665 -0
- package/dist/serve-R03mDBwd.mjs +45 -0
- package/dist/service-Cd4R47n4.mjs +94 -0
- package/dist/share-DGM2TOft.mjs +133 -0
- package/dist/status-DRaKNvqz.mjs +102 -0
- package/dist/status-W2OS7WB6.mjs +57 -0
- package/package.json +64 -6
- package/index.js +0 -1
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).
|
package/dist/ai-fly.d.ts
ADDED
|
@@ -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 };
|