jungle-grid 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/dist/api.d.ts +208 -0
- package/dist/api.d.ts.map +1 -0
- package/dist/api.js +118 -0
- package/dist/api.js.map +1 -0
- package/dist/auth.d.ts +66 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/auth.js +151 -0
- package/dist/auth.js.map +1 -0
- package/dist/banner.d.ts +5 -0
- package/dist/banner.d.ts.map +1 -0
- package/dist/banner.js +11 -0
- package/dist/banner.js.map +1 -0
- package/dist/config.d.ts +30 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +114 -0
- package/dist/config.js.map +1 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1769 -0
- package/dist/index.js.map +1 -0
- package/dist/node-agent-installer.d.ts +56 -0
- package/dist/node-agent-installer.d.ts.map +1 -0
- package/dist/node-agent-installer.js +332 -0
- package/dist/node-agent-installer.js.map +1 -0
- package/dist/node-config.d.ts +40 -0
- package/dist/node-config.d.ts.map +1 -0
- package/dist/node-config.js +129 -0
- package/dist/node-config.js.map +1 -0
- package/dist/prompt.d.ts +4 -0
- package/dist/prompt.d.ts.map +1 -0
- package/dist/prompt.js +58 -0
- package/dist/prompt.js.map +1 -0
- package/dist/repl.d.ts +10 -0
- package/dist/repl.d.ts.map +1 -0
- package/dist/repl.js +200 -0
- package/dist/repl.js.map +1 -0
- package/dist/ui.d.ts +29 -0
- package/dist/ui.d.ts.map +1 -0
- package/dist/ui.js +165 -0
- package/dist/ui.js.map +1 -0
- package/dist/version.d.ts +22 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +83 -0
- package/dist/version.js.map +1 -0
- package/package.json +27 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1769 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
/**
|
|
4
|
+
* index.ts — Entry point for the Jungle Grid CLI (`jungle` command).
|
|
5
|
+
*
|
|
6
|
+
* Architecture role: Pillar 1 (Abstract GPU Selection) — primary user interface.
|
|
7
|
+
* This is the main user-facing surface of Jungle Grid. Every command must
|
|
8
|
+
* uphold the abstraction boundary: users declare WHAT they want to run
|
|
9
|
+
* (workload type, model size, optimization preference), never WHERE or HOW.
|
|
10
|
+
*
|
|
11
|
+
* PILLAR 1 INVARIANTS enforced here:
|
|
12
|
+
* - `jungle submit` accepts --workload and --model-size, NOT --gpu-type.
|
|
13
|
+
* - Job-oriented commands stay workload-first and scheduling-focused.
|
|
14
|
+
* - `jungle nodes` is a marketplace overview, while `jungle nodes show`
|
|
15
|
+
* intentionally exposes full node capability and pricing metadata.
|
|
16
|
+
* - The CLI never prints node API keys, shared secrets, or auth material.
|
|
17
|
+
*
|
|
18
|
+
* Pillar 2 (Intelligent Routing): The CLI does not route — it delegates to
|
|
19
|
+
* the orchestrator API, which runs the classifier and matcher server-side.
|
|
20
|
+
*
|
|
21
|
+
* Upstream: User's terminal.
|
|
22
|
+
* Downstream: Orchestrator REST API via api.ts; local credentials via auth.ts.
|
|
23
|
+
*/
|
|
24
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
25
|
+
if (k2 === undefined) k2 = k;
|
|
26
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
27
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
28
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
29
|
+
}
|
|
30
|
+
Object.defineProperty(o, k2, desc);
|
|
31
|
+
}) : (function(o, m, k, k2) {
|
|
32
|
+
if (k2 === undefined) k2 = k;
|
|
33
|
+
o[k2] = m[k];
|
|
34
|
+
}));
|
|
35
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
36
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
37
|
+
}) : function(o, v) {
|
|
38
|
+
o["default"] = v;
|
|
39
|
+
});
|
|
40
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
41
|
+
var ownKeys = function(o) {
|
|
42
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
43
|
+
var ar = [];
|
|
44
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
45
|
+
return ar;
|
|
46
|
+
};
|
|
47
|
+
return ownKeys(o);
|
|
48
|
+
};
|
|
49
|
+
return function (mod) {
|
|
50
|
+
if (mod && mod.__esModule) return mod;
|
|
51
|
+
var result = {};
|
|
52
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
53
|
+
__setModuleDefault(result, mod);
|
|
54
|
+
return result;
|
|
55
|
+
};
|
|
56
|
+
})();
|
|
57
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
58
|
+
const commander_1 = require("commander");
|
|
59
|
+
const banner_1 = require("./banner");
|
|
60
|
+
const repl_1 = require("./repl");
|
|
61
|
+
const api_1 = require("./api");
|
|
62
|
+
const auth_1 = require("./auth");
|
|
63
|
+
const config_1 = require("./config");
|
|
64
|
+
const node_config_1 = require("./node-config");
|
|
65
|
+
const node_agent_installer_1 = require("./node-agent-installer");
|
|
66
|
+
const prompt_1 = require("./prompt");
|
|
67
|
+
const version_1 = require("./version");
|
|
68
|
+
const ui_1 = require("./ui");
|
|
69
|
+
const fs = __importStar(require("fs"));
|
|
70
|
+
const os = __importStar(require("os"));
|
|
71
|
+
const path = __importStar(require("path"));
|
|
72
|
+
const child_process_1 = require("child_process");
|
|
73
|
+
const CLI_VERSION = (0, version_1.getCliVersion)();
|
|
74
|
+
/**
|
|
75
|
+
* Interval in milliseconds between token polling attempts during device login.
|
|
76
|
+
* 2 seconds is the standard device-flow cadence — fast enough to feel
|
|
77
|
+
* responsive, slow enough to not hammer the API.
|
|
78
|
+
*/
|
|
79
|
+
const LOGIN_POLL_INTERVAL_MS = 2000;
|
|
80
|
+
/**
|
|
81
|
+
* Maximum time in milliseconds to wait for the user to complete browser login.
|
|
82
|
+
* 5 minutes is generous — if the user walks away, the CLI stops polling
|
|
83
|
+
* and the server-side device_code expires independently.
|
|
84
|
+
*/
|
|
85
|
+
const LOGIN_TIMEOUT_MS = 5 * 60 * 1000;
|
|
86
|
+
/**
|
|
87
|
+
* Generates a random 16-character hex string for the device auth flow.
|
|
88
|
+
* Used as the device_code that links the CLI session to the browser login.
|
|
89
|
+
*
|
|
90
|
+
* @returns A 16-char hex string (8 random bytes).
|
|
91
|
+
*/
|
|
92
|
+
function generateDeviceCode() {
|
|
93
|
+
// crypto.randomBytes is available in Node 18+ without importing crypto
|
|
94
|
+
// because it's on the global. We use the array approach for simplicity.
|
|
95
|
+
const bytes = new Uint8Array(8);
|
|
96
|
+
// Node 18+ has crypto.getRandomValues on globalThis
|
|
97
|
+
require("crypto").randomFillSync(bytes);
|
|
98
|
+
return Array.from(bytes)
|
|
99
|
+
.map((b) => b.toString(16).padStart(2, "0"))
|
|
100
|
+
.join("");
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Pauses execution for the specified duration.
|
|
104
|
+
* Used only in the device-flow polling loop.
|
|
105
|
+
*
|
|
106
|
+
* @param ms - Duration in milliseconds.
|
|
107
|
+
*/
|
|
108
|
+
function sleep(ms) {
|
|
109
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Detects whether a Linux session has a GUI display available.
|
|
113
|
+
* Device login stays browser-based even on VPS hosts, but we should not try to
|
|
114
|
+
* launch a local browser when there is clearly no display server attached.
|
|
115
|
+
*/
|
|
116
|
+
function hasLinuxDisplaySession() {
|
|
117
|
+
return Boolean(process.env.DISPLAY || process.env.WAYLAND_DISPLAY);
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Chooses the native browser-launch command for the current platform.
|
|
121
|
+
* This avoids the ESM/CJS interop problem from the `open` package while
|
|
122
|
+
* keeping local browser launch as a convenience rather than a hard dependency.
|
|
123
|
+
*/
|
|
124
|
+
function resolveBrowserLaunchCommand(url) {
|
|
125
|
+
switch (process.platform) {
|
|
126
|
+
case "win32":
|
|
127
|
+
return { command: "cmd", args: ["/c", "start", "", url] };
|
|
128
|
+
case "darwin":
|
|
129
|
+
return { command: "open", args: [url] };
|
|
130
|
+
case "linux":
|
|
131
|
+
return { command: "xdg-open", args: [url] };
|
|
132
|
+
default:
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Attempts to open the device-login URL locally without making login depend on
|
|
138
|
+
* the existence of a GUI environment. A failure here is informational only.
|
|
139
|
+
*/
|
|
140
|
+
async function launchBrowserBestEffort(url) {
|
|
141
|
+
if (process.platform === "linux" && !hasLinuxDisplaySession()) {
|
|
142
|
+
return {
|
|
143
|
+
attempted: false,
|
|
144
|
+
launched: false,
|
|
145
|
+
reason: "This host appears headless; open the login URL on any machine to continue.",
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
const launchCommand = resolveBrowserLaunchCommand(url);
|
|
149
|
+
if (!launchCommand) {
|
|
150
|
+
return {
|
|
151
|
+
attempted: false,
|
|
152
|
+
launched: false,
|
|
153
|
+
reason: `Automatic browser launch is not supported on platform ${process.platform}.`,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
return new Promise((resolve) => {
|
|
157
|
+
let settled = false;
|
|
158
|
+
const finish = (result) => {
|
|
159
|
+
if (settled) {
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
settled = true;
|
|
163
|
+
resolve(result);
|
|
164
|
+
};
|
|
165
|
+
const child = (0, child_process_1.spawn)(launchCommand.command, launchCommand.args, {
|
|
166
|
+
detached: true,
|
|
167
|
+
stdio: "ignore",
|
|
168
|
+
});
|
|
169
|
+
child.once("error", (err) => {
|
|
170
|
+
finish({
|
|
171
|
+
attempted: true,
|
|
172
|
+
launched: false,
|
|
173
|
+
reason: err.message,
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
child.once("exit", (code) => {
|
|
177
|
+
if (typeof code === "number" && code !== 0) {
|
|
178
|
+
finish({
|
|
179
|
+
attempted: true,
|
|
180
|
+
launched: false,
|
|
181
|
+
reason: `launcher exited with code ${code}`,
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
child.once("spawn", () => {
|
|
186
|
+
child.unref();
|
|
187
|
+
// If the platform launcher survived long enough to spawn cleanly, treat
|
|
188
|
+
// it as a successful handoff and let login continue in parallel.
|
|
189
|
+
setTimeout(() => {
|
|
190
|
+
finish({
|
|
191
|
+
attempted: true,
|
|
192
|
+
launched: true,
|
|
193
|
+
});
|
|
194
|
+
}, 150);
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Prints the device-login instructions in a form that works for both local
|
|
200
|
+
* desktop sessions and remote VPS sessions. The browser can complete login on
|
|
201
|
+
* any machine; the CLI simply keeps polling with the device code.
|
|
202
|
+
*/
|
|
203
|
+
function printDeviceLoginInstructions(loginURL, deviceCode) {
|
|
204
|
+
console.log((0, ui_1.joinBlocks)([
|
|
205
|
+
(0, ui_1.section)("Browser handoff", (0, ui_1.kvLines)([
|
|
206
|
+
["Login URL", (0, ui_1.info)(loginURL)],
|
|
207
|
+
["Device code", (0, ui_1.accent)(deviceCode)],
|
|
208
|
+
]), "primary"),
|
|
209
|
+
(0, ui_1.section)("What to do next", (0, ui_1.stepLines)([
|
|
210
|
+
`Open the login URL in any browser, including one on another machine.`,
|
|
211
|
+
`Enter the device code ${(0, ui_1.code)(deviceCode)} when the sign-in flow asks for it.`,
|
|
212
|
+
"Leave this terminal open while Jungle Grid waits for browser confirmation.",
|
|
213
|
+
]), "success"),
|
|
214
|
+
]));
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Column widths for `jungle jobs` tabular output.
|
|
218
|
+
* Increasing these widths improves readability for long values but widens
|
|
219
|
+
* terminal output and may wrap on narrow screens.
|
|
220
|
+
*/
|
|
221
|
+
const JOB_TABLE_COLUMNS = {
|
|
222
|
+
id: 18,
|
|
223
|
+
name: 24,
|
|
224
|
+
status: 12,
|
|
225
|
+
workload: 14,
|
|
226
|
+
optimize: 10,
|
|
227
|
+
created: 20,
|
|
228
|
+
updated: 20,
|
|
229
|
+
};
|
|
230
|
+
/**
|
|
231
|
+
* Truncates a value to fit a fixed-width table column.
|
|
232
|
+
*
|
|
233
|
+
* @param value - Source value to render in the column.
|
|
234
|
+
* @param width - Maximum column width in characters.
|
|
235
|
+
* @returns A padded or truncated string that fits exactly in width.
|
|
236
|
+
*/
|
|
237
|
+
function fitColumn(value, width) {
|
|
238
|
+
if (value.length <= width) {
|
|
239
|
+
return value.padEnd(width);
|
|
240
|
+
}
|
|
241
|
+
if (width <= 3) {
|
|
242
|
+
return value.slice(0, width);
|
|
243
|
+
}
|
|
244
|
+
return `${value.slice(0, width - 3)}...`;
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Converts unix seconds to a compact ISO UTC timestamp for table display.
|
|
248
|
+
*
|
|
249
|
+
* @param unixSeconds - Timestamp in unix seconds.
|
|
250
|
+
* @returns Compact timestamp (`YYYY-MM-DDTHH:MM:SSZ`) or "-" when absent.
|
|
251
|
+
*/
|
|
252
|
+
function formatUnixSeconds(unixSeconds) {
|
|
253
|
+
if (!unixSeconds || unixSeconds <= 0) {
|
|
254
|
+
return "-";
|
|
255
|
+
}
|
|
256
|
+
const iso = new Date(unixSeconds * 1000).toISOString();
|
|
257
|
+
return `${iso.slice(0, 19)}Z`;
|
|
258
|
+
}
|
|
259
|
+
function formatTimestamp(value) {
|
|
260
|
+
return value && value.trim().length > 0 ? value : (0, ui_1.muted)("not recorded");
|
|
261
|
+
}
|
|
262
|
+
function formatText(value, fallback = "not reported") {
|
|
263
|
+
return value && value.trim().length > 0 ? value : (0, ui_1.muted)(fallback);
|
|
264
|
+
}
|
|
265
|
+
function formatNumber(value, digits = 2, fallback = "not reported") {
|
|
266
|
+
return typeof value === "number" && Number.isFinite(value)
|
|
267
|
+
? value.toFixed(digits)
|
|
268
|
+
: (0, ui_1.muted)(fallback);
|
|
269
|
+
}
|
|
270
|
+
function formatInteger(value, fallback = "not reported") {
|
|
271
|
+
return typeof value === "number" && Number.isFinite(value)
|
|
272
|
+
? String(value)
|
|
273
|
+
: (0, ui_1.muted)(fallback);
|
|
274
|
+
}
|
|
275
|
+
function formatCurrency(value, fallback = "not reported") {
|
|
276
|
+
return typeof value === "number" && Number.isFinite(value)
|
|
277
|
+
? `$${value.toFixed(2)}`
|
|
278
|
+
: (0, ui_1.muted)(fallback);
|
|
279
|
+
}
|
|
280
|
+
function formatGigabytes(value, fallback = "not reported") {
|
|
281
|
+
return typeof value === "number" && Number.isFinite(value)
|
|
282
|
+
? `${value} GB`
|
|
283
|
+
: (0, ui_1.muted)(fallback);
|
|
284
|
+
}
|
|
285
|
+
function formatBoolean(value, trueLabel = "yes", falseLabel = "no") {
|
|
286
|
+
if (typeof value !== "boolean") {
|
|
287
|
+
return (0, ui_1.muted)("not reported");
|
|
288
|
+
}
|
|
289
|
+
return value ? (0, ui_1.success)(trueLabel) : (0, ui_1.muted)(falseLabel);
|
|
290
|
+
}
|
|
291
|
+
function formatHeartbeatTimestamp(unixSeconds) {
|
|
292
|
+
if (typeof unixSeconds !== "number" || !Number.isFinite(unixSeconds) || unixSeconds <= 0) {
|
|
293
|
+
return (0, ui_1.muted)("not reported");
|
|
294
|
+
}
|
|
295
|
+
return new Date(unixSeconds * 1000).toISOString();
|
|
296
|
+
}
|
|
297
|
+
function healthLabel(value) {
|
|
298
|
+
const normalized = value?.trim().toLowerCase();
|
|
299
|
+
if (!normalized) {
|
|
300
|
+
return (0, ui_1.muted)("not reported");
|
|
301
|
+
}
|
|
302
|
+
if (normalized === "healthy") {
|
|
303
|
+
return (0, ui_1.success)(value.trim());
|
|
304
|
+
}
|
|
305
|
+
if (normalized === "unhealthy" || normalized === "offline") {
|
|
306
|
+
return (0, ui_1.danger)(value.trim());
|
|
307
|
+
}
|
|
308
|
+
if (normalized === "degraded") {
|
|
309
|
+
return (0, ui_1.warning)(value.trim());
|
|
310
|
+
}
|
|
311
|
+
return (0, ui_1.info)(value.trim());
|
|
312
|
+
}
|
|
313
|
+
function formatArg(arg) {
|
|
314
|
+
return /^[A-Za-z0-9_./:@%+=,-]+$/.test(arg) ? arg : JSON.stringify(arg);
|
|
315
|
+
}
|
|
316
|
+
function formatCommandLine(command, args, fallback = "image default") {
|
|
317
|
+
const parts = [];
|
|
318
|
+
if (command && command.trim().length > 0) {
|
|
319
|
+
parts.push(command.trim());
|
|
320
|
+
}
|
|
321
|
+
if (Array.isArray(args)) {
|
|
322
|
+
for (const arg of args) {
|
|
323
|
+
if (arg.trim().length > 0) {
|
|
324
|
+
parts.push(arg);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
if (parts.length === 0) {
|
|
329
|
+
return (0, ui_1.muted)(fallback);
|
|
330
|
+
}
|
|
331
|
+
return (0, ui_1.code)(parts.map(formatArg).join(" "));
|
|
332
|
+
}
|
|
333
|
+
function pickRecordValue(record, ...keys) {
|
|
334
|
+
for (const key of keys) {
|
|
335
|
+
const value = record[key];
|
|
336
|
+
if (value !== undefined && value !== null) {
|
|
337
|
+
return value;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
return undefined;
|
|
341
|
+
}
|
|
342
|
+
function normalizeTextValue(value) {
|
|
343
|
+
if (typeof value !== "string") {
|
|
344
|
+
return undefined;
|
|
345
|
+
}
|
|
346
|
+
const trimmed = value.trim();
|
|
347
|
+
return trimmed.length > 0 ? trimmed : undefined;
|
|
348
|
+
}
|
|
349
|
+
function normalizeNumberValue(value) {
|
|
350
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
351
|
+
return value;
|
|
352
|
+
}
|
|
353
|
+
if (typeof value === "string" && value.trim().length > 0) {
|
|
354
|
+
const parsed = Number(value);
|
|
355
|
+
if (Number.isFinite(parsed)) {
|
|
356
|
+
return parsed;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
return undefined;
|
|
360
|
+
}
|
|
361
|
+
function normalizeBooleanValue(value) {
|
|
362
|
+
if (typeof value === "boolean") {
|
|
363
|
+
return value;
|
|
364
|
+
}
|
|
365
|
+
if (typeof value === "string") {
|
|
366
|
+
if (value.trim().toLowerCase() === "true") {
|
|
367
|
+
return true;
|
|
368
|
+
}
|
|
369
|
+
if (value.trim().toLowerCase() === "false") {
|
|
370
|
+
return false;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
return undefined;
|
|
374
|
+
}
|
|
375
|
+
function normalizeNodeRecord(node) {
|
|
376
|
+
return {
|
|
377
|
+
id: normalizeTextValue(pickRecordValue(node, "id", "ID")) || "",
|
|
378
|
+
status: normalizeTextValue(pickRecordValue(node, "status", "Status")),
|
|
379
|
+
healthStatus: normalizeTextValue(pickRecordValue(node, "health_status", "healthStatus", "HealthStatus")),
|
|
380
|
+
location: normalizeTextValue(pickRecordValue(node, "location", "Location")),
|
|
381
|
+
queueDepth: normalizeNumberValue(pickRecordValue(node, "queue_depth", "queueDepth", "QueueDepth")),
|
|
382
|
+
pricePerHour: normalizeNumberValue(pickRecordValue(node, "price_per_hour", "pricePerHour", "PricePerHour")),
|
|
383
|
+
reliabilityScore: normalizeNumberValue(pickRecordValue(node, "reliability_score", "reliabilityScore", "ReliabilityScore")),
|
|
384
|
+
latencyScore: normalizeNumberValue(pickRecordValue(node, "latency_score", "latencyScore", "LatencyScore")),
|
|
385
|
+
performanceScore: normalizeNumberValue(pickRecordValue(node, "performance_score", "performanceScore", "PerformanceScore")),
|
|
386
|
+
gpuType: normalizeTextValue(pickRecordValue(node, "gpu_type", "gpuType", "GPUType")),
|
|
387
|
+
vramGB: normalizeNumberValue(pickRecordValue(node, "vram_gb", "vramGb", "VRAMGB")),
|
|
388
|
+
gpuCount: normalizeNumberValue(pickRecordValue(node, "gpu_count", "gpuCount", "GPUCount")),
|
|
389
|
+
cudaVersion: normalizeTextValue(pickRecordValue(node, "cuda_version", "cudaVersion", "CUDAVersion")),
|
|
390
|
+
cudaSupport: normalizeBooleanValue(pickRecordValue(node, "cuda_support", "cudaSupport", "CUDASupport")),
|
|
391
|
+
computeCapability: normalizeTextValue(pickRecordValue(node, "compute_capability", "computeCapability", "ComputeCapability")),
|
|
392
|
+
dispatchURL: normalizeTextValue(pickRecordValue(node, "dispatch_url", "dispatchUrl", "DispatchURL")),
|
|
393
|
+
thermalThrottled: normalizeBooleanValue(pickRecordValue(node, "thermal_throttled", "thermalThrottled", "ThermalThrottled")),
|
|
394
|
+
lastHeartbeatUnix: normalizeNumberValue(pickRecordValue(node, "last_heartbeat_unix", "lastHeartbeatUnix", "LastHeartbeatUnix")),
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
async function collectNodeRegistrationPrompts(defaultEmail) {
|
|
398
|
+
const emailInput = await (0, prompt_1.askPrompt)(`Enter email [${defaultEmail}]: `);
|
|
399
|
+
const email = emailInput || defaultEmail;
|
|
400
|
+
const bankName = await (0, prompt_1.askPrompt)("Enter payout bank name: ");
|
|
401
|
+
const accountNumber = await (0, prompt_1.askPrompt)("Enter payout account number: ");
|
|
402
|
+
const accountName = await (0, prompt_1.askPrompt)("Enter payout account name: ");
|
|
403
|
+
if (!email || !bankName || !accountNumber || !accountName) {
|
|
404
|
+
throw new Error("email, bank name, account number, and account name are required");
|
|
405
|
+
}
|
|
406
|
+
return { email, bankName, accountNumber, accountName };
|
|
407
|
+
}
|
|
408
|
+
async function resolveDispatchURL(providedDispatchURL) {
|
|
409
|
+
const fromFlag = (providedDispatchURL || "").trim();
|
|
410
|
+
if (fromFlag) {
|
|
411
|
+
return fromFlag;
|
|
412
|
+
}
|
|
413
|
+
const promptedDispatchURL = (await (0, prompt_1.askPrompt)("Enter public dispatch URL for this node (e.g. http://127.0.0.1:8090): ")).trim();
|
|
414
|
+
if (!promptedDispatchURL) {
|
|
415
|
+
throw new Error("dispatch URL is required");
|
|
416
|
+
}
|
|
417
|
+
return promptedDispatchURL;
|
|
418
|
+
}
|
|
419
|
+
function runCommandOrFail(command, args, errorMessage) {
|
|
420
|
+
const result = spawnSyncCompat(command, args);
|
|
421
|
+
if (result.exitCode !== 0 || !result.stdout.trim()) {
|
|
422
|
+
throw new Error(errorMessage);
|
|
423
|
+
}
|
|
424
|
+
return result.stdout;
|
|
425
|
+
}
|
|
426
|
+
function spawnSyncCompat(command, args) {
|
|
427
|
+
const { spawnSync } = require("child_process");
|
|
428
|
+
const result = spawnSync(command, args, { encoding: "utf-8" });
|
|
429
|
+
return {
|
|
430
|
+
stdout: result.stdout || "",
|
|
431
|
+
stderr: result.stderr || "",
|
|
432
|
+
exitCode: typeof result.status === "number" ? result.status : 1,
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
function parseBooleanEnv(raw, fallback) {
|
|
436
|
+
if (!raw) {
|
|
437
|
+
return fallback;
|
|
438
|
+
}
|
|
439
|
+
const normalized = raw.trim().toLowerCase();
|
|
440
|
+
if (["1", "true", "yes", "on"].includes(normalized)) {
|
|
441
|
+
return true;
|
|
442
|
+
}
|
|
443
|
+
if (["0", "false", "no", "off"].includes(normalized)) {
|
|
444
|
+
return false;
|
|
445
|
+
}
|
|
446
|
+
return fallback;
|
|
447
|
+
}
|
|
448
|
+
function parsePositiveIntegerEnv(raw, fallback) {
|
|
449
|
+
if (!raw) {
|
|
450
|
+
return fallback;
|
|
451
|
+
}
|
|
452
|
+
const parsed = parseInt(raw, 10);
|
|
453
|
+
if (!Number.isFinite(parsed) || parsed < 1) {
|
|
454
|
+
return fallback;
|
|
455
|
+
}
|
|
456
|
+
return parsed;
|
|
457
|
+
}
|
|
458
|
+
function parseNonEmptyEnv(raw, fallback) {
|
|
459
|
+
const value = (raw || "").trim();
|
|
460
|
+
return value.length > 0 ? value : fallback;
|
|
461
|
+
}
|
|
462
|
+
function isSimulationModeEnabled() {
|
|
463
|
+
return parseBooleanEnv(process.env.JUNGLE_SIMULATION_MODE, false);
|
|
464
|
+
}
|
|
465
|
+
function detectHostSystemInfo() {
|
|
466
|
+
const cpus = os.cpus();
|
|
467
|
+
return {
|
|
468
|
+
cpuCores: cpus.length,
|
|
469
|
+
cpuMHz: cpus.length > 0 ? cpus[0].speed : 0,
|
|
470
|
+
ramGB: Math.round((os.totalmem() / (1024 ** 3)) * 10) / 10,
|
|
471
|
+
osLabel: `${os.platform()}-${os.release()}`,
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
function detectComputeCapabilityOrUnknown() {
|
|
475
|
+
const result = spawnSyncCompat("nvidia-smi", ["--query-gpu=compute_cap", "--format=csv,noheader,nounits"]);
|
|
476
|
+
if (result.exitCode !== 0 || !result.stdout.trim()) {
|
|
477
|
+
return "unknown";
|
|
478
|
+
}
|
|
479
|
+
const firstLine = result.stdout
|
|
480
|
+
.split(/\r?\n/)
|
|
481
|
+
.map((line) => line.trim())
|
|
482
|
+
.find((line) => line.length > 0);
|
|
483
|
+
return firstLine || "unknown";
|
|
484
|
+
}
|
|
485
|
+
function detectSimulatedNodeHardware() {
|
|
486
|
+
const host = detectHostSystemInfo();
|
|
487
|
+
return {
|
|
488
|
+
gpu: parseNonEmptyEnv(process.env.JUNGLE_SIM_GPU_NAME, "RTX 3090"),
|
|
489
|
+
vramGb: parsePositiveIntegerEnv(process.env.JUNGLE_SIM_GPU_VRAM_GB, 24),
|
|
490
|
+
gpuCount: parsePositiveIntegerEnv(process.env.JUNGLE_SIM_GPU_COUNT, 1),
|
|
491
|
+
cpuCores: host.cpuCores,
|
|
492
|
+
cpuMHz: host.cpuMHz,
|
|
493
|
+
ramGB: host.ramGB,
|
|
494
|
+
cudaVersion: parseNonEmptyEnv(process.env.JUNGLE_SIM_CUDA_VERSION, "12.4"),
|
|
495
|
+
cudaSupport: parseBooleanEnv(process.env.JUNGLE_SIM_CUDA_SUPPORT, true),
|
|
496
|
+
computeCapability: parseNonEmptyEnv(process.env.JUNGLE_SIM_COMPUTE_CAPABILITY, "8.6"),
|
|
497
|
+
os: host.osLabel,
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
function detectNodeHardwareOrFail() {
|
|
501
|
+
if (isSimulationModeEnabled()) {
|
|
502
|
+
return detectSimulatedNodeHardware();
|
|
503
|
+
}
|
|
504
|
+
// Production requirement: registration fails if nvidia-smi is unavailable.
|
|
505
|
+
const nvidiaQuery = runCommandOrFail("nvidia-smi", [
|
|
506
|
+
"--query-gpu=name,memory.total",
|
|
507
|
+
"--format=csv,noheader,nounits",
|
|
508
|
+
], "nvidia-smi is required for node registration but was not found or returned no GPU data");
|
|
509
|
+
const gpuLines = nvidiaQuery
|
|
510
|
+
.split(/\r?\n/)
|
|
511
|
+
.map((line) => line.trim())
|
|
512
|
+
.filter((line) => line.length > 0);
|
|
513
|
+
if (gpuLines.length === 0) {
|
|
514
|
+
throw new Error("nvidia-smi returned no GPUs; registration cannot proceed");
|
|
515
|
+
}
|
|
516
|
+
const firstGpuParts = gpuLines[0].split(",").map((x) => x.trim());
|
|
517
|
+
const gpu = firstGpuParts[0] || "unknown";
|
|
518
|
+
const vramGb = Math.max(1, Math.round((parseFloat(firstGpuParts[1] || "0") || 0) / 1024));
|
|
519
|
+
const gpuCount = gpuLines.length;
|
|
520
|
+
const cudaOut = runCommandOrFail("nvidia-smi", [], "unable to read CUDA version from nvidia-smi");
|
|
521
|
+
const cudaMatch = cudaOut.match(/CUDA Version:\s*([0-9.]+)/i);
|
|
522
|
+
const cudaVersion = cudaMatch ? cudaMatch[1] : "unknown";
|
|
523
|
+
const cudaSupport = cudaMatch !== null;
|
|
524
|
+
const computeCapability = detectComputeCapabilityOrUnknown();
|
|
525
|
+
const host = detectHostSystemInfo();
|
|
526
|
+
return {
|
|
527
|
+
gpu,
|
|
528
|
+
vramGb,
|
|
529
|
+
gpuCount,
|
|
530
|
+
cpuCores: host.cpuCores,
|
|
531
|
+
cpuMHz: host.cpuMHz,
|
|
532
|
+
ramGB: host.ramGB,
|
|
533
|
+
cudaVersion,
|
|
534
|
+
cudaSupport,
|
|
535
|
+
computeCapability,
|
|
536
|
+
os: host.osLabel,
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
async function measureLatencyMs(apiUrl) {
|
|
540
|
+
const started = Date.now();
|
|
541
|
+
const target = `${apiUrl.replace(/\/+$/, "")}/healthz`;
|
|
542
|
+
const timeoutMs = 5000;
|
|
543
|
+
const controller = new AbortController();
|
|
544
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
545
|
+
try {
|
|
546
|
+
await fetch(target, {
|
|
547
|
+
method: "GET",
|
|
548
|
+
headers: { Accept: "application/json" },
|
|
549
|
+
signal: controller.signal,
|
|
550
|
+
});
|
|
551
|
+
return Math.max(1, Date.now() - started);
|
|
552
|
+
}
|
|
553
|
+
catch {
|
|
554
|
+
return 100;
|
|
555
|
+
}
|
|
556
|
+
finally {
|
|
557
|
+
clearTimeout(timeout);
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
function resolveNodeAgentGoRunCwd() {
|
|
561
|
+
const candidates = [
|
|
562
|
+
process.cwd(),
|
|
563
|
+
path.resolve(__dirname, "..", "..", ".."),
|
|
564
|
+
];
|
|
565
|
+
for (const candidate of candidates) {
|
|
566
|
+
const marker = path.join(candidate, "services", "node-agent", "cmd", "main.go");
|
|
567
|
+
if (fs.existsSync(marker)) {
|
|
568
|
+
return candidate;
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
throw new Error("Unable to locate services/node-agent/cmd/main.go. Run from the repo root or use the managed installer via jungle node start.");
|
|
572
|
+
}
|
|
573
|
+
/**
|
|
574
|
+
* Resolves how the provider runtime should be launched on this machine.
|
|
575
|
+
*
|
|
576
|
+
* Launch precedence:
|
|
577
|
+
* 1. Explicit --binary path supplied by the operator
|
|
578
|
+
* 2. Explicit --binary go for local repo development
|
|
579
|
+
* 3. Managed installer path under ~/.jungle-grid/bin/node-agent/<version>
|
|
580
|
+
*
|
|
581
|
+
* This keeps provider UX simple in production while preserving an intentional
|
|
582
|
+
* escape hatch for contributors who still want to run the service from source.
|
|
583
|
+
*/
|
|
584
|
+
async function resolveNodeAgentLaunchSpec(binaryOption) {
|
|
585
|
+
const requestedBinary = binaryOption ? String(binaryOption).trim() : "";
|
|
586
|
+
if (requestedBinary.length > 0) {
|
|
587
|
+
if (requestedBinary === "go") {
|
|
588
|
+
return {
|
|
589
|
+
binary: "go",
|
|
590
|
+
args: ["run", "./services/node-agent/cmd"],
|
|
591
|
+
cwd: resolveNodeAgentGoRunCwd(),
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
return {
|
|
595
|
+
binary: requestedBinary,
|
|
596
|
+
args: [],
|
|
597
|
+
cwd: process.cwd(),
|
|
598
|
+
};
|
|
599
|
+
}
|
|
600
|
+
const installResult = await (0, node_agent_installer_1.ensureManagedNodeAgentInstalled)(CLI_VERSION);
|
|
601
|
+
return {
|
|
602
|
+
binary: installResult.binaryPath,
|
|
603
|
+
args: [],
|
|
604
|
+
cwd: installResult.installDir,
|
|
605
|
+
installSummary: {
|
|
606
|
+
downloaded: installResult.downloaded,
|
|
607
|
+
binaryPath: installResult.binaryPath,
|
|
608
|
+
version: installResult.releaseTag,
|
|
609
|
+
platformLabel: installResult.platformLabel,
|
|
610
|
+
},
|
|
611
|
+
};
|
|
612
|
+
}
|
|
613
|
+
function isProcessRunning(pid) {
|
|
614
|
+
if (!Number.isInteger(pid) || pid <= 0) {
|
|
615
|
+
return false;
|
|
616
|
+
}
|
|
617
|
+
try {
|
|
618
|
+
process.kill(pid, 0);
|
|
619
|
+
return true;
|
|
620
|
+
}
|
|
621
|
+
catch (err) {
|
|
622
|
+
const code = err.code;
|
|
623
|
+
// EPERM means the process exists but we may not have permissions.
|
|
624
|
+
return code === "EPERM";
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
async function waitForProcessExit(pid, timeoutMs) {
|
|
628
|
+
const started = Date.now();
|
|
629
|
+
while (Date.now() - started < timeoutMs) {
|
|
630
|
+
if (!isProcessRunning(pid)) {
|
|
631
|
+
return true;
|
|
632
|
+
}
|
|
633
|
+
await sleep(200);
|
|
634
|
+
}
|
|
635
|
+
return !isProcessRunning(pid);
|
|
636
|
+
}
|
|
637
|
+
async function stopProcessByPid(pid) {
|
|
638
|
+
if (!isProcessRunning(pid)) {
|
|
639
|
+
return;
|
|
640
|
+
}
|
|
641
|
+
if (process.platform === "win32") {
|
|
642
|
+
const result = spawnSyncCompat("taskkill", ["/PID", String(pid), "/T", "/F"]);
|
|
643
|
+
if (result.exitCode !== 0 && isProcessRunning(pid)) {
|
|
644
|
+
const msg = result.stderr.trim() || result.stdout.trim() || `taskkill failed with code ${result.exitCode}`;
|
|
645
|
+
throw new Error(msg);
|
|
646
|
+
}
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
649
|
+
try {
|
|
650
|
+
process.kill(pid, "SIGTERM");
|
|
651
|
+
}
|
|
652
|
+
catch (err) {
|
|
653
|
+
const code = err.code;
|
|
654
|
+
if (code !== "ESRCH") {
|
|
655
|
+
throw err;
|
|
656
|
+
}
|
|
657
|
+
return;
|
|
658
|
+
}
|
|
659
|
+
if (await waitForProcessExit(pid, 5000)) {
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
662
|
+
try {
|
|
663
|
+
process.kill(pid, "SIGKILL");
|
|
664
|
+
}
|
|
665
|
+
catch (err) {
|
|
666
|
+
const code = err.code;
|
|
667
|
+
if (code !== "ESRCH") {
|
|
668
|
+
throw err;
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
if (!(await waitForProcessExit(pid, 2000))) {
|
|
672
|
+
throw new Error(`process ${pid} did not exit`);
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
function ensureProviderSessionOrFail(creds) {
|
|
676
|
+
if (!creds) {
|
|
677
|
+
throw new Error("Not logged in. Run `jungle login` and authenticate as provider.");
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
/**
|
|
681
|
+
* Normalises multiline help sections so command help stays readable.
|
|
682
|
+
*
|
|
683
|
+
* @param content - Raw multiline help content.
|
|
684
|
+
* @returns Help block padded with surrounding newlines.
|
|
685
|
+
*/
|
|
686
|
+
function helpBlock(content) {
|
|
687
|
+
return `\n${content.trim()}\n`;
|
|
688
|
+
}
|
|
689
|
+
function renderGuide(options) {
|
|
690
|
+
return helpBlock((0, ui_1.joinBlocks)([
|
|
691
|
+
(0, ui_1.section)("What this command does", [options.summary], "primary"),
|
|
692
|
+
options.examples && options.examples.length > 0
|
|
693
|
+
? (0, ui_1.section)("Examples", options.examples.map((example) => `${(0, ui_1.muted)("$")} ${(0, ui_1.code)(example)}`), "info")
|
|
694
|
+
: null,
|
|
695
|
+
options.steps && options.steps.length > 0
|
|
696
|
+
? (0, ui_1.section)("Suggested flow", (0, ui_1.stepLines)(options.steps), "success")
|
|
697
|
+
: null,
|
|
698
|
+
options.behavior && options.behavior.length > 0
|
|
699
|
+
? (0, ui_1.section)("Behavior", (0, ui_1.listLines)(options.behavior), "warning")
|
|
700
|
+
: null,
|
|
701
|
+
options.notes && options.notes.length > 0
|
|
702
|
+
? (0, ui_1.section)("Notes", (0, ui_1.listLines)(options.notes), "muted")
|
|
703
|
+
: null,
|
|
704
|
+
]));
|
|
705
|
+
}
|
|
706
|
+
function renderSummary(title, entries, tone = "success", nextSteps) {
|
|
707
|
+
return (0, ui_1.joinBlocks)([
|
|
708
|
+
(0, ui_1.section)(title, (0, ui_1.kvLines)(entries), tone),
|
|
709
|
+
nextSteps && nextSteps.length > 0
|
|
710
|
+
? (0, ui_1.section)("Next steps", (0, ui_1.listLines)(nextSteps), "info")
|
|
711
|
+
: null,
|
|
712
|
+
]);
|
|
713
|
+
}
|
|
714
|
+
function renderApiError(prefix, err) {
|
|
715
|
+
if (err instanceof api_1.ApiError) {
|
|
716
|
+
return `${prefix} (${err.status}): ${err.message}`;
|
|
717
|
+
}
|
|
718
|
+
return `${prefix}: ${err.message}`;
|
|
719
|
+
}
|
|
720
|
+
const ROOT_HELP_DETAILS = helpBlock((0, ui_1.joinBlocks)([
|
|
721
|
+
(0, ui_1.section)("Overview", [
|
|
722
|
+
"Jungle Grid keeps the terminal workload-first: submit jobs by intent, inspect lifecycle cleanly, and run provider workflows without digging through backend internals.",
|
|
723
|
+
], "primary"),
|
|
724
|
+
(0, ui_1.section)("Start here", (0, ui_1.stepLines)([
|
|
725
|
+
`Authenticate with ${(0, ui_1.code)("jungle login")}.`,
|
|
726
|
+
`Queue a first workload with ${(0, ui_1.code)("jungle submit --workload inference --model-size 7 --image ghcr.io/acme/infer:latest --name chat-infer")}.`,
|
|
727
|
+
`Track activity with ${(0, ui_1.code)("jungle jobs")} and inspect a single run with ${(0, ui_1.code)("jungle status <job-id>")}.`,
|
|
728
|
+
]), "success"),
|
|
729
|
+
(0, ui_1.section)("Command lanes", (0, ui_1.kvLines)([
|
|
730
|
+
["submit/jobs/status/logs", "User workload flow from queue to runtime inspection."],
|
|
731
|
+
["login/logout/whoami", "Account session flow for this machine."],
|
|
732
|
+
["nodes", "Remote marketplace and node-detail inspection."],
|
|
733
|
+
["node", "Provider registration and local node-agent lifecycle."],
|
|
734
|
+
["shell", "Interactive REPL when you want to stay inside the CLI."],
|
|
735
|
+
]), "info"),
|
|
736
|
+
(0, ui_1.section)("Need more detail", (0, ui_1.listLines)([
|
|
737
|
+
`Run ${(0, ui_1.code)("jungle <command> --help")} for a command-specific guide.`,
|
|
738
|
+
`Run ${(0, ui_1.code)("jungle help <command>")} if you prefer the Commander help path.`,
|
|
739
|
+
]), "muted"),
|
|
740
|
+
]));
|
|
741
|
+
// ─── Program definition ────────────────────────────────────────────────────
|
|
742
|
+
const program = new commander_1.Command();
|
|
743
|
+
program
|
|
744
|
+
.name("jungle")
|
|
745
|
+
.description("Jungle Grid CLI — submit GPU workloads without thinking about hardware")
|
|
746
|
+
.version(CLI_VERSION)
|
|
747
|
+
.showSuggestionAfterError()
|
|
748
|
+
.showHelpAfterError(`${(0, ui_1.muted)("Run")} ${(0, ui_1.code)("jungle --help")} ${(0, ui_1.muted)("for the full operator brief.")}`)
|
|
749
|
+
.addHelpText("after", ROOT_HELP_DETAILS);
|
|
750
|
+
// ─── jungle submit ─────────────────────────────────────────────────────────
|
|
751
|
+
program
|
|
752
|
+
.command("submit")
|
|
753
|
+
.description("Submit a containerized GPU workload for scheduling and dispatch")
|
|
754
|
+
.requiredOption("--workload <type>", 'Workload type: "inference", "training", "fine-tuning", or "batch"')
|
|
755
|
+
.requiredOption("--image <image>", "Public container image to run")
|
|
756
|
+
.requiredOption("--model-size <gb>", "Estimated model size in GB (drives hardware tier selection)")
|
|
757
|
+
.option("--optimize-for <mode>", 'Optimization target: "cost", "speed", or "balanced"', "balanced")
|
|
758
|
+
.option("--name <name>", "Human-readable job name")
|
|
759
|
+
.option("--command <command>", "Optional runtime command override. Omit this to use the image default entrypoint/CMD.")
|
|
760
|
+
.option("--arg <value>", "Optional runtime argument. Repeat this flag to pass multiple args.", (value, previous) => {
|
|
761
|
+
previous.push(value);
|
|
762
|
+
return previous;
|
|
763
|
+
}, [])
|
|
764
|
+
.option("--batch-size <n>", "Batch size hint for throughput estimation", "1")
|
|
765
|
+
.option("--precision <p>", 'Numeric precision: "fp32", "fp16", "bf16", "int8"', "fp16")
|
|
766
|
+
.addHelpText("after", renderGuide({
|
|
767
|
+
summary: "Creates a real container job from workload intent fields. The orchestrator handles placement and the node-agent runs the image.",
|
|
768
|
+
examples: [
|
|
769
|
+
"jungle submit --workload inference --model-size 7 --image ghcr.io/acme/infer:latest --name chat-infer",
|
|
770
|
+
"jungle submit --workload batch --model-size 3 --image node:22-alpine --command node --arg app.js --optimize-for cost",
|
|
771
|
+
"jungle submit --workload training --model-size 40 --image pytorch/pytorch:2.4.0-cuda12.1-cudnn9-runtime --command python --arg train.py --optimize-for speed",
|
|
772
|
+
],
|
|
773
|
+
notes: [
|
|
774
|
+
"--image is required and should point to a public image.",
|
|
775
|
+
"--model-size must be greater than zero.",
|
|
776
|
+
"Omit --command to use the image default entrypoint or CMD.",
|
|
777
|
+
"Repeat --arg to pass multiple runtime arguments.",
|
|
778
|
+
"Use jungle jobs after submit to find the new job ID.",
|
|
779
|
+
],
|
|
780
|
+
}))
|
|
781
|
+
.action(async (opts) => {
|
|
782
|
+
try {
|
|
783
|
+
// Validate model size is a positive number.
|
|
784
|
+
const modelSize = parseFloat(opts.modelSize);
|
|
785
|
+
if (isNaN(modelSize) || modelSize <= 0) {
|
|
786
|
+
console.error((0, ui_1.danger)("Error: --model-size must be a positive number (in GB)"));
|
|
787
|
+
process.exit(1);
|
|
788
|
+
}
|
|
789
|
+
const batchSize = parseInt(opts.batchSize, 10) || 1;
|
|
790
|
+
/**
|
|
791
|
+
* Build the job submission payload using workload intent fields.
|
|
792
|
+
* PILLAR 1: We send workload_type and model_size_gb — never gpu_type.
|
|
793
|
+
* The orchestrator's classifier will resolve the appropriate GPU tier.
|
|
794
|
+
*/
|
|
795
|
+
const payload = {
|
|
796
|
+
name: opts.name || `${opts.workload}-job`,
|
|
797
|
+
image: opts.image,
|
|
798
|
+
command: opts.command || undefined,
|
|
799
|
+
args: Array.isArray(opts.arg) ? opts.arg : [],
|
|
800
|
+
workload_type: opts.workload,
|
|
801
|
+
model_size_gb: modelSize,
|
|
802
|
+
batch_size: batchSize,
|
|
803
|
+
precision: opts.precision,
|
|
804
|
+
optimize_for: opts.optimizeFor,
|
|
805
|
+
};
|
|
806
|
+
const result = await (0, api_1.apiRequest)("POST", "/v1/jobs", payload);
|
|
807
|
+
console.log(renderSummary("Job queued", [
|
|
808
|
+
["Job ID", (0, ui_1.strong)(result.job_id)],
|
|
809
|
+
["Status", (0, ui_1.statusLabel)(result.status)],
|
|
810
|
+
["Queued at", result.queued_at],
|
|
811
|
+
], "success", [
|
|
812
|
+
`List workloads with ${(0, ui_1.code)("jungle jobs")}.`,
|
|
813
|
+
`Inspect this job with ${(0, ui_1.code)(`jungle status ${result.job_id}`)}.`,
|
|
814
|
+
]));
|
|
815
|
+
}
|
|
816
|
+
catch (err) {
|
|
817
|
+
console.error((0, ui_1.danger)(renderApiError("Failed to submit job", err)));
|
|
818
|
+
process.exit(1);
|
|
819
|
+
}
|
|
820
|
+
});
|
|
821
|
+
// ─── jungle status ─────────────────────────────────────────────────────────
|
|
822
|
+
program
|
|
823
|
+
.command("status <job-id>")
|
|
824
|
+
.description("Check the status of a submitted job")
|
|
825
|
+
.addHelpText("after", renderGuide({
|
|
826
|
+
summary: "Shows the full job record for a single workload, including intent, execution request, scheduler signals, and runtime summary when available.",
|
|
827
|
+
examples: [
|
|
828
|
+
"jungle status job-123456",
|
|
829
|
+
"jungle status 3f4e2d10-89ab-4f9d-a122-c57a3be31c10",
|
|
830
|
+
],
|
|
831
|
+
notes: [
|
|
832
|
+
"Use jungle jobs first if you need to look up a job ID.",
|
|
833
|
+
"Typical progression is queued -> running -> completed, with failed when execution or scheduling cannot complete.",
|
|
834
|
+
"Use jungle logs <job-id> when you need the persisted stdout and stderr tail.",
|
|
835
|
+
],
|
|
836
|
+
}))
|
|
837
|
+
.action(async (jobId) => {
|
|
838
|
+
try {
|
|
839
|
+
const result = await (0, api_1.apiRequest)("GET", `/v1/jobs/${encodeURIComponent(jobId)}`);
|
|
840
|
+
let runtimeResult = null;
|
|
841
|
+
let runtimeWarningMessage = null;
|
|
842
|
+
try {
|
|
843
|
+
runtimeResult = await (0, api_1.apiRequest)("GET", `/v1/jobs/${encodeURIComponent(jobId)}/runtime`);
|
|
844
|
+
}
|
|
845
|
+
catch (err) {
|
|
846
|
+
if (!(err instanceof api_1.ApiError && err.status === 404)) {
|
|
847
|
+
runtimeWarningMessage = renderApiError("Runtime detail unavailable", err);
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
const summaryRows = [
|
|
851
|
+
["Job ID", (0, ui_1.strong)(result.job_id)],
|
|
852
|
+
["Status", (0, ui_1.statusLabel)(result.status)],
|
|
853
|
+
];
|
|
854
|
+
if (result.name) {
|
|
855
|
+
summaryRows.push(["Name", (0, ui_1.strong)(result.name)]);
|
|
856
|
+
}
|
|
857
|
+
if (result.status_reason) {
|
|
858
|
+
summaryRows.push(["Reason", result.status_reason]);
|
|
859
|
+
}
|
|
860
|
+
if (result.assigned_node_id) {
|
|
861
|
+
summaryRows.push(["Assigned node", result.assigned_node_id]);
|
|
862
|
+
}
|
|
863
|
+
const blocks = [
|
|
864
|
+
(0, ui_1.section)("Job status", (0, ui_1.kvLines)(summaryRows), "info"),
|
|
865
|
+
];
|
|
866
|
+
const intentRows = [];
|
|
867
|
+
if (result.workload_type) {
|
|
868
|
+
intentRows.push(["Workload", result.workload_type]);
|
|
869
|
+
}
|
|
870
|
+
if (typeof result.model_size_gb === "number" && Number.isFinite(result.model_size_gb)) {
|
|
871
|
+
intentRows.push(["Model size", formatGigabytes(result.model_size_gb)]);
|
|
872
|
+
}
|
|
873
|
+
if (typeof result.batch_size === "number" && Number.isFinite(result.batch_size)) {
|
|
874
|
+
intentRows.push(["Batch size", String(result.batch_size)]);
|
|
875
|
+
}
|
|
876
|
+
if (result.precision) {
|
|
877
|
+
intentRows.push(["Precision", result.precision]);
|
|
878
|
+
}
|
|
879
|
+
intentRows.push(["Optimize for", formatText(result.optimize_for || "balanced")]);
|
|
880
|
+
if (intentRows.length > 0) {
|
|
881
|
+
blocks.push((0, ui_1.section)("Workload intent", (0, ui_1.kvLines)(intentRows), "primary"));
|
|
882
|
+
}
|
|
883
|
+
const executionRows = [];
|
|
884
|
+
if (result.image) {
|
|
885
|
+
executionRows.push(["Image", (0, ui_1.code)(result.image)]);
|
|
886
|
+
}
|
|
887
|
+
executionRows.push([
|
|
888
|
+
"Command",
|
|
889
|
+
result.command && result.command.trim().length > 0
|
|
890
|
+
? (0, ui_1.code)(formatArg(result.command.trim()))
|
|
891
|
+
: (0, ui_1.muted)("image default"),
|
|
892
|
+
]);
|
|
893
|
+
executionRows.push([
|
|
894
|
+
"Args",
|
|
895
|
+
result.args && result.args.length > 0
|
|
896
|
+
? (0, ui_1.code)(result.args.map(formatArg).join(" "))
|
|
897
|
+
: (0, ui_1.muted)("none"),
|
|
898
|
+
]);
|
|
899
|
+
executionRows.push(["Resolved", formatCommandLine(result.command, result.args)]);
|
|
900
|
+
if (executionRows.length > 0) {
|
|
901
|
+
blocks.push((0, ui_1.section)("Execution request", (0, ui_1.kvLines)(executionRows), "muted"));
|
|
902
|
+
}
|
|
903
|
+
const lifecycleRows = [
|
|
904
|
+
["Created", formatTimestamp(result.created_at)],
|
|
905
|
+
["Queued", formatTimestamp(result.queued_at)],
|
|
906
|
+
["Started", formatTimestamp(result.started_at)],
|
|
907
|
+
["Completed", formatTimestamp(result.completed_at)],
|
|
908
|
+
["Updated", formatTimestamp(result.updated_at)],
|
|
909
|
+
];
|
|
910
|
+
if (typeof result.dispatch_attempt === "number" && Number.isFinite(result.dispatch_attempt)) {
|
|
911
|
+
lifecycleRows.push(["Dispatch attempt", String(result.dispatch_attempt)]);
|
|
912
|
+
}
|
|
913
|
+
if (result.last_failed_node_id) {
|
|
914
|
+
lifecycleRows.push(["Last failed node", result.last_failed_node_id]);
|
|
915
|
+
}
|
|
916
|
+
blocks.push((0, ui_1.section)("Lifecycle", (0, ui_1.kvLines)(lifecycleRows), "info"));
|
|
917
|
+
if (result.score_breakdown) {
|
|
918
|
+
const s = result.score_breakdown;
|
|
919
|
+
blocks.push((0, ui_1.section)("Scheduler signals", (0, ui_1.kvLines)([
|
|
920
|
+
["Total", s.total.toFixed(2)],
|
|
921
|
+
["Price", s.price.toFixed(2)],
|
|
922
|
+
["Reliability", s.reliability.toFixed(2)],
|
|
923
|
+
["Latency", s.latency.toFixed(2)],
|
|
924
|
+
["Performance", s.performance.toFixed(2)],
|
|
925
|
+
["Queue depth", s.queue_depth.toFixed(2)],
|
|
926
|
+
["Thermal", s.thermal.toFixed(2)],
|
|
927
|
+
]), "muted"));
|
|
928
|
+
}
|
|
929
|
+
if (runtimeResult) {
|
|
930
|
+
const runtimeRows = [
|
|
931
|
+
["Exit code", typeof runtimeResult.exit_code === "number" ? String(runtimeResult.exit_code) : (0, ui_1.muted)("not reported")],
|
|
932
|
+
["Timed out", formatBoolean(runtimeResult.timed_out ?? false)],
|
|
933
|
+
["Runtime started", formatTimestamp(runtimeResult.started_at)],
|
|
934
|
+
["Runtime finished", formatTimestamp(runtimeResult.finished_at)],
|
|
935
|
+
];
|
|
936
|
+
blocks.push((0, ui_1.section)("Runtime summary", [
|
|
937
|
+
...(0, ui_1.kvLines)(runtimeRows),
|
|
938
|
+
"",
|
|
939
|
+
`${(0, ui_1.muted)("Logs")} ${(0, ui_1.code)(`jungle logs ${result.job_id}`)}`,
|
|
940
|
+
], runtimeResult.status === "completed" ? "success" : "warning"));
|
|
941
|
+
}
|
|
942
|
+
else if (runtimeWarningMessage) {
|
|
943
|
+
blocks.push((0, ui_1.section)("Runtime summary", [runtimeWarningMessage], "warning"));
|
|
944
|
+
}
|
|
945
|
+
console.log((0, ui_1.joinBlocks)(blocks));
|
|
946
|
+
}
|
|
947
|
+
catch (err) {
|
|
948
|
+
console.error((0, ui_1.danger)(renderApiError("Failed to get job status", err)));
|
|
949
|
+
process.exit(1);
|
|
950
|
+
}
|
|
951
|
+
});
|
|
952
|
+
// ─── jungle jobs ───────────────────────────────────────────────────────────
|
|
953
|
+
program
|
|
954
|
+
.command("logs <job-id>")
|
|
955
|
+
.description("Fetch the persisted runtime log tail for a completed or failed job")
|
|
956
|
+
.addHelpText("after", renderGuide({
|
|
957
|
+
summary: "Fetches the latest persisted stdout and stderr tails for a job's real runtime record.",
|
|
958
|
+
examples: ["jungle logs job-123456"],
|
|
959
|
+
notes: [
|
|
960
|
+
"Logs appear after the node-agent reports runtime state back to the orchestrator.",
|
|
961
|
+
"Use jungle status first if you want the lifecycle state before fetching logs.",
|
|
962
|
+
],
|
|
963
|
+
}))
|
|
964
|
+
.action(async (jobId) => {
|
|
965
|
+
try {
|
|
966
|
+
const result = await (0, api_1.apiRequest)("GET", `/v1/jobs/${encodeURIComponent(jobId)}/runtime`);
|
|
967
|
+
const blocks = [
|
|
968
|
+
(0, ui_1.section)("Runtime", (0, ui_1.kvLines)([
|
|
969
|
+
["Job ID", (0, ui_1.strong)(result.job_id)],
|
|
970
|
+
["Status", (0, ui_1.statusLabel)(result.status)],
|
|
971
|
+
["Reason", result.status_reason || (0, ui_1.muted)("not reported")],
|
|
972
|
+
["Image", (0, ui_1.code)(result.image)],
|
|
973
|
+
["Command", result.command ? (0, ui_1.code)(formatArg(result.command)) : (0, ui_1.muted)("image default")],
|
|
974
|
+
[
|
|
975
|
+
"Args",
|
|
976
|
+
result.args && result.args.length > 0
|
|
977
|
+
? (0, ui_1.code)(result.args.map(formatArg).join(" "))
|
|
978
|
+
: (0, ui_1.muted)("none"),
|
|
979
|
+
],
|
|
980
|
+
[
|
|
981
|
+
"Exit code",
|
|
982
|
+
typeof result.exit_code === "number"
|
|
983
|
+
? String(result.exit_code)
|
|
984
|
+
: (0, ui_1.muted)("not reported"),
|
|
985
|
+
],
|
|
986
|
+
["Timed out", formatBoolean(Boolean(result.timed_out))],
|
|
987
|
+
["Started", formatTimestamp(result.started_at)],
|
|
988
|
+
["Finished", formatTimestamp(result.finished_at)],
|
|
989
|
+
]), result.status === "completed" ? "success" : "warning"),
|
|
990
|
+
(0, ui_1.section)("Stdout", result.stdout_tail
|
|
991
|
+
? result.stdout_tail.replace(/\s+$/, "").split(/\r?\n/)
|
|
992
|
+
: [(0, ui_1.muted)("(no persisted stdout)")], "info"),
|
|
993
|
+
(0, ui_1.section)("Stderr", result.stderr_tail
|
|
994
|
+
? result.stderr_tail.replace(/\s+$/, "").split(/\r?\n/)
|
|
995
|
+
: [(0, ui_1.muted)("(no persisted stderr)")], result.stderr_tail ? "danger" : "muted"),
|
|
996
|
+
];
|
|
997
|
+
console.log((0, ui_1.joinBlocks)(blocks));
|
|
998
|
+
}
|
|
999
|
+
catch (err) {
|
|
1000
|
+
console.error((0, ui_1.danger)(renderApiError("Failed to fetch job logs", err)));
|
|
1001
|
+
process.exit(1);
|
|
1002
|
+
}
|
|
1003
|
+
});
|
|
1004
|
+
program
|
|
1005
|
+
.command("jobs")
|
|
1006
|
+
.description("List all of your jobs (active and completed)")
|
|
1007
|
+
.addHelpText("after", renderGuide({
|
|
1008
|
+
summary: "Lists every job owned by the authenticated account in a terminal-friendly table.",
|
|
1009
|
+
examples: ["jungle jobs"],
|
|
1010
|
+
steps: [
|
|
1011
|
+
"Submit a workload with jungle submit.",
|
|
1012
|
+
"Run jungle jobs to find the most recent ID and current status.",
|
|
1013
|
+
"Inspect one workload in detail with jungle status <job-id>.",
|
|
1014
|
+
],
|
|
1015
|
+
}))
|
|
1016
|
+
.action(async () => {
|
|
1017
|
+
// Fast local check to provide clear auth guidance before network calls.
|
|
1018
|
+
if (!(0, auth_1.readCredentials)()) {
|
|
1019
|
+
console.log((0, ui_1.section)("Authentication required", (0, ui_1.listLines)([`Run ${(0, ui_1.code)("jungle login")} to authenticate.`]), "warning"));
|
|
1020
|
+
process.exit(1);
|
|
1021
|
+
}
|
|
1022
|
+
try {
|
|
1023
|
+
const result = await (0, api_1.apiRequest)("GET", "/v1/jobs");
|
|
1024
|
+
const jobsFromApi = Array.isArray(result.jobs) ? result.jobs : [];
|
|
1025
|
+
// Default behavior: show all owner-filtered jobs from the API,
|
|
1026
|
+
// including terminal states like completed/failed.
|
|
1027
|
+
const jobs = [...jobsFromApi].sort((a, b) => (b.CreatedAtUnix ?? 0) - (a.CreatedAtUnix ?? 0));
|
|
1028
|
+
if (jobs.length === 0) {
|
|
1029
|
+
console.log((0, ui_1.joinBlocks)([
|
|
1030
|
+
(0, ui_1.section)("No jobs found", ["No workloads are currently associated with this account."], "warning"),
|
|
1031
|
+
(0, ui_1.section)("Next step", (0, ui_1.listLines)([`Queue one with ${(0, ui_1.code)("jungle submit ...")}.`]), "info"),
|
|
1032
|
+
]));
|
|
1033
|
+
return;
|
|
1034
|
+
}
|
|
1035
|
+
// PILLAR 1: Render intent and lifecycle fields only.
|
|
1036
|
+
// We intentionally do not display any hardware-specific API fields.
|
|
1037
|
+
const rows = jobs.map((job) => [
|
|
1038
|
+
fitColumn(job.ID ?? "", JOB_TABLE_COLUMNS.id),
|
|
1039
|
+
fitColumn(job.Name ?? "", JOB_TABLE_COLUMNS.name),
|
|
1040
|
+
(0, ui_1.statusLabel)(fitColumn(job.Status ?? "unknown", JOB_TABLE_COLUMNS.status)),
|
|
1041
|
+
fitColumn(job.WorkloadType ?? "", JOB_TABLE_COLUMNS.workload),
|
|
1042
|
+
fitColumn(job.OptimizeFor ?? "balanced", JOB_TABLE_COLUMNS.optimize),
|
|
1043
|
+
fitColumn(formatUnixSeconds(job.CreatedAtUnix), JOB_TABLE_COLUMNS.created),
|
|
1044
|
+
fitColumn(formatUnixSeconds(job.UpdatedAtUnix), JOB_TABLE_COLUMNS.updated),
|
|
1045
|
+
]);
|
|
1046
|
+
console.log((0, ui_1.joinBlocks)([
|
|
1047
|
+
(0, ui_1.section)("Jobs overview", [
|
|
1048
|
+
`${jobs.length} workload${jobs.length === 1 ? "" : "s"} returned for the current account.`,
|
|
1049
|
+
], "info"),
|
|
1050
|
+
(0, ui_1.renderTable)(["ID", "NAME", "STATUS", "WORKLOAD", "OPTIMIZE", "CREATED", "UPDATED"], rows),
|
|
1051
|
+
]));
|
|
1052
|
+
}
|
|
1053
|
+
catch (err) {
|
|
1054
|
+
if (err instanceof api_1.ApiError && err.status === 401) {
|
|
1055
|
+
(0, auth_1.clearCredentials)();
|
|
1056
|
+
console.error((0, ui_1.danger)(`Session expired. Run ${(0, ui_1.code)("jungle login")} to re-authenticate.`));
|
|
1057
|
+
process.exit(1);
|
|
1058
|
+
}
|
|
1059
|
+
console.error((0, ui_1.danger)(renderApiError("Failed to list jobs", err)));
|
|
1060
|
+
process.exit(1);
|
|
1061
|
+
}
|
|
1062
|
+
});
|
|
1063
|
+
// ─── jungle nodes ──────────────────────────────────────────────────────────
|
|
1064
|
+
const nodesCommand = program
|
|
1065
|
+
.command("nodes")
|
|
1066
|
+
.description("List available nodes in the pool")
|
|
1067
|
+
.option("--workload <type>", "Filter nodes eligible for a workload type")
|
|
1068
|
+
.addHelpText("after", renderGuide({
|
|
1069
|
+
summary: "Lists currently registered nodes with marketplace-facing signals such as health, reliability, queue depth, and price.",
|
|
1070
|
+
examples: [
|
|
1071
|
+
"jungle nodes",
|
|
1072
|
+
"jungle nodes --workload inference",
|
|
1073
|
+
"jungle nodes show node-abc123",
|
|
1074
|
+
],
|
|
1075
|
+
notes: [
|
|
1076
|
+
"Use jungle nodes show <node-id> for the full remote node detail view.",
|
|
1077
|
+
"Workload filtering is currently best-effort and may expand in future API versions.",
|
|
1078
|
+
],
|
|
1079
|
+
}))
|
|
1080
|
+
.action(async (opts) => {
|
|
1081
|
+
try {
|
|
1082
|
+
let endpoint = "/v1/nodes";
|
|
1083
|
+
// When logged in as a provider, prefer the provider-owned node listing endpoint.
|
|
1084
|
+
// Fall back to the public list for unauthenticated sessions and non-provider roles.
|
|
1085
|
+
const creds = (0, auth_1.readCredentials)();
|
|
1086
|
+
if (creds) {
|
|
1087
|
+
try {
|
|
1088
|
+
const me = await (0, api_1.apiRequest)("GET", "/auth/me");
|
|
1089
|
+
if (me.role === "provider") {
|
|
1090
|
+
endpoint = "/v1/nodes/mine";
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
catch (err) {
|
|
1094
|
+
if (err instanceof api_1.ApiError && err.status === 401) {
|
|
1095
|
+
(0, auth_1.clearCredentials)();
|
|
1096
|
+
console.error((0, ui_1.danger)(`Session expired. Run ${(0, ui_1.code)("jungle login")} to re-authenticate.`));
|
|
1097
|
+
process.exit(1);
|
|
1098
|
+
}
|
|
1099
|
+
// If identity lookup fails for transient reasons, keep global list fallback.
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
// TODO: Pillar 2 — When the API supports GET /v1/nodes?workload_type=...,
|
|
1103
|
+
// pass the filter server-side so the classifier determines eligibility.
|
|
1104
|
+
// For now we fetch nodes from either the global or provider-owned endpoint.
|
|
1105
|
+
const result = await (0, api_1.apiRequest)("GET", endpoint);
|
|
1106
|
+
if (!result.nodes || result.nodes.length === 0) {
|
|
1107
|
+
console.log((0, ui_1.section)("No nodes found", ["No nodes are currently registered for this view."], "warning"));
|
|
1108
|
+
return;
|
|
1109
|
+
}
|
|
1110
|
+
const rows = result.nodes.map((node) => {
|
|
1111
|
+
const normalized = normalizeNodeRecord(node);
|
|
1112
|
+
return [
|
|
1113
|
+
fitColumn(normalized.id, 20),
|
|
1114
|
+
(0, ui_1.statusLabel)(fitColumn(normalized.status ?? "unknown", 12)),
|
|
1115
|
+
healthLabel(fitColumn(normalized.healthStatus ?? "unknown", 10)),
|
|
1116
|
+
fitColumn(normalized.location ?? "-", 15),
|
|
1117
|
+
formatInteger(normalized.queueDepth, "0"),
|
|
1118
|
+
formatCurrency(normalized.pricePerHour, "0.00"),
|
|
1119
|
+
formatNumber(normalized.reliabilityScore, 2, "-"),
|
|
1120
|
+
formatNumber(normalized.latencyScore, 2, "-"),
|
|
1121
|
+
formatNumber(normalized.performanceScore, 2, "-"),
|
|
1122
|
+
];
|
|
1123
|
+
});
|
|
1124
|
+
console.log((0, ui_1.joinBlocks)([
|
|
1125
|
+
(0, ui_1.section)(endpoint === "/v1/nodes/mine" ? "Provider nodes" : "Global node view", [
|
|
1126
|
+
`${result.nodes.length} node${result.nodes.length === 1 ? "" : "s"} returned${opts.workload ? ` for workload ${(0, ui_1.strong)(opts.workload)}` : ""}.`,
|
|
1127
|
+
], "info"),
|
|
1128
|
+
(0, ui_1.renderTable)(["ID", "STATUS", "HEALTH", "LOCATION", "QUEUE", "$/HR", "RELIAB", "LAT", "PERF"], rows),
|
|
1129
|
+
]));
|
|
1130
|
+
}
|
|
1131
|
+
catch (err) {
|
|
1132
|
+
console.error((0, ui_1.danger)(renderApiError("Failed to list nodes", err)));
|
|
1133
|
+
process.exit(1);
|
|
1134
|
+
}
|
|
1135
|
+
});
|
|
1136
|
+
nodesCommand
|
|
1137
|
+
.command("show <node-id>")
|
|
1138
|
+
.description("Show full remote marketplace detail for one node")
|
|
1139
|
+
.addHelpText("after", renderGuide({
|
|
1140
|
+
summary: "Shows the full remote node record, including hardware, pricing, scheduling signals, and heartbeat state.",
|
|
1141
|
+
examples: [
|
|
1142
|
+
"jungle nodes show node-abc123",
|
|
1143
|
+
],
|
|
1144
|
+
notes: [
|
|
1145
|
+
"This command inspects the remote marketplace node, not the local node-agent daemon.",
|
|
1146
|
+
`Use ${(0, ui_1.code)("jungle node status")} for the local daemon process on this machine.`,
|
|
1147
|
+
],
|
|
1148
|
+
}))
|
|
1149
|
+
.action(async (nodeId) => {
|
|
1150
|
+
try {
|
|
1151
|
+
const result = await (0, api_1.apiRequest)("GET", "/v1/nodes");
|
|
1152
|
+
const normalizedNodes = (result.nodes ?? []).map(normalizeNodeRecord);
|
|
1153
|
+
const node = normalizedNodes.find((candidate) => candidate.id === nodeId);
|
|
1154
|
+
if (!node) {
|
|
1155
|
+
console.log((0, ui_1.joinBlocks)([
|
|
1156
|
+
(0, ui_1.section)("Node not found", [`No remote node matched ${(0, ui_1.strong)(nodeId)} in the current marketplace view.`], "warning"),
|
|
1157
|
+
(0, ui_1.section)("Next step", (0, ui_1.listLines)([`Run ${(0, ui_1.code)("jungle nodes")} to inspect the available node IDs.`]), "info"),
|
|
1158
|
+
]));
|
|
1159
|
+
process.exit(1);
|
|
1160
|
+
}
|
|
1161
|
+
console.log((0, ui_1.joinBlocks)([
|
|
1162
|
+
(0, ui_1.section)("Remote node detail", (0, ui_1.kvLines)([
|
|
1163
|
+
["Node ID", (0, ui_1.strong)(node.id)],
|
|
1164
|
+
["Status", (0, ui_1.statusLabel)(node.status ?? "unknown")],
|
|
1165
|
+
["Health", healthLabel(node.healthStatus)],
|
|
1166
|
+
["Location", formatText(node.location)],
|
|
1167
|
+
["Last heartbeat", formatHeartbeatTimestamp(node.lastHeartbeatUnix)],
|
|
1168
|
+
]), "info"),
|
|
1169
|
+
(0, ui_1.section)("Marketplace signals", (0, ui_1.kvLines)([
|
|
1170
|
+
["Price / hour", formatCurrency(node.pricePerHour)],
|
|
1171
|
+
["Reliability", formatNumber(node.reliabilityScore)],
|
|
1172
|
+
["Latency", formatNumber(node.latencyScore)],
|
|
1173
|
+
["Performance", formatNumber(node.performanceScore)],
|
|
1174
|
+
["Queue depth", formatInteger(node.queueDepth)],
|
|
1175
|
+
["Thermal throttled", formatBoolean(node.thermalThrottled)],
|
|
1176
|
+
]), "primary"),
|
|
1177
|
+
(0, ui_1.section)("Hardware profile", (0, ui_1.kvLines)([
|
|
1178
|
+
["GPU type", formatText(node.gpuType)],
|
|
1179
|
+
["VRAM", formatGigabytes(node.vramGB)],
|
|
1180
|
+
["GPU count", formatInteger(node.gpuCount)],
|
|
1181
|
+
["CUDA support", formatBoolean(node.cudaSupport)],
|
|
1182
|
+
["CUDA version", formatText(node.cudaVersion)],
|
|
1183
|
+
["Compute capability", formatText(node.computeCapability)],
|
|
1184
|
+
]), "muted"),
|
|
1185
|
+
(0, ui_1.section)("Control plane", (0, ui_1.kvLines)([
|
|
1186
|
+
["Dispatch URL", formatText(node.dispatchURL)],
|
|
1187
|
+
]), "muted"),
|
|
1188
|
+
]));
|
|
1189
|
+
}
|
|
1190
|
+
catch (err) {
|
|
1191
|
+
console.error((0, ui_1.danger)(renderApiError("Failed to show node detail", err)));
|
|
1192
|
+
process.exit(1);
|
|
1193
|
+
}
|
|
1194
|
+
});
|
|
1195
|
+
// ─── jungle login ──────────────────────────────────────────────────────────
|
|
1196
|
+
program
|
|
1197
|
+
.command("login")
|
|
1198
|
+
.description("Authenticate with Jungle Grid via browser-based login")
|
|
1199
|
+
.option("--no-browser", "Do not try to open a local browser; print login instructions and keep polling")
|
|
1200
|
+
.addHelpText("after", renderGuide({
|
|
1201
|
+
summary: "Starts browser-based device login and stores the resulting session locally on this machine.",
|
|
1202
|
+
examples: ["jungle login", "jungle login --no-browser"],
|
|
1203
|
+
behavior: [
|
|
1204
|
+
"If already logged in, the CLI reports the active session and does not restart login.",
|
|
1205
|
+
"If the stored token has expired, credentials are cleared and login restarts cleanly.",
|
|
1206
|
+
"The login URL can be opened in any browser, including one on another machine.",
|
|
1207
|
+
],
|
|
1208
|
+
}))
|
|
1209
|
+
.action(async (opts) => {
|
|
1210
|
+
try {
|
|
1211
|
+
// If credentials already exist, verify before starting a new login flow.
|
|
1212
|
+
// This avoids unnecessary device-flow prompts for users who are already authenticated.
|
|
1213
|
+
const existingCreds = (0, auth_1.readCredentials)();
|
|
1214
|
+
if (existingCreds) {
|
|
1215
|
+
try {
|
|
1216
|
+
const me = await (0, api_1.apiRequest)("GET", "/auth/me");
|
|
1217
|
+
console.log(renderSummary("Session already active", [
|
|
1218
|
+
["Email", (0, ui_1.strong)(me.email)],
|
|
1219
|
+
["Account ID", me.id],
|
|
1220
|
+
], "info", [`Run ${(0, ui_1.code)("jungle logout")} first if you want to switch accounts.`]));
|
|
1221
|
+
return;
|
|
1222
|
+
}
|
|
1223
|
+
catch (err) {
|
|
1224
|
+
if (err instanceof api_1.ApiError && err.status === 401) {
|
|
1225
|
+
// Expired/revoked token: clear and continue with normal login below.
|
|
1226
|
+
(0, auth_1.clearCredentials)();
|
|
1227
|
+
}
|
|
1228
|
+
else {
|
|
1229
|
+
// If verification fails for non-auth reasons, preserve current local session.
|
|
1230
|
+
if (existingCreds.user?.email && existingCreds.user?.id) {
|
|
1231
|
+
console.log(renderSummary("Offline session detected", [
|
|
1232
|
+
["Email", (0, ui_1.strong)(existingCreds.user.email)],
|
|
1233
|
+
["Account ID", existingCreds.user.id],
|
|
1234
|
+
["Verification", (0, ui_1.warning)("offline - could not verify token")],
|
|
1235
|
+
], "warning", [`Run ${(0, ui_1.code)("jungle logout")} first if you want to switch accounts.`]));
|
|
1236
|
+
}
|
|
1237
|
+
else {
|
|
1238
|
+
console.log((0, ui_1.section)("Offline session detected", ["Stored credentials exist but the session could not be verified right now."], "warning"));
|
|
1239
|
+
}
|
|
1240
|
+
return;
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
// Step 1: Generate a random device code to link this CLI session
|
|
1245
|
+
// to the browser-based login flow.
|
|
1246
|
+
const deviceCode = generateDeviceCode();
|
|
1247
|
+
// Step 2: Register the device code with the API.
|
|
1248
|
+
const authResponse = await (0, api_1.apiRequest)("POST", "/auth/device", { device_code: deviceCode });
|
|
1249
|
+
// Step 3: Print device-login details in a format that works for both
|
|
1250
|
+
// desktop and headless sessions. Browser launch is optional convenience;
|
|
1251
|
+
// the actual auth contract is the device code plus login URL.
|
|
1252
|
+
printDeviceLoginInstructions(authResponse.login_url, deviceCode);
|
|
1253
|
+
if (opts.noBrowser) {
|
|
1254
|
+
console.log((0, ui_1.section)("Browser launch", ["Auto-open disabled. Continue with the URL above."], "warning"));
|
|
1255
|
+
}
|
|
1256
|
+
else {
|
|
1257
|
+
const launchResult = await launchBrowserBestEffort(authResponse.login_url);
|
|
1258
|
+
if (launchResult.launched) {
|
|
1259
|
+
console.log((0, ui_1.section)("Browser launch", ["Opening a local browser for authentication."], "info"));
|
|
1260
|
+
}
|
|
1261
|
+
else if (!launchResult.attempted) {
|
|
1262
|
+
console.log((0, ui_1.section)("Browser launch", [launchResult.reason || "Open the login URL on any machine to continue."], "warning"));
|
|
1263
|
+
}
|
|
1264
|
+
else {
|
|
1265
|
+
console.log((0, ui_1.section)("Browser launch", ["Could not open a local browser. Continue manually with the login details above."], "warning"));
|
|
1266
|
+
if (launchResult.reason) {
|
|
1267
|
+
console.log((0, ui_1.muted)(` detail ${launchResult.reason}`));
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
// Step 4: Poll for token completion.
|
|
1272
|
+
// The browser step may happen on this machine or a different one. The CLI
|
|
1273
|
+
// simply polls until the server confirms authentication or times out.
|
|
1274
|
+
console.log((0, ui_1.section)("Waiting for confirmation", [
|
|
1275
|
+
`Polling every ${LOGIN_POLL_INTERVAL_MS / 1000}s for up to ${LOGIN_TIMEOUT_MS / 60000} minutes.`,
|
|
1276
|
+
"Leave this terminal open while you finish the browser step.",
|
|
1277
|
+
], "info"));
|
|
1278
|
+
const startTime = Date.now();
|
|
1279
|
+
while (Date.now() - startTime < LOGIN_TIMEOUT_MS) {
|
|
1280
|
+
await sleep(LOGIN_POLL_INTERVAL_MS);
|
|
1281
|
+
const tokenResult = await (0, api_1.apiPoll)(`/auth/token?device_code=${encodeURIComponent(deviceCode)}`);
|
|
1282
|
+
if (tokenResult) {
|
|
1283
|
+
// Step 5: Store credentials locally.
|
|
1284
|
+
// SECURITY: The token itself is never printed to the console.
|
|
1285
|
+
(0, auth_1.writeCredentials)({
|
|
1286
|
+
token: tokenResult.token,
|
|
1287
|
+
user: tokenResult.user,
|
|
1288
|
+
});
|
|
1289
|
+
console.log(renderSummary("Login complete", [
|
|
1290
|
+
["Email", (0, ui_1.strong)(tokenResult.user.email)],
|
|
1291
|
+
["Role", tokenResult.user.role ? (0, ui_1.statusLabel)(tokenResult.user.role) : (0, ui_1.muted)("unknown")],
|
|
1292
|
+
], "success", [
|
|
1293
|
+
`Check the active session with ${(0, ui_1.code)("jungle whoami")}.`,
|
|
1294
|
+
`Start working with ${(0, ui_1.code)("jungle jobs")} or ${(0, ui_1.code)("jungle nodes")}.`,
|
|
1295
|
+
]));
|
|
1296
|
+
return;
|
|
1297
|
+
}
|
|
1298
|
+
// null means 202 (still pending) — continue polling.
|
|
1299
|
+
}
|
|
1300
|
+
// Timeout reached without successful login.
|
|
1301
|
+
console.error((0, ui_1.danger)(`Login timed out. Please try again with ${(0, ui_1.code)("jungle login")}.`));
|
|
1302
|
+
process.exit(1);
|
|
1303
|
+
}
|
|
1304
|
+
catch (err) {
|
|
1305
|
+
console.error((0, ui_1.danger)(renderApiError("Login failed", err)));
|
|
1306
|
+
process.exit(1);
|
|
1307
|
+
}
|
|
1308
|
+
});
|
|
1309
|
+
// ─── jungle logout ─────────────────────────────────────────────────────────
|
|
1310
|
+
program
|
|
1311
|
+
.command("logout")
|
|
1312
|
+
.description("Clear stored authentication credentials")
|
|
1313
|
+
.addHelpText("after", renderGuide({
|
|
1314
|
+
summary: "Removes locally stored credentials for the current CLI session.",
|
|
1315
|
+
examples: ["jungle logout"],
|
|
1316
|
+
notes: ["Run jungle login again after logout to switch accounts."],
|
|
1317
|
+
}))
|
|
1318
|
+
.action(() => {
|
|
1319
|
+
(0, auth_1.clearCredentials)();
|
|
1320
|
+
console.log((0, ui_1.section)("Session cleared", ["Stored credentials were removed from this machine."], "success"));
|
|
1321
|
+
});
|
|
1322
|
+
// ─── jungle whoami ─────────────────────────────────────────────────────────
|
|
1323
|
+
program
|
|
1324
|
+
.command("whoami")
|
|
1325
|
+
.description("Print the currently authenticated user")
|
|
1326
|
+
.addHelpText("after", renderGuide({
|
|
1327
|
+
summary: "Prints the identity currently associated with the stored session.",
|
|
1328
|
+
examples: ["jungle whoami"],
|
|
1329
|
+
notes: ["If the session has expired, run jungle login to re-authenticate."],
|
|
1330
|
+
}))
|
|
1331
|
+
.action(async () => {
|
|
1332
|
+
// First check local credentials for a quick response.
|
|
1333
|
+
const creds = (0, auth_1.readCredentials)();
|
|
1334
|
+
if (!creds) {
|
|
1335
|
+
console.log((0, ui_1.section)("Authentication required", (0, ui_1.listLines)([`Run ${(0, ui_1.code)("jungle login")} to authenticate.`]), "warning"));
|
|
1336
|
+
process.exit(1);
|
|
1337
|
+
}
|
|
1338
|
+
try {
|
|
1339
|
+
// Verify the token is still valid by calling the /auth/me endpoint.
|
|
1340
|
+
const me = await (0, api_1.apiRequest)("GET", "/auth/me");
|
|
1341
|
+
console.log(renderSummary("Active session", [
|
|
1342
|
+
["Email", (0, ui_1.strong)(me.email)],
|
|
1343
|
+
["Account ID", me.id],
|
|
1344
|
+
["Role", me.role ? (0, ui_1.statusLabel)(me.role) : (0, ui_1.muted)("unknown")],
|
|
1345
|
+
], "info"));
|
|
1346
|
+
}
|
|
1347
|
+
catch (err) {
|
|
1348
|
+
if (err instanceof api_1.ApiError && err.status === 401) {
|
|
1349
|
+
// Token expired or revoked.
|
|
1350
|
+
console.log((0, ui_1.section)("Session expired", (0, ui_1.listLines)([`Run ${(0, ui_1.code)("jungle login")} to re-authenticate.`]), "warning"));
|
|
1351
|
+
(0, auth_1.clearCredentials)();
|
|
1352
|
+
process.exit(1);
|
|
1353
|
+
}
|
|
1354
|
+
// Server unreachable — fall back to locally stored info.
|
|
1355
|
+
console.log(`Logged in as ${creds.user.email} (${creds.user.id}) [offline — could not verify token]`);
|
|
1356
|
+
}
|
|
1357
|
+
});
|
|
1358
|
+
// ─── jungle shell ──────────────────────────────────────────────────────────
|
|
1359
|
+
program
|
|
1360
|
+
.command("shell")
|
|
1361
|
+
.description("Start an interactive REPL shell session")
|
|
1362
|
+
.addHelpText("after", renderGuide({
|
|
1363
|
+
summary: "Opens an interactive prompt so you can stay inside Jungle Grid while running multiple commands.",
|
|
1364
|
+
examples: ["jungle shell"],
|
|
1365
|
+
notes: [
|
|
1366
|
+
`Inside the shell, try ${(0, ui_1.code)("help")}, ${(0, ui_1.code)("jobs")}, or ${(0, ui_1.code)("submit --workload inference --model-size 7 --image ...")}.`,
|
|
1367
|
+
"Use exit or quit to leave the shell.",
|
|
1368
|
+
],
|
|
1369
|
+
}))
|
|
1370
|
+
.action(async () => {
|
|
1371
|
+
await (0, repl_1.startRepl)(program);
|
|
1372
|
+
});
|
|
1373
|
+
// ─── jungle node register/start/stop/status ───────────────────────────────
|
|
1374
|
+
const nodeCommand = program
|
|
1375
|
+
.command("node")
|
|
1376
|
+
.description("Node owner workflows: register and manage node-agent runtime");
|
|
1377
|
+
nodeCommand
|
|
1378
|
+
.command("register")
|
|
1379
|
+
.alias("registration")
|
|
1380
|
+
.description("Register this machine as a provider node")
|
|
1381
|
+
.option("--dispatch-url <url>", "Public dispatch URL for this node")
|
|
1382
|
+
.option("--location <location>", "Node location label", "local")
|
|
1383
|
+
.option("--price-per-hour <amount>", "Node hourly price", "1.0")
|
|
1384
|
+
.option("--reliability-score <score>", "Reliability score [0..1]", "0.90")
|
|
1385
|
+
.option("--performance-score <score>", "Performance score [0..1]", "0.85")
|
|
1386
|
+
.addHelpText("after", renderGuide({
|
|
1387
|
+
summary: "Registers this machine as a provider node endpoint and stores the returned local node configuration.",
|
|
1388
|
+
examples: [
|
|
1389
|
+
"jungle node register --dispatch-url http://127.0.0.1:8090 --location lagos",
|
|
1390
|
+
],
|
|
1391
|
+
notes: [
|
|
1392
|
+
"Requires provider login.",
|
|
1393
|
+
"Requires nvidia-smi unless JUNGLE_SIMULATION_MODE is enabled.",
|
|
1394
|
+
"Validates payout account details through backend Paystack validation.",
|
|
1395
|
+
],
|
|
1396
|
+
}))
|
|
1397
|
+
.action(async (opts) => {
|
|
1398
|
+
try {
|
|
1399
|
+
const creds = (0, auth_1.readCredentials)();
|
|
1400
|
+
ensureProviderSessionOrFail(creds);
|
|
1401
|
+
const me = await (0, api_1.apiRequest)("GET", "/auth/me");
|
|
1402
|
+
if (me.role !== "provider") {
|
|
1403
|
+
console.error((0, ui_1.danger)("Only provider accounts can register nodes."));
|
|
1404
|
+
process.exit(1);
|
|
1405
|
+
}
|
|
1406
|
+
if (isSimulationModeEnabled()) {
|
|
1407
|
+
console.log((0, ui_1.section)("Simulation mode", ["JUNGLE_SIMULATION_MODE is enabled; using the simulated GPU profile."], "warning"));
|
|
1408
|
+
}
|
|
1409
|
+
const hardware = detectNodeHardwareOrFail();
|
|
1410
|
+
const prompts = await collectNodeRegistrationPrompts(me.email);
|
|
1411
|
+
const dispatchURL = await resolveDispatchURL(opts.dispatchUrl);
|
|
1412
|
+
const apiUrl = (0, config_1.getApiUrl)();
|
|
1413
|
+
const payload = {
|
|
1414
|
+
node_id: `node-${Date.now().toString(36)}`,
|
|
1415
|
+
email: prompts.email,
|
|
1416
|
+
payout: {
|
|
1417
|
+
bank_name: prompts.bankName,
|
|
1418
|
+
account_number: prompts.accountNumber,
|
|
1419
|
+
account_name: prompts.accountName,
|
|
1420
|
+
},
|
|
1421
|
+
hardware: {
|
|
1422
|
+
gpu: hardware.gpu,
|
|
1423
|
+
vram_gb: hardware.vramGb,
|
|
1424
|
+
gpu_count: hardware.gpuCount,
|
|
1425
|
+
cpu_cores: hardware.cpuCores,
|
|
1426
|
+
cpu_mhz: hardware.cpuMHz,
|
|
1427
|
+
ram_gb: hardware.ramGB,
|
|
1428
|
+
cuda_version: hardware.cudaVersion,
|
|
1429
|
+
cuda_support: hardware.cudaSupport,
|
|
1430
|
+
compute_capability: hardware.computeCapability,
|
|
1431
|
+
},
|
|
1432
|
+
network: {
|
|
1433
|
+
latency_ms: await measureLatencyMs(apiUrl),
|
|
1434
|
+
},
|
|
1435
|
+
environment: {
|
|
1436
|
+
os: hardware.os,
|
|
1437
|
+
location: opts.location,
|
|
1438
|
+
dispatch_url: dispatchURL,
|
|
1439
|
+
},
|
|
1440
|
+
price_per_hour: parseFloat(opts.pricePerHour),
|
|
1441
|
+
reliability_score: parseFloat(opts.reliabilityScore),
|
|
1442
|
+
performance_score: parseFloat(opts.performanceScore),
|
|
1443
|
+
};
|
|
1444
|
+
const registered = await (0, api_1.apiRequest)("POST", "/v1/nodes/register", payload);
|
|
1445
|
+
(0, node_config_1.writeNodeConfig)({
|
|
1446
|
+
node_id: registered.node_id,
|
|
1447
|
+
api_key: registered.api_key,
|
|
1448
|
+
email: prompts.email,
|
|
1449
|
+
orchestrator_url: apiUrl,
|
|
1450
|
+
dispatch_url: dispatchURL,
|
|
1451
|
+
gpu: hardware.gpu,
|
|
1452
|
+
vram_gb: hardware.vramGb,
|
|
1453
|
+
gpu_count: hardware.gpuCount,
|
|
1454
|
+
cpu_cores: hardware.cpuCores,
|
|
1455
|
+
cpu_mhz: hardware.cpuMHz,
|
|
1456
|
+
ram_gb: hardware.ramGB,
|
|
1457
|
+
cuda_version: hardware.cudaVersion,
|
|
1458
|
+
cuda_support: hardware.cudaSupport,
|
|
1459
|
+
compute_capability: hardware.computeCapability,
|
|
1460
|
+
os: hardware.os,
|
|
1461
|
+
location: opts.location,
|
|
1462
|
+
latency_ms: payload.network.latency_ms,
|
|
1463
|
+
price_per_hour: payload.price_per_hour,
|
|
1464
|
+
reliability_score: payload.reliability_score,
|
|
1465
|
+
performance_score: payload.performance_score,
|
|
1466
|
+
registered_status: registered.status,
|
|
1467
|
+
});
|
|
1468
|
+
console.log(renderSummary("Node registered", [
|
|
1469
|
+
["Node ID", (0, ui_1.strong)(registered.node_id)],
|
|
1470
|
+
["Status", (0, ui_1.statusLabel)(registered.status)],
|
|
1471
|
+
["Config", (0, ui_1.pathLabel)((0, node_config_1.nodeConfigFilePath)())],
|
|
1472
|
+
], "success", [
|
|
1473
|
+
`Activate the runtime with ${(0, ui_1.code)("jungle node start --daemon")} ${(0, ui_1.muted)("(installs node-agent automatically when needed)")}.`,
|
|
1474
|
+
`Inspect runtime state with ${(0, ui_1.code)("jungle node status")}.`,
|
|
1475
|
+
]));
|
|
1476
|
+
}
|
|
1477
|
+
catch (err) {
|
|
1478
|
+
if (err instanceof api_1.ApiError && err.status === 401) {
|
|
1479
|
+
(0, auth_1.clearCredentials)();
|
|
1480
|
+
console.error((0, ui_1.danger)(`Session expired. Run ${(0, ui_1.code)("jungle login")} again.`));
|
|
1481
|
+
}
|
|
1482
|
+
else {
|
|
1483
|
+
console.error((0, ui_1.danger)(renderApiError("Node registration failed", err)));
|
|
1484
|
+
}
|
|
1485
|
+
process.exit(1);
|
|
1486
|
+
}
|
|
1487
|
+
});
|
|
1488
|
+
nodeCommand
|
|
1489
|
+
.command("install-agent")
|
|
1490
|
+
.description("Install the managed node-agent binary for this machine")
|
|
1491
|
+
.option("--force", "Re-download the node-agent even if a verified copy already exists")
|
|
1492
|
+
.addHelpText("after", renderGuide({
|
|
1493
|
+
summary: "Downloads the node-agent release binary that matches the installed CLI version and current platform.",
|
|
1494
|
+
examples: [
|
|
1495
|
+
"jungle node install-agent",
|
|
1496
|
+
"jungle node install-agent --force",
|
|
1497
|
+
],
|
|
1498
|
+
notes: [
|
|
1499
|
+
"This command does not require node registration.",
|
|
1500
|
+
"The binary is stored under ~/.jungle-grid/bin/node-agent/<version>.",
|
|
1501
|
+
`Most providers can skip this and just run ${(0, ui_1.code)("jungle node start")}, which auto-installs on demand.`,
|
|
1502
|
+
],
|
|
1503
|
+
}))
|
|
1504
|
+
.action(async (opts) => {
|
|
1505
|
+
try {
|
|
1506
|
+
const result = await (0, node_agent_installer_1.ensureManagedNodeAgentInstalled)(CLI_VERSION, {
|
|
1507
|
+
force: Boolean(opts.force),
|
|
1508
|
+
onProgress: (message) => console.log((0, ui_1.muted)(message)),
|
|
1509
|
+
});
|
|
1510
|
+
console.log(renderSummary(result.downloaded ? "Node-agent installed" : "Node-agent ready", [
|
|
1511
|
+
["Version", (0, ui_1.strong)(result.releaseTag)],
|
|
1512
|
+
["Platform", (0, ui_1.info)(result.platformLabel)],
|
|
1513
|
+
["Binary", (0, ui_1.pathLabel)(result.binaryPath)],
|
|
1514
|
+
], "success", result.downloaded
|
|
1515
|
+
? [
|
|
1516
|
+
`Start the runtime with ${(0, ui_1.code)("jungle node start --daemon")}.`,
|
|
1517
|
+
`Manual override still works via ${(0, ui_1.code)("jungle node start --binary <path>")}.`,
|
|
1518
|
+
]
|
|
1519
|
+
: [`The verified managed binary is already cached locally.`]));
|
|
1520
|
+
}
|
|
1521
|
+
catch (err) {
|
|
1522
|
+
console.error((0, ui_1.danger)(renderApiError("Node-agent install failed", err)));
|
|
1523
|
+
process.exit(1);
|
|
1524
|
+
}
|
|
1525
|
+
});
|
|
1526
|
+
nodeCommand
|
|
1527
|
+
.command("start")
|
|
1528
|
+
.description("Start node-agent process for this registered node")
|
|
1529
|
+
.option("--binary <path>", "Path to node-agent binary", "")
|
|
1530
|
+
.option("--daemon", "Run node-agent as a detached background process")
|
|
1531
|
+
.addHelpText("after", renderGuide({
|
|
1532
|
+
summary: "Launches the node-agent with the stored node configuration and optionally detaches it as a tracked daemon.",
|
|
1533
|
+
examples: [
|
|
1534
|
+
"jungle node start",
|
|
1535
|
+
"jungle node start --daemon",
|
|
1536
|
+
"jungle node start --binary go",
|
|
1537
|
+
"jungle node start --binary C:/tools/node-agent.exe",
|
|
1538
|
+
],
|
|
1539
|
+
notes: [
|
|
1540
|
+
"Requires prior jungle node register.",
|
|
1541
|
+
"Installs the matching managed node-agent automatically when no --binary is supplied.",
|
|
1542
|
+
"Use --daemon to run in the background with PID tracking.",
|
|
1543
|
+
"Starts the heartbeat loop and dispatch listener through the node-agent runtime.",
|
|
1544
|
+
],
|
|
1545
|
+
}))
|
|
1546
|
+
.action(async (opts) => {
|
|
1547
|
+
let daemonPid = null;
|
|
1548
|
+
let child = null;
|
|
1549
|
+
try {
|
|
1550
|
+
const config = (0, node_config_1.readNodeConfig)();
|
|
1551
|
+
if (!config) {
|
|
1552
|
+
console.error((0, ui_1.danger)(`No node config found. Run ${(0, ui_1.code)("jungle node register")} first.`));
|
|
1553
|
+
process.exit(1);
|
|
1554
|
+
}
|
|
1555
|
+
const trackedRuntime = (0, node_config_1.readNodeRuntime)();
|
|
1556
|
+
if (trackedRuntime) {
|
|
1557
|
+
if (isProcessRunning(trackedRuntime.pid)) {
|
|
1558
|
+
console.error((0, ui_1.danger)(`Node-agent daemon is already running (PID ${trackedRuntime.pid}).`));
|
|
1559
|
+
console.error((0, ui_1.muted)(`Use ${(0, ui_1.code)("jungle node status")} or ${(0, ui_1.code)("jungle node stop")}.`));
|
|
1560
|
+
process.exit(1);
|
|
1561
|
+
}
|
|
1562
|
+
(0, node_config_1.clearNodeRuntime)();
|
|
1563
|
+
}
|
|
1564
|
+
const daemonMode = Boolean(opts.daemon);
|
|
1565
|
+
const launch = await resolveNodeAgentLaunchSpec(opts.binary);
|
|
1566
|
+
const { binary, args, cwd: spawnCwd } = launch;
|
|
1567
|
+
const runtimeEnv = {
|
|
1568
|
+
...process.env,
|
|
1569
|
+
ORCH_BASE_URL: config.orchestrator_url,
|
|
1570
|
+
NODE_AGENT_ID: config.node_id,
|
|
1571
|
+
NODE_AGENT_KEY: config.api_key,
|
|
1572
|
+
NODE_AGENT_SECRET: process.env.NODE_AGENT_SECRET || "",
|
|
1573
|
+
NODE_GPU_TYPE: config.gpu,
|
|
1574
|
+
NODE_VRAM_GB: String(config.vram_gb),
|
|
1575
|
+
NODE_PRICE_PER_HOUR: String(config.price_per_hour),
|
|
1576
|
+
NODE_RELIABILITY_SCORE: String(config.reliability_score),
|
|
1577
|
+
NODE_PERFORMANCE_SCORE: String(config.performance_score),
|
|
1578
|
+
NODE_LATENCY_SCORE: String(Math.max(0.1, Math.min(0.99, 1 - (config.latency_ms / 500)))),
|
|
1579
|
+
NODE_DISPATCH_PUBLIC_URL: config.dispatch_url,
|
|
1580
|
+
};
|
|
1581
|
+
if (launch.installSummary?.downloaded) {
|
|
1582
|
+
console.log((0, ui_1.section)("Managed node-agent", [
|
|
1583
|
+
`Installed ${(0, ui_1.strong)(launch.installSummary.version)} for ${(0, ui_1.info)(launch.installSummary.platformLabel)}.`,
|
|
1584
|
+
`Binary ${(0, ui_1.pathLabel)(launch.installSummary.binaryPath)}`,
|
|
1585
|
+
], "success"));
|
|
1586
|
+
}
|
|
1587
|
+
if (daemonMode) {
|
|
1588
|
+
child = (0, child_process_1.spawn)(binary, args, {
|
|
1589
|
+
cwd: spawnCwd,
|
|
1590
|
+
detached: true,
|
|
1591
|
+
stdio: "ignore",
|
|
1592
|
+
env: runtimeEnv,
|
|
1593
|
+
});
|
|
1594
|
+
const pid = child.pid ?? 0;
|
|
1595
|
+
if (pid <= 0) {
|
|
1596
|
+
throw new Error("failed to start node-agent daemon process");
|
|
1597
|
+
}
|
|
1598
|
+
daemonPid = pid;
|
|
1599
|
+
child.unref();
|
|
1600
|
+
// Give the process a short window to fail fast (bad binary, env, etc).
|
|
1601
|
+
await sleep(600);
|
|
1602
|
+
if (!isProcessRunning(pid)) {
|
|
1603
|
+
throw new Error("node-agent daemon exited immediately");
|
|
1604
|
+
}
|
|
1605
|
+
(0, node_config_1.writeNodeRuntime)({
|
|
1606
|
+
node_id: config.node_id,
|
|
1607
|
+
pid,
|
|
1608
|
+
mode: "daemon",
|
|
1609
|
+
started_at_unix: Math.floor(Date.now() / 1000),
|
|
1610
|
+
binary,
|
|
1611
|
+
args,
|
|
1612
|
+
cwd: spawnCwd,
|
|
1613
|
+
});
|
|
1614
|
+
const activated = await (0, api_1.apiRequest)("POST", `/v1/nodes/${encodeURIComponent(config.node_id)}/activate`, {
|
|
1615
|
+
dispatch_url: config.dispatch_url,
|
|
1616
|
+
api_key: config.api_key,
|
|
1617
|
+
});
|
|
1618
|
+
console.log(renderSummary("Node daemon started", [
|
|
1619
|
+
["Node ID", (0, ui_1.strong)(activated.node_id)],
|
|
1620
|
+
["Status", (0, ui_1.statusLabel)(activated.status)],
|
|
1621
|
+
["PID", String(pid)],
|
|
1622
|
+
["Runtime", (0, ui_1.pathLabel)((0, node_config_1.nodeRuntimeFilePath)())],
|
|
1623
|
+
], "success", [
|
|
1624
|
+
`Verify health with ${(0, ui_1.code)("jungle node status")}.`,
|
|
1625
|
+
`Stop the daemon with ${(0, ui_1.code)("jungle node stop")}.`,
|
|
1626
|
+
]));
|
|
1627
|
+
return;
|
|
1628
|
+
}
|
|
1629
|
+
child = (0, child_process_1.spawn)(binary, args, {
|
|
1630
|
+
cwd: spawnCwd,
|
|
1631
|
+
stdio: "inherit",
|
|
1632
|
+
env: runtimeEnv,
|
|
1633
|
+
});
|
|
1634
|
+
await new Promise((resolve, reject) => {
|
|
1635
|
+
child?.once("spawn", () => resolve());
|
|
1636
|
+
child?.once("error", (spawnErr) => reject(spawnErr));
|
|
1637
|
+
});
|
|
1638
|
+
const activated = await (0, api_1.apiRequest)("POST", `/v1/nodes/${encodeURIComponent(config.node_id)}/activate`, {
|
|
1639
|
+
dispatch_url: config.dispatch_url,
|
|
1640
|
+
api_key: config.api_key,
|
|
1641
|
+
});
|
|
1642
|
+
if (child.exitCode !== null) {
|
|
1643
|
+
throw new Error(`node-agent exited before activation completed (code ${child.exitCode})`);
|
|
1644
|
+
}
|
|
1645
|
+
console.log(renderSummary("Node runtime attached", [
|
|
1646
|
+
["Node ID", (0, ui_1.strong)(activated.node_id)],
|
|
1647
|
+
["Status", (0, ui_1.statusLabel)(activated.status)],
|
|
1648
|
+
["Mode", (0, ui_1.info)("foreground")],
|
|
1649
|
+
], "success"));
|
|
1650
|
+
child.on("exit", (code) => {
|
|
1651
|
+
process.exit(typeof code === "number" ? code : 0);
|
|
1652
|
+
});
|
|
1653
|
+
}
|
|
1654
|
+
catch (err) {
|
|
1655
|
+
if (daemonPid !== null) {
|
|
1656
|
+
try {
|
|
1657
|
+
await stopProcessByPid(daemonPid);
|
|
1658
|
+
}
|
|
1659
|
+
catch {
|
|
1660
|
+
// Best-effort rollback.
|
|
1661
|
+
}
|
|
1662
|
+
(0, node_config_1.clearNodeRuntime)();
|
|
1663
|
+
}
|
|
1664
|
+
if (child && child.exitCode === null && !child.killed) {
|
|
1665
|
+
child.kill();
|
|
1666
|
+
}
|
|
1667
|
+
if (err instanceof api_1.ApiError && err.status === 401) {
|
|
1668
|
+
(0, auth_1.clearCredentials)();
|
|
1669
|
+
console.error((0, ui_1.danger)(`Session expired. Run ${(0, ui_1.code)("jungle login")} again.`));
|
|
1670
|
+
}
|
|
1671
|
+
else {
|
|
1672
|
+
console.error((0, ui_1.danger)(renderApiError("Node start failed", err)));
|
|
1673
|
+
}
|
|
1674
|
+
process.exit(1);
|
|
1675
|
+
}
|
|
1676
|
+
});
|
|
1677
|
+
nodeCommand
|
|
1678
|
+
.command("stop")
|
|
1679
|
+
.description("Stop node-agent daemon process")
|
|
1680
|
+
.addHelpText("after", renderGuide({
|
|
1681
|
+
summary: "Stops the node-agent process started with jungle node start --daemon.",
|
|
1682
|
+
examples: ["jungle node stop"],
|
|
1683
|
+
}))
|
|
1684
|
+
.action(async () => {
|
|
1685
|
+
try {
|
|
1686
|
+
const runtime = (0, node_config_1.readNodeRuntime)();
|
|
1687
|
+
if (!runtime) {
|
|
1688
|
+
console.log((0, ui_1.section)("No daemon runtime", ["No tracked node-agent daemon was found on this machine."], "warning"));
|
|
1689
|
+
return;
|
|
1690
|
+
}
|
|
1691
|
+
if (!isProcessRunning(runtime.pid)) {
|
|
1692
|
+
(0, node_config_1.clearNodeRuntime)();
|
|
1693
|
+
console.log((0, ui_1.section)("Runtime cleaned up", [`A stale PID record was removed for ${(0, ui_1.strong)(String(runtime.pid))}.`], "warning"));
|
|
1694
|
+
return;
|
|
1695
|
+
}
|
|
1696
|
+
await stopProcessByPid(runtime.pid);
|
|
1697
|
+
(0, node_config_1.clearNodeRuntime)();
|
|
1698
|
+
console.log(renderSummary("Node daemon stopped", [["PID", String(runtime.pid)]], "success"));
|
|
1699
|
+
}
|
|
1700
|
+
catch (err) {
|
|
1701
|
+
console.error((0, ui_1.danger)(renderApiError("Node stop failed", err)));
|
|
1702
|
+
process.exit(1);
|
|
1703
|
+
}
|
|
1704
|
+
});
|
|
1705
|
+
nodeCommand
|
|
1706
|
+
.command("status")
|
|
1707
|
+
.description("Show node-agent daemon status")
|
|
1708
|
+
.addHelpText("after", renderGuide({
|
|
1709
|
+
summary: "Shows whether a daemonized node-agent process is currently running on this machine.",
|
|
1710
|
+
examples: ["jungle node status"],
|
|
1711
|
+
}))
|
|
1712
|
+
.action(() => {
|
|
1713
|
+
const runtime = (0, node_config_1.readNodeRuntime)();
|
|
1714
|
+
if (!runtime) {
|
|
1715
|
+
console.log(renderSummary("Node daemon stopped", [["Runtime file", `${(0, ui_1.pathLabel)((0, node_config_1.nodeRuntimeFilePath)())} ${(0, ui_1.muted)("(not found)")}`]], "warning"));
|
|
1716
|
+
return;
|
|
1717
|
+
}
|
|
1718
|
+
const running = isProcessRunning(runtime.pid);
|
|
1719
|
+
if (!running) {
|
|
1720
|
+
(0, node_config_1.clearNodeRuntime)();
|
|
1721
|
+
console.log(renderSummary("Node daemon stopped", [["Last PID", `${runtime.pid} ${(0, ui_1.muted)("(stale PID removed)")}`]], "warning"));
|
|
1722
|
+
return;
|
|
1723
|
+
}
|
|
1724
|
+
console.log(renderSummary("Node daemon running", [
|
|
1725
|
+
["Node ID", (0, ui_1.strong)(runtime.node_id)],
|
|
1726
|
+
["PID", String(runtime.pid)],
|
|
1727
|
+
["Mode", (0, ui_1.info)(runtime.mode)],
|
|
1728
|
+
["Started", new Date(runtime.started_at_unix * 1000).toISOString()],
|
|
1729
|
+
["Binary", runtime.binary],
|
|
1730
|
+
["CWD", (0, ui_1.pathLabel)(runtime.cwd)],
|
|
1731
|
+
["Runtime", (0, ui_1.pathLabel)((0, node_config_1.nodeRuntimeFilePath)())],
|
|
1732
|
+
], "info"));
|
|
1733
|
+
});
|
|
1734
|
+
// ─── Parse and execute ─────────────────────────────────────────────────────
|
|
1735
|
+
function shouldPrintStartupBanner(argv) {
|
|
1736
|
+
if (!process.stdout.isTTY) {
|
|
1737
|
+
return false;
|
|
1738
|
+
}
|
|
1739
|
+
if (argv.length === 0) {
|
|
1740
|
+
return false;
|
|
1741
|
+
}
|
|
1742
|
+
if (argv.length === 1 && (argv[0] === "--version" || argv[0] === "-V")) {
|
|
1743
|
+
return false;
|
|
1744
|
+
}
|
|
1745
|
+
return true;
|
|
1746
|
+
}
|
|
1747
|
+
async function main() {
|
|
1748
|
+
const argv = process.argv.slice(2);
|
|
1749
|
+
// Start REPL only when no arguments are supplied.
|
|
1750
|
+
// This avoids recursive REPL startup when invalid commands are entered in REPL mode.
|
|
1751
|
+
if (argv.length === 0) {
|
|
1752
|
+
await (0, repl_1.startRepl)(program);
|
|
1753
|
+
return;
|
|
1754
|
+
}
|
|
1755
|
+
if (shouldPrintStartupBanner(argv)) {
|
|
1756
|
+
(0, banner_1.printBanner)(CLI_VERSION);
|
|
1757
|
+
}
|
|
1758
|
+
await program.parseAsync(process.argv);
|
|
1759
|
+
}
|
|
1760
|
+
main().catch((err) => {
|
|
1761
|
+
if (err instanceof Error) {
|
|
1762
|
+
console.error(`CLI failed: ${err.message}`);
|
|
1763
|
+
}
|
|
1764
|
+
else {
|
|
1765
|
+
console.error("CLI failed: unexpected error");
|
|
1766
|
+
}
|
|
1767
|
+
process.exit(1);
|
|
1768
|
+
});
|
|
1769
|
+
//# sourceMappingURL=index.js.map
|