@reefclaw/connect 0.1.26 → 0.1.28
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/assets/bridge/bridge.d.ts +10 -0
- package/assets/bridge/bridge.js +142 -22
- package/assets/bridge/gateway/gateway-ws-client.d.ts +7 -1
- package/assets/bridge/gateway/gateway-ws-client.js +31 -8
- package/assets/bridge/gateway/tool-discovery.d.ts +1 -1
- package/assets/bridge/gateway/tool-discovery.js +11 -0
- package/assets/bridge/provider.d.ts +73 -6
- package/assets/bridge/providers/gateway.d.ts +12 -3
- package/assets/bridge/providers/gateway.js +18 -5
- package/assets/bridge/providers/onboarding-commands.d.ts +33 -7
- package/assets/bridge/providers/onboarding-commands.js +109 -13
- package/assets/bridge/types.d.ts +1 -1
- package/assets/bridge/types.js +5 -0
- package/assets/plugin/ccxt/binance-private.js +6 -0
- package/assets/plugin/config/tool-gate.js +4 -0
- package/assets/plugin/index.js +77 -12
- package/assets/plugin/ingest/position-auto-capture.js +19 -0
- package/assets/plugin/ingest/readiness-reporter.js +25 -1
- package/assets/plugin/openclaw.plugin.json +3 -1
- package/assets/plugin/security/sealed-credentials.d.ts +38 -0
- package/assets/plugin/security/sealed-credentials.js +180 -0
- package/assets/plugin/tools/clear-exchange-credentials.js +16 -3
- package/assets/plugin/tools/hl-agent-wallet-status.d.ts +29 -0
- package/assets/plugin/tools/hl-agent-wallet-status.js +100 -0
- package/assets/plugin/tools/hl-provision-agent-wallet.d.ts +35 -0
- package/assets/plugin/tools/hl-provision-agent-wallet.js +144 -0
- package/assets/plugin/tools/set-exchange-credentials.d.ts +32 -3
- package/assets/plugin/tools/set-exchange-credentials.js +144 -32
- package/assets/plugin/tools/set-trading-mode.d.ts +7 -0
- package/assets/plugin/tools/set-trading-mode.js +15 -0
- package/assets/plugin/tools/test-exchange-credentials.d.ts +30 -3
- package/assets/plugin/tools/test-exchange-credentials.js +173 -35
- package/assets/plugin/types.d.ts +10 -11
- package/assets/plugin/venues/hyperliquid/hl-agent-wallet.d.ts +29 -0
- package/assets/plugin/venues/hyperliquid/hl-agent-wallet.js +118 -0
- package/assets/plugin/venues/hyperliquid/hl-preflight.d.ts +28 -0
- package/assets/plugin/venues/hyperliquid/hl-preflight.js +116 -0
- package/package.json +32 -32
- package/dist/daemon.js +0 -104
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// Hyperliquid agent-wallet helpers — format validation + address derivation.
|
|
2
|
+
//
|
|
3
|
+
// Why derivation exists at all: the single most catastrophic mistake a
|
|
4
|
+
// non-technical trader can make in the HL onboarding form is pasting their
|
|
5
|
+
// MASTER wallet's private key where the AGENT key belongs (the master key can
|
|
6
|
+
// withdraw everything; the agent key can only trade). The form cannot tell the
|
|
7
|
+
// two apart — both are 32-byte hex — but the box can: derive the address from
|
|
8
|
+
// the pasted key and refuse when it equals the master walletAddress. The same
|
|
9
|
+
// derived address also lets test_exchange_credentials verify the agent is
|
|
10
|
+
// actually APPROVED for the master account (POST /info extraAgents) before the
|
|
11
|
+
// user goes live.
|
|
12
|
+
//
|
|
13
|
+
// Derivation = secp256k1 pubkey (node:crypto createECDH — stable Node API) +
|
|
14
|
+
// keccak-256 of the 64-byte public key, last 20 bytes (the Ethereum address
|
|
15
|
+
// rule). Node has no keccak (its sha3-256 is NIST-padded, a different hash),
|
|
16
|
+
// so keccak_256 comes from ccxt's vendored noble-hashes — ccxt is pinned at
|
|
17
|
+
// 4.5.37 and the deep path is guarded by a known-vector unit test
|
|
18
|
+
// (hl-agent-wallet.test.ts), so a ccxt bump that moves the file fails loudly.
|
|
19
|
+
// If the import ever breaks at runtime we degrade gracefully: derivation
|
|
20
|
+
// reports 'unavailable' and callers skip the address checks rather than
|
|
21
|
+
// blocking credential entry.
|
|
22
|
+
//
|
|
23
|
+
// SECURITY: this module handles the raw agent private key in memory only —
|
|
24
|
+
// it never logs it, never persists it, and never puts it in a return value.
|
|
25
|
+
import { createECDH, randomBytes } from 'node:crypto';
|
|
26
|
+
import { createRequire } from 'node:module';
|
|
27
|
+
import { pathToFileURL } from 'node:url';
|
|
28
|
+
import path from 'node:path';
|
|
29
|
+
/** secp256k1 group order n — a private key must be in [1, n-1]. */
|
|
30
|
+
const SECP256K1_ORDER = BigInt('0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141');
|
|
31
|
+
/** Generate a fresh secp256k1 private key (0x-prefixed, 64 hex chars) from
|
|
32
|
+
* CSPRNG bytes, rejection-sampled into the valid scalar range. This is how
|
|
33
|
+
* the box mints its own HL agent wallet — the key is BORN here and never
|
|
34
|
+
* leaves the machine (the caller persists it to plugin-config and surfaces
|
|
35
|
+
* only the derived PUBLIC address). */
|
|
36
|
+
export function generateAgentPrivateKey() {
|
|
37
|
+
// Rejection probability per draw is ~2^-128 (order ≈ 2^256) — the loop is
|
|
38
|
+
// for correctness, not because retries are expected.
|
|
39
|
+
for (;;) {
|
|
40
|
+
const candidate = randomBytes(32);
|
|
41
|
+
const scalar = BigInt(`0x${candidate.toString('hex')}`);
|
|
42
|
+
if (scalar > 0n && scalar < SECP256K1_ORDER) {
|
|
43
|
+
return `0x${candidate.toString('hex')}`;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/** Master/agent wallet address: 0x + 40 hex chars. */
|
|
48
|
+
export function isHexAddress(value) {
|
|
49
|
+
return typeof value === 'string' && /^0x[0-9a-fA-F]{40}$/.test(value.trim());
|
|
50
|
+
}
|
|
51
|
+
/** Agent private key: 32 bytes of hex, 0x prefix optional (the HL app shows it
|
|
52
|
+
* with the prefix; some wallets export without). */
|
|
53
|
+
export function isHexPrivateKey(value) {
|
|
54
|
+
return typeof value === 'string' && /^(0x)?[0-9a-fA-F]{64}$/.test(value.trim());
|
|
55
|
+
}
|
|
56
|
+
/** Canonical stored form: trimmed, 0x-prefixed, original casing preserved. */
|
|
57
|
+
export function normalizeHexPrivateKey(value) {
|
|
58
|
+
const trimmed = value.trim();
|
|
59
|
+
return trimmed.startsWith('0x') || trimmed.startsWith('0X') ? `0x${trimmed.slice(2)}` : `0x${trimmed}`;
|
|
60
|
+
}
|
|
61
|
+
let keccakPromise = null;
|
|
62
|
+
/** Load keccak_256 from ccxt's vendored noble-hashes. The package's `exports`
|
|
63
|
+
* map only exposes ".", so we resolve the package root from the main entry and
|
|
64
|
+
* import the file by path (file URLs bypass the exports map). Returns null on
|
|
65
|
+
* any failure — derivation then reports 'unavailable'. */
|
|
66
|
+
function loadKeccak() {
|
|
67
|
+
if (!keccakPromise) {
|
|
68
|
+
keccakPromise = (async () => {
|
|
69
|
+
try {
|
|
70
|
+
const req = createRequire(import.meta.url);
|
|
71
|
+
const entry = req.resolve('ccxt');
|
|
72
|
+
const marker = `${path.sep}ccxt${path.sep}`;
|
|
73
|
+
const idx = entry.lastIndexOf(marker);
|
|
74
|
+
if (idx === -1)
|
|
75
|
+
return null;
|
|
76
|
+
const root = entry.slice(0, idx + marker.length - 1);
|
|
77
|
+
const sha3 = path.join(root, 'js', 'src', 'static_dependencies', 'noble-hashes', 'sha3.js');
|
|
78
|
+
const mod = (await import(pathToFileURL(sha3).href));
|
|
79
|
+
return typeof mod.keccak_256 === 'function' ? mod.keccak_256 : null;
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
})();
|
|
85
|
+
}
|
|
86
|
+
return keccakPromise;
|
|
87
|
+
}
|
|
88
|
+
/** Test seam: reset the memoized keccak loader (unit tests only). */
|
|
89
|
+
export function __resetKeccakCacheForTests() {
|
|
90
|
+
keccakPromise = null;
|
|
91
|
+
}
|
|
92
|
+
/** Derive the Ethereum address a private key controls (lowercase 0x…40 hex).
|
|
93
|
+
* 'invalid_key' = the key is malformed or outside the secp256k1 range;
|
|
94
|
+
* 'unavailable' = the keccak dependency could not be loaded (callers must
|
|
95
|
+
* fail OPEN — skip the check, never block credential entry on infra). */
|
|
96
|
+
export async function deriveAddressFromPrivateKey(privateKey) {
|
|
97
|
+
if (!isHexPrivateKey(privateKey))
|
|
98
|
+
return { ok: false, reason: 'invalid_key' };
|
|
99
|
+
const keccak = await loadKeccak();
|
|
100
|
+
if (!keccak)
|
|
101
|
+
return { ok: false, reason: 'unavailable' };
|
|
102
|
+
try {
|
|
103
|
+
const ecdh = createECDH('secp256k1');
|
|
104
|
+
ecdh.setPrivateKey(Buffer.from(normalizeHexPrivateKey(privateKey).slice(2), 'hex'));
|
|
105
|
+
const uncompressed = ecdh.getPublicKey(null, 'uncompressed'); // 0x04 || X || Y (65 bytes)
|
|
106
|
+
const digest = keccak(Uint8Array.from(uncompressed.subarray(1)));
|
|
107
|
+
const address = `0x${Buffer.from(digest.subarray(digest.length - 20)).toString('hex')}`;
|
|
108
|
+
return { ok: true, address };
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
// createECDH throws on out-of-range keys — malformed input, not infra.
|
|
112
|
+
return { ok: false, reason: 'invalid_key' };
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
/** Case-insensitive address equality (addresses may arrive checksummed). */
|
|
116
|
+
export function sameAddress(a, b) {
|
|
117
|
+
return a.trim().toLowerCase() === b.trim().toLowerCase();
|
|
118
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** Approvals expiring inside this window get a renewal warning — long enough
|
|
2
|
+
* to act on, short enough not to nag for months. */
|
|
3
|
+
export declare const HL_EXPIRY_WARN_MS: number;
|
|
4
|
+
export interface HlPreflightInput {
|
|
5
|
+
walletAddress: string;
|
|
6
|
+
/** Derived agent address to check approval for; null = skip the check
|
|
7
|
+
* (derivation unavailable). */
|
|
8
|
+
agentAddress: string | null;
|
|
9
|
+
testnet: boolean;
|
|
10
|
+
/** Injectable for tests; production uses global fetch. */
|
|
11
|
+
fetchImpl?: typeof fetch;
|
|
12
|
+
}
|
|
13
|
+
export interface HlPreflightOutcome {
|
|
14
|
+
/** clearinghouseState answered and parsed — the master account is queryable. */
|
|
15
|
+
reachable: boolean;
|
|
16
|
+
/** Set when !reachable — the raw error string for the caller's message. */
|
|
17
|
+
unreachableError?: string;
|
|
18
|
+
/** Perp equity + spot USDC; null when unreachable. */
|
|
19
|
+
balanceUSDC: number | null;
|
|
20
|
+
/** true/false = extraAgents answered definitively; null = could not check. */
|
|
21
|
+
agentApproved: boolean | null;
|
|
22
|
+
/** Approval expiry (epoch ms) when the exchange reports one. */
|
|
23
|
+
agentValidUntil: number | null;
|
|
24
|
+
warnings: string[];
|
|
25
|
+
}
|
|
26
|
+
export declare function hlApiBase(testnet: boolean): string;
|
|
27
|
+
export declare function hlInfo(fetchImpl: typeof fetch, testnet: boolean, body: Record<string, unknown>): Promise<unknown>;
|
|
28
|
+
export declare function hlPreflight(input: HlPreflightInput): Promise<HlPreflightOutcome>;
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// Hyperliquid credential preflight — the UNSIGNED verification core shared by
|
|
2
|
+
// test_exchange_credentials (candidate creds) and hl_agent_wallet_status
|
|
3
|
+
// (stored creds). Deliberately takes ADDRESSES ONLY — this module never sees a
|
|
4
|
+
// private key, so it stays outside the credential-holder set.
|
|
5
|
+
//
|
|
6
|
+
// Checks (all keyless POST /info reads — the master address is the query key):
|
|
7
|
+
// 1. clearinghouseState(master) → perp equity. Failure here = "unreachable";
|
|
8
|
+
// callers hard-fail with the error string.
|
|
9
|
+
// 2. spotClearinghouseState(master) → spot USDC (the unified account
|
|
10
|
+
// collateralizes perps from spot — verified live on testnet 2026-07-12).
|
|
11
|
+
// Cosmetic: failure is silently skipped, perp equity alone still answers.
|
|
12
|
+
// 3. extraAgents(master) → is `agentAddress` in the approved list, and when
|
|
13
|
+
// does the approval expire? Shape [{name,address,validUntil(ms)}] —
|
|
14
|
+
// verified vs Chainstack/QuickNode HL API references 2026-08-01 (the
|
|
15
|
+
// official gitbook omits this info type). A definitive miss is the
|
|
16
|
+
// caller's hard fail; an endpoint error degrades to a warning
|
|
17
|
+
// (fail-open on infra, closed on an explicit "not approved").
|
|
18
|
+
import { sameAddress } from './hl-agent-wallet.js';
|
|
19
|
+
/** Approvals expiring inside this window get a renewal warning — long enough
|
|
20
|
+
* to act on, short enough not to nag for months. */
|
|
21
|
+
export const HL_EXPIRY_WARN_MS = 14 * 24 * 60 * 60 * 1000;
|
|
22
|
+
export function hlApiBase(testnet) {
|
|
23
|
+
return testnet ? 'https://api.hyperliquid-testnet.xyz' : 'https://api.hyperliquid.xyz';
|
|
24
|
+
}
|
|
25
|
+
export async function hlInfo(fetchImpl, testnet, body) {
|
|
26
|
+
const res = await fetchImpl(`${hlApiBase(testnet)}/info`, {
|
|
27
|
+
method: 'POST',
|
|
28
|
+
headers: { 'content-type': 'application/json' },
|
|
29
|
+
body: JSON.stringify(body),
|
|
30
|
+
signal: AbortSignal.timeout(15_000),
|
|
31
|
+
});
|
|
32
|
+
if (!res.ok)
|
|
33
|
+
throw new Error(`POST /info ${String(body.type)} → ${res.status}`);
|
|
34
|
+
return res.json();
|
|
35
|
+
}
|
|
36
|
+
function msg(err) {
|
|
37
|
+
return err instanceof Error ? err.message : String(err);
|
|
38
|
+
}
|
|
39
|
+
export async function hlPreflight(input) {
|
|
40
|
+
const fetchImpl = input.fetchImpl ?? fetch;
|
|
41
|
+
const { walletAddress, testnet } = input;
|
|
42
|
+
const warnings = [];
|
|
43
|
+
// 1+2. Master account state.
|
|
44
|
+
let balanceUSDC = null;
|
|
45
|
+
try {
|
|
46
|
+
const state = (await hlInfo(fetchImpl, testnet, {
|
|
47
|
+
type: 'clearinghouseState',
|
|
48
|
+
user: walletAddress,
|
|
49
|
+
}));
|
|
50
|
+
const perpEquity = Number(state?.marginSummary?.accountValue);
|
|
51
|
+
let total = Number.isFinite(perpEquity) ? perpEquity : 0;
|
|
52
|
+
try {
|
|
53
|
+
const spot = (await hlInfo(fetchImpl, testnet, {
|
|
54
|
+
type: 'spotClearinghouseState',
|
|
55
|
+
user: walletAddress,
|
|
56
|
+
}));
|
|
57
|
+
for (const b of spot?.balances ?? []) {
|
|
58
|
+
if (String(b?.coin) === 'USDC') {
|
|
59
|
+
const t = Number(b?.total);
|
|
60
|
+
if (Number.isFinite(t))
|
|
61
|
+
total += t;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
// Spot read is cosmetic — perp equity alone is still a real answer.
|
|
67
|
+
}
|
|
68
|
+
balanceUSDC = total;
|
|
69
|
+
if (total <= 0) {
|
|
70
|
+
warnings.push('The master account holds no USDC yet — deposit/bridge USDC to Hyperliquid before going live.');
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
catch (e) {
|
|
74
|
+
return {
|
|
75
|
+
reachable: false,
|
|
76
|
+
unreachableError: msg(e),
|
|
77
|
+
balanceUSDC: null,
|
|
78
|
+
agentApproved: null,
|
|
79
|
+
agentValidUntil: null,
|
|
80
|
+
warnings,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
// 3. Agent approval.
|
|
84
|
+
let agentApproved = null;
|
|
85
|
+
let agentValidUntil = null;
|
|
86
|
+
if (input.agentAddress) {
|
|
87
|
+
try {
|
|
88
|
+
const agents = (await hlInfo(fetchImpl, testnet, {
|
|
89
|
+
type: 'extraAgents',
|
|
90
|
+
user: walletAddress,
|
|
91
|
+
}));
|
|
92
|
+
if (Array.isArray(agents)) {
|
|
93
|
+
const match = agents.find((a) => typeof a?.address === 'string' && sameAddress(a.address, input.agentAddress));
|
|
94
|
+
agentApproved = match != null;
|
|
95
|
+
if (match && Number.isFinite(Number(match.validUntil))) {
|
|
96
|
+
agentValidUntil = Number(match.validUntil);
|
|
97
|
+
const remaining = agentValidUntil - Date.now();
|
|
98
|
+
if (remaining <= 0) {
|
|
99
|
+
agentApproved = false;
|
|
100
|
+
warnings.push('The agent wallet approval has EXPIRED — re-approve it in the Hyperliquid API page.');
|
|
101
|
+
}
|
|
102
|
+
else if (remaining < HL_EXPIRY_WARN_MS) {
|
|
103
|
+
warnings.push(`The agent wallet approval expires in ${Math.ceil(remaining / (24 * 60 * 60 * 1000))} day(s) — renew it soon (approvals last at most 180 days).`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
warnings.push('Could not read the approved-agents list — approval not verified.');
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
warnings.push('Could not read the approved-agents list — approval not verified.');
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return { reachable: true, balanceUSDC, agentApproved, agentValidUntil, warnings };
|
|
116
|
+
}
|
package/package.json
CHANGED
|
@@ -1,32 +1,32 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@reefclaw/connect",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "One-command installer that connects your OpenClaw agent to ReefClaw (paper trading, no exchange keys).",
|
|
5
|
-
"type": "module",
|
|
6
|
-
"bin": {
|
|
7
|
-
"reefclaw-connect": "dist/cli.js"
|
|
8
|
-
},
|
|
9
|
-
"files": [
|
|
10
|
-
"dist",
|
|
11
|
-
"assets"
|
|
12
|
-
],
|
|
13
|
-
"engines": {
|
|
14
|
-
"node": ">=20"
|
|
15
|
-
},
|
|
16
|
-
"scripts": {
|
|
17
|
-
"bundle-assets": "node scripts/bundle-assets.mjs",
|
|
18
|
-
"build": "tsc && node scripts/bundle-assets.mjs",
|
|
19
|
-
"test": "vitest",
|
|
20
|
-
"test:run": "vitest run"
|
|
21
|
-
},
|
|
22
|
-
"dependencies": {
|
|
23
|
-
"json5": "2.2.3"
|
|
24
|
-
},
|
|
25
|
-
"devDependencies": {
|
|
26
|
-
"@types/node": "^20",
|
|
27
|
-
"typescript": "^5",
|
|
28
|
-
"vitest": "^4.0.18"
|
|
29
|
-
},
|
|
30
|
-
"license": "MIT",
|
|
31
|
-
"homepage": "https://reefclaw.com"
|
|
32
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "@reefclaw/connect",
|
|
3
|
+
"version": "0.1.28",
|
|
4
|
+
"description": "One-command installer that connects your OpenClaw agent to ReefClaw (paper trading, no exchange keys).",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"reefclaw-connect": "dist/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist",
|
|
11
|
+
"assets"
|
|
12
|
+
],
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": ">=20"
|
|
15
|
+
},
|
|
16
|
+
"scripts": {
|
|
17
|
+
"bundle-assets": "node scripts/bundle-assets.mjs",
|
|
18
|
+
"build": "tsc && node scripts/bundle-assets.mjs",
|
|
19
|
+
"test": "vitest",
|
|
20
|
+
"test:run": "vitest run"
|
|
21
|
+
},
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"json5": "2.2.3"
|
|
24
|
+
},
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"@types/node": "^20",
|
|
27
|
+
"typescript": "^5",
|
|
28
|
+
"vitest": "^4.0.18"
|
|
29
|
+
},
|
|
30
|
+
"license": "MIT",
|
|
31
|
+
"homepage": "https://reefclaw.com"
|
|
32
|
+
}
|
package/dist/daemon.js
DELETED
|
@@ -1,104 +0,0 @@
|
|
|
1
|
-
// Keep the bridge running across reboots. Linux/systemd-user is implemented
|
|
2
|
-
// fully; macOS and Windows fall back to printing the manual run command (a
|
|
3
|
-
// launchd/Task-Scheduler unit is a follow-up). The bridge reads its config from
|
|
4
|
-
// ~/.openclaw/openclaw.json, so until the user pastes their connect message the
|
|
5
|
-
// service will start, find no token, and restart — harmless; it connects within
|
|
6
|
-
// seconds of the agent writing the config.
|
|
7
|
-
import { writeFileSync, mkdirSync } from 'node:fs';
|
|
8
|
-
import { join } from 'node:path';
|
|
9
|
-
import { homedir, userInfo } from 'node:os';
|
|
10
|
-
import { BRIDGE_DIR } from './paths.js';
|
|
11
|
-
import { run, which } from './exec.js';
|
|
12
|
-
import { step, ok, info, warn } from './ui.js';
|
|
13
|
-
const SERVICE_NAME = 'reefclaw-bridge';
|
|
14
|
-
const NODE = process.execPath; // absolute path to the node running the installer
|
|
15
|
-
/**
|
|
16
|
-
* Build the systemd user-unit text. Pure + exported so the path-quoting is
|
|
17
|
-
* unit-testable. Both `node` (process.execPath) and `bridgeDir` (under the
|
|
18
|
-
* user's home) can contain spaces. Quoting rules differ per directive:
|
|
19
|
-
* - ExecStart= is parsed with shell-like word splitting, so an unquoted
|
|
20
|
-
* `ExecStart=/home/a b/node …` reads the binary as `/home/a` — QUOTE both
|
|
21
|
-
* the binary and the script path.
|
|
22
|
-
* - WorkingDirectory= takes the raw value after `=` as a single path (no word
|
|
23
|
-
* splitting) — spaces are safe UNQUOTED, and quotes are treated as literal
|
|
24
|
-
* characters, failing the unit with "path is not absolute" (verified live
|
|
25
|
-
* on systemd 255 / Ubuntu 24.04). Do NOT quote it.
|
|
26
|
-
*/
|
|
27
|
-
export function buildSystemdUnit(node, bridgeDir) {
|
|
28
|
-
const indexJs = join(bridgeDir, 'index.js');
|
|
29
|
-
return `[Unit]
|
|
30
|
-
Description=ReefClaw connector - bridges OpenClaw to the ReefClaw dashboard
|
|
31
|
-
After=network-online.target
|
|
32
|
-
Wants=network-online.target
|
|
33
|
-
|
|
34
|
-
[Service]
|
|
35
|
-
Type=simple
|
|
36
|
-
WorkingDirectory=${bridgeDir}
|
|
37
|
-
ExecStart="${node}" "${indexJs}" --provider gateway --log-level info
|
|
38
|
-
Restart=always
|
|
39
|
-
RestartSec=5s
|
|
40
|
-
|
|
41
|
-
[Install]
|
|
42
|
-
WantedBy=default.target
|
|
43
|
-
`;
|
|
44
|
-
}
|
|
45
|
-
function manualHint() {
|
|
46
|
-
warn('Could not set up an auto-start service on this OS yet.');
|
|
47
|
-
info('Keep the connector running with this command (leave it open / use your own service manager):');
|
|
48
|
-
info(` "${NODE}" "${join(BRIDGE_DIR, 'index.js')}" --provider gateway`);
|
|
49
|
-
}
|
|
50
|
-
function installSystemd() {
|
|
51
|
-
if (!which('systemctl'))
|
|
52
|
-
return false;
|
|
53
|
-
const unitDir = join(homedir(), '.config', 'systemd', 'user');
|
|
54
|
-
mkdirSync(unitDir, { recursive: true });
|
|
55
|
-
const unit = buildSystemdUnit(NODE, BRIDGE_DIR);
|
|
56
|
-
writeFileSync(join(unitDir, `${SERVICE_NAME}.service`), unit, 'utf-8');
|
|
57
|
-
run('systemctl', ['--user', 'daemon-reload']);
|
|
58
|
-
const enabled = run('systemctl', ['--user', 'enable', '--now', `${SERVICE_NAME}.service`]);
|
|
59
|
-
if (!enabled.ok) {
|
|
60
|
-
warn('systemd --user enable/start did not succeed:');
|
|
61
|
-
if (enabled.stderr.trim())
|
|
62
|
-
info(enabled.stderr.trim().split('\n').slice(-2).join('\n'));
|
|
63
|
-
info(`Try: systemctl --user enable --now ${SERVICE_NAME}.service`);
|
|
64
|
-
return false;
|
|
65
|
-
}
|
|
66
|
-
// `enable --now` can exit 0 while the unit failed to load (e.g. a bad unit
|
|
67
|
-
// file setting) — verify the unit actually came up before claiming ✓.
|
|
68
|
-
// 'active' = running; 'activating' = the expected pre-token restart loop
|
|
69
|
-
// (the bridge exits until the user pastes their connect message, and
|
|
70
|
-
// Restart=always re-launches it). Anything else (inactive/failed) means the
|
|
71
|
-
// unit never loaded.
|
|
72
|
-
const active = run('systemctl', ['--user', 'is-active', `${SERVICE_NAME}.service`]);
|
|
73
|
-
const state = active.stdout.trim();
|
|
74
|
-
if (state !== 'active' && state !== 'activating') {
|
|
75
|
-
warn(`the service did not come up (state: ${state || 'unknown'}).`);
|
|
76
|
-
info(`Inspect: systemctl --user status ${SERVICE_NAME}.service`);
|
|
77
|
-
return false;
|
|
78
|
-
}
|
|
79
|
-
// Linger lets the user service run without an active login session (servers).
|
|
80
|
-
// Best-effort: needs privileges; non-fatal if it fails.
|
|
81
|
-
const linger = run('loginctl', ['enable-linger', userInfo().username]);
|
|
82
|
-
if (linger.ok) {
|
|
83
|
-
info('enabled linger (service survives logout / reboot)');
|
|
84
|
-
}
|
|
85
|
-
else {
|
|
86
|
-
info('note: run `sudo loginctl enable-linger $USER` so the connector survives logout.');
|
|
87
|
-
}
|
|
88
|
-
return true;
|
|
89
|
-
}
|
|
90
|
-
export function installDaemon() {
|
|
91
|
-
step('Starting the connector as a background service');
|
|
92
|
-
if (process.platform === 'linux') {
|
|
93
|
-
if (installSystemd()) {
|
|
94
|
-
ok(`connector running as a systemd user service (${SERVICE_NAME})`);
|
|
95
|
-
info(`logs: journalctl --user -u ${SERVICE_NAME} -f`);
|
|
96
|
-
return true;
|
|
97
|
-
}
|
|
98
|
-
manualHint();
|
|
99
|
-
return false;
|
|
100
|
-
}
|
|
101
|
-
// macOS / Windows: manual for now (launchd / Task Scheduler unit is a follow-up).
|
|
102
|
-
manualHint();
|
|
103
|
-
return false;
|
|
104
|
-
}
|