@nexstone/rift-cli 0.1.2 → 0.1.3
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/dist/lib/python-bridge.d.ts +22 -2
- package/dist/lib/python-bridge.js +141 -57
- package/package.json +1 -1
|
@@ -1,15 +1,35 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Bridge between the TypeScript CLI and the Python engine.
|
|
3
|
-
*
|
|
3
|
+
*
|
|
4
|
+
* The CLI ships under three install paths:
|
|
5
|
+
*
|
|
6
|
+
* 1. Source clone — `engine/` directory exists above the CLI binary.
|
|
7
|
+
* We invoke `python -m rift.cli` with PYTHONPATH set to `engine/src`.
|
|
8
|
+
* Used during development.
|
|
9
|
+
*
|
|
10
|
+
* 2. Installed wheel — user did `pip install rift-engine-core`. The
|
|
11
|
+
* wheel ships a `rift-engine` console script as its entry point.
|
|
12
|
+
* We invoke that binary directly. Used for end-user pip/brew installs.
|
|
13
|
+
*
|
|
14
|
+
* 3. Env override — `RIFT_ENGINE_BINARY=/path/to/rift-engine` forces us
|
|
15
|
+
* to use a specific binary. Used by the Homebrew formula to point at
|
|
16
|
+
* the libexec venv's rift-engine even if PATH is weird.
|
|
17
|
+
*
|
|
18
|
+
* Detection runs in priority order: env override → source → installed.
|
|
19
|
+
* Each spawned process inherits the same environment + extraEnv overlay.
|
|
4
20
|
*/
|
|
21
|
+
import { type ChildProcess } from 'node:child_process';
|
|
5
22
|
export interface EngineMessage {
|
|
6
23
|
type: 'progress' | 'result' | 'error' | 'status' | 'trade' | 'candle' | 'heartbeat' | 'shutdown' | 'step' | 'step_done' | 'soak';
|
|
7
24
|
[key: string]: unknown;
|
|
8
25
|
}
|
|
9
26
|
export declare function getDataDir(): string;
|
|
27
|
+
/** Public helper — install-mode-aware strategies dir, for commands that
|
|
28
|
+
* scaffold or list strategies. */
|
|
29
|
+
export declare function resolveStrategiesDir(): string;
|
|
10
30
|
export declare function runEngine(command: string, args: string[], onMessage: (msg: EngineMessage) => void, extraEnv?: Record<string, string>): Promise<void>;
|
|
11
31
|
/** Get the child process from a runEngine promise (for sending signals) */
|
|
12
|
-
export declare function getEngineProcess(promise: Promise<void>):
|
|
32
|
+
export declare function getEngineProcess(promise: Promise<void>): ChildProcess | null;
|
|
13
33
|
/**
|
|
14
34
|
* Spawn the Python engine as a detached daemon process.
|
|
15
35
|
* Returns immediately after the process is started.
|
|
@@ -1,84 +1,179 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Bridge between the TypeScript CLI and the Python engine.
|
|
3
|
-
*
|
|
3
|
+
*
|
|
4
|
+
* The CLI ships under three install paths:
|
|
5
|
+
*
|
|
6
|
+
* 1. Source clone — `engine/` directory exists above the CLI binary.
|
|
7
|
+
* We invoke `python -m rift.cli` with PYTHONPATH set to `engine/src`.
|
|
8
|
+
* Used during development.
|
|
9
|
+
*
|
|
10
|
+
* 2. Installed wheel — user did `pip install rift-engine-core`. The
|
|
11
|
+
* wheel ships a `rift-engine` console script as its entry point.
|
|
12
|
+
* We invoke that binary directly. Used for end-user pip/brew installs.
|
|
13
|
+
*
|
|
14
|
+
* 3. Env override — `RIFT_ENGINE_BINARY=/path/to/rift-engine` forces us
|
|
15
|
+
* to use a specific binary. Used by the Homebrew formula to point at
|
|
16
|
+
* the libexec venv's rift-engine even if PATH is weird.
|
|
17
|
+
*
|
|
18
|
+
* Detection runs in priority order: env override → source → installed.
|
|
19
|
+
* Each spawned process inherits the same environment + extraEnv overlay.
|
|
4
20
|
*/
|
|
5
21
|
import { spawn, execSync } from 'node:child_process';
|
|
6
22
|
import { createInterface } from 'node:readline';
|
|
7
23
|
import * as path from 'node:path';
|
|
8
24
|
import * as fs from 'node:fs';
|
|
25
|
+
import * as os from 'node:os';
|
|
9
26
|
import { fileURLToPath } from 'node:url';
|
|
10
27
|
const __filename = fileURLToPath(import.meta.url);
|
|
11
28
|
const __dirname = path.dirname(__filename);
|
|
12
|
-
|
|
29
|
+
/** Walk up looking for `engine/pyproject.toml` + `engine/src/rift/cli.py`.
|
|
30
|
+
* Only present in a source clone, not in any installed layout. */
|
|
31
|
+
function findSourceEngineDir() {
|
|
13
32
|
let dir = path.resolve(__dirname);
|
|
14
33
|
for (let i = 0; i < 10; i++) {
|
|
15
34
|
const engineDir = path.join(dir, 'engine');
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
35
|
+
// Both probes required to avoid matching `packages/engine/` (the
|
|
36
|
+
// rift_engine library subpackage) before reaching the real `engine/`
|
|
37
|
+
// host that owns `rift.cli`.
|
|
19
38
|
if (fs.existsSync(path.join(engineDir, 'pyproject.toml')) &&
|
|
20
39
|
fs.existsSync(path.join(engineDir, 'src', 'rift', 'cli.py')))
|
|
21
40
|
return engineDir;
|
|
22
|
-
|
|
41
|
+
const parent = path.dirname(dir);
|
|
42
|
+
if (parent === dir)
|
|
43
|
+
break;
|
|
44
|
+
dir = parent;
|
|
23
45
|
}
|
|
24
|
-
|
|
46
|
+
return null;
|
|
25
47
|
}
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
48
|
+
/** Locate a binary on PATH via `which`. Returns null if not found. */
|
|
49
|
+
function findOnPath(binary) {
|
|
50
|
+
try {
|
|
51
|
+
const out = execSync(`command -v ${binary}`, { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
52
|
+
return out || null;
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
function detectEngine() {
|
|
59
|
+
// 1. Env override
|
|
60
|
+
const envBinary = process.env.RIFT_ENGINE_BINARY;
|
|
61
|
+
if (envBinary && fs.existsSync(envBinary)) {
|
|
62
|
+
return { kind: 'installed', binary: envBinary };
|
|
63
|
+
}
|
|
64
|
+
// 2. Source clone
|
|
65
|
+
const sourceDir = findSourceEngineDir();
|
|
66
|
+
if (sourceDir) {
|
|
67
|
+
return { kind: 'source', engineDir: sourceDir };
|
|
68
|
+
}
|
|
69
|
+
// 3. Installed wheel — rift-engine on PATH
|
|
70
|
+
const binary = findOnPath('rift-engine');
|
|
71
|
+
if (binary) {
|
|
72
|
+
return { kind: 'installed', binary };
|
|
73
|
+
}
|
|
74
|
+
throw new Error('Cannot find RIFT engine.\n' +
|
|
75
|
+
'\n' +
|
|
76
|
+
'Install one of:\n' +
|
|
77
|
+
' brew install Nexstone/tap/rift # all-in-one, recommended\n' +
|
|
78
|
+
' pip install rift-engine-core # Python engine only\n' +
|
|
79
|
+
'\n' +
|
|
80
|
+
'Or set RIFT_ENGINE_BINARY=/path/to/rift-engine to use a specific install.');
|
|
81
|
+
}
|
|
82
|
+
/** Find Python for source mode. Prefers the engine's uv-managed venv. */
|
|
83
|
+
function findPythonForSource(engineDir) {
|
|
84
|
+
const engineVenv = path.join(engineDir, '.venv', 'bin', 'python3');
|
|
29
85
|
if (fs.existsSync(engineVenv))
|
|
30
86
|
return engineVenv;
|
|
31
|
-
// Check for managed venv in data dir
|
|
32
87
|
const dataVenv = path.join(getDataDir(), 'venv', 'bin', 'python3');
|
|
33
88
|
if (fs.existsSync(dataVenv))
|
|
34
89
|
return dataVenv;
|
|
35
|
-
// Fall back to system Python
|
|
36
90
|
for (const cmd of ['python3.14', 'python3.13', 'python3']) {
|
|
37
91
|
try {
|
|
38
92
|
execSync(`${cmd} --version`, { stdio: 'ignore' });
|
|
39
93
|
return cmd;
|
|
40
94
|
}
|
|
41
95
|
catch {
|
|
42
|
-
|
|
96
|
+
// try next
|
|
43
97
|
}
|
|
44
98
|
}
|
|
45
99
|
throw new Error('Python 3.13+ not found. Install Python or run: rift setup');
|
|
46
100
|
}
|
|
47
|
-
function getStrategiesDir() {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
101
|
+
function getStrategiesDir(engine) {
|
|
102
|
+
if (engine.kind === 'source') {
|
|
103
|
+
// Walk up from the CLI's location looking for a `strategies/` sibling
|
|
104
|
+
// to engine/. Falls back to <repo-root>/strategies.
|
|
105
|
+
let dir = path.resolve(__dirname);
|
|
106
|
+
for (let i = 0; i < 10; i++) {
|
|
107
|
+
const stratDir = path.join(dir, 'strategies');
|
|
108
|
+
if (fs.existsSync(stratDir))
|
|
109
|
+
return stratDir;
|
|
110
|
+
const parent = path.dirname(dir);
|
|
111
|
+
if (parent === dir)
|
|
112
|
+
break;
|
|
113
|
+
dir = parent;
|
|
114
|
+
}
|
|
115
|
+
return path.join(engine.engineDir, '..', 'strategies');
|
|
116
|
+
}
|
|
117
|
+
// Installed mode: strategies live under the user's data dir. We create
|
|
118
|
+
// it on demand so `rift new` and `rift algo` see the same path even
|
|
119
|
+
// if the user never explicitly initialized it.
|
|
120
|
+
const stratDir = path.join(getDataDir(), 'strategies');
|
|
121
|
+
if (!fs.existsSync(stratDir)) {
|
|
122
|
+
fs.mkdirSync(stratDir, { recursive: true });
|
|
54
123
|
}
|
|
55
|
-
|
|
56
|
-
return path.join(projectRoot, 'strategies');
|
|
124
|
+
return stratDir;
|
|
57
125
|
}
|
|
58
126
|
export function getDataDir() {
|
|
59
|
-
return path.join(process.env.HOME ||
|
|
127
|
+
return path.join(process.env.HOME || os.homedir(), '.rift');
|
|
60
128
|
}
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
129
|
+
/** Public helper — install-mode-aware strategies dir, for commands that
|
|
130
|
+
* scaffold or list strategies. */
|
|
131
|
+
export function resolveStrategiesDir() {
|
|
132
|
+
return getStrategiesDir(detectEngine());
|
|
133
|
+
}
|
|
134
|
+
const COMMANDS_WITH_STRATEGIES_DIR = new Set([
|
|
135
|
+
'backtest', 'strategies', 'compare', 'walk-forward',
|
|
136
|
+
'sweep', 'montecarlo', 'portfolio-backtest', 'research',
|
|
137
|
+
'quick-test', 'algo',
|
|
138
|
+
]);
|
|
139
|
+
function buildSpawnPlan(command, args, extraEnv, engine) {
|
|
140
|
+
const finalArgs = [];
|
|
141
|
+
let cmd;
|
|
142
|
+
let cwd;
|
|
143
|
+
let env;
|
|
144
|
+
if (engine.kind === 'source') {
|
|
145
|
+
const python = findPythonForSource(engine.engineDir);
|
|
146
|
+
cmd = python;
|
|
147
|
+
finalArgs.push('-m', 'rift.cli');
|
|
148
|
+
cwd = engine.engineDir;
|
|
149
|
+
env = {
|
|
150
|
+
...process.env,
|
|
151
|
+
...extraEnv,
|
|
152
|
+
PYTHONPATH: path.join(engine.engineDir, 'src'),
|
|
153
|
+
PYTHONUNBUFFERED: '1',
|
|
154
|
+
};
|
|
73
155
|
}
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
156
|
+
else {
|
|
157
|
+
cmd = engine.binary;
|
|
158
|
+
cwd = undefined; // inherit caller's cwd
|
|
159
|
+
env = {
|
|
77
160
|
...process.env,
|
|
78
161
|
...extraEnv,
|
|
79
|
-
PYTHONPATH: path.join(engineDir, 'src'),
|
|
80
162
|
PYTHONUNBUFFERED: '1',
|
|
81
|
-
}
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
finalArgs.push(command, ...args);
|
|
166
|
+
if (COMMANDS_WITH_STRATEGIES_DIR.has(command)) {
|
|
167
|
+
finalArgs.push('--strategies-dir', getStrategiesDir(engine));
|
|
168
|
+
}
|
|
169
|
+
return { cmd, args: finalArgs, env, cwd };
|
|
170
|
+
}
|
|
171
|
+
export async function runEngine(command, args, onMessage, extraEnv) {
|
|
172
|
+
const engine = detectEngine();
|
|
173
|
+
const plan = buildSpawnPlan(command, args, extraEnv, engine);
|
|
174
|
+
const proc = spawn(plan.cmd, plan.args, {
|
|
175
|
+
cwd: plan.cwd,
|
|
176
|
+
env: plan.env,
|
|
82
177
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
83
178
|
});
|
|
84
179
|
const promise = new Promise((resolve, reject) => {
|
|
@@ -135,7 +230,7 @@ export async function runEngine(command, args, onMessage, extraEnv) {
|
|
|
135
230
|
}
|
|
136
231
|
/** Get the child process from a runEngine promise (for sending signals) */
|
|
137
232
|
export function getEngineProcess(promise) {
|
|
138
|
-
return promise
|
|
233
|
+
return promise._proc ?? null;
|
|
139
234
|
}
|
|
140
235
|
/**
|
|
141
236
|
* Spawn the Python engine as a detached daemon process.
|
|
@@ -143,24 +238,13 @@ export function getEngineProcess(promise) {
|
|
|
143
238
|
* The daemon writes its own PID file and log file.
|
|
144
239
|
*/
|
|
145
240
|
export function spawnDaemon(command, args, extraEnv) {
|
|
146
|
-
const
|
|
147
|
-
const
|
|
148
|
-
const fullArgs = [
|
|
149
|
-
'-m', 'rift.cli',
|
|
150
|
-
command,
|
|
151
|
-
...args,
|
|
152
|
-
'--daemon',
|
|
153
|
-
];
|
|
241
|
+
const engine = detectEngine();
|
|
242
|
+
const plan = buildSpawnPlan(command, [...args, '--daemon'], extraEnv, engine);
|
|
154
243
|
// Open /dev/null for stdio — daemon writes to its own log file
|
|
155
244
|
const devNull = fs.openSync('/dev/null', 'r+');
|
|
156
|
-
const proc = spawn(
|
|
157
|
-
cwd:
|
|
158
|
-
env:
|
|
159
|
-
...process.env,
|
|
160
|
-
...extraEnv,
|
|
161
|
-
PYTHONPATH: path.join(engineDir, 'src'),
|
|
162
|
-
PYTHONUNBUFFERED: '1',
|
|
163
|
-
},
|
|
245
|
+
const proc = spawn(plan.cmd, plan.args, {
|
|
246
|
+
cwd: plan.cwd,
|
|
247
|
+
env: plan.env,
|
|
164
248
|
stdio: [devNull, devNull, devNull],
|
|
165
249
|
detached: true,
|
|
166
250
|
});
|