sublera-cli 0.0.0-stage → 1.3.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/CHANGELOG.md +172 -0
- package/LICENSE +21 -0
- package/README.md +585 -2
- package/SECURITY.md +78 -0
- package/THIRD_PARTY_NOTICES.md +37 -0
- package/dist/agent.d.ts +270 -0
- package/dist/agent.js +1867 -0
- package/dist/commands.d.ts +76 -0
- package/dist/commands.js +841 -0
- package/dist/config.d.ts +27 -0
- package/dist/config.js +95 -0
- package/dist/engine/binaryManager.d.ts +67 -0
- package/dist/engine/binaryManager.js +590 -0
- package/dist/engine/gguf.d.ts +88 -0
- package/dist/engine/gguf.js +458 -0
- package/dist/engine/llamaRunner.d.ts +78 -0
- package/dist/engine/llamaRunner.js +282 -0
- package/dist/engine/modelManager.d.ts +63 -0
- package/dist/engine/modelManager.js +510 -0
- package/dist/engine/selfCheck.d.ts +34 -0
- package/dist/engine/selfCheck.js +108 -0
- package/dist/exitCodes.d.ts +20 -0
- package/dist/exitCodes.js +20 -0
- package/dist/hardware/gpuMonitor.d.ts +32 -0
- package/dist/hardware/gpuMonitor.js +378 -0
- package/dist/hardware/sleepGuard.d.ts +42 -0
- package/dist/hardware/sleepGuard.js +149 -0
- package/dist/hardware/types.d.ts +40 -0
- package/dist/hardware/types.js +2 -0
- package/dist/health.d.ts +17 -0
- package/dist/health.js +38 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +268 -0
- package/dist/logger.d.ts +53 -0
- package/dist/logger.js +201 -0
- package/dist/network/proxy.d.ts +58 -0
- package/dist/network/proxy.js +328 -0
- package/dist/network/streamProxy.d.ts +51 -0
- package/dist/network/streamProxy.js +286 -0
- package/dist/network/tunnelClient.d.ts +54 -0
- package/dist/network/tunnelClient.js +257 -0
- package/dist/settings.d.ts +53 -0
- package/dist/settings.js +171 -0
- package/dist/state.d.ts +52 -0
- package/dist/state.js +71 -0
- package/dist/storageCommand.d.ts +22 -0
- package/dist/storageCommand.js +81 -0
- package/dist/types.d.ts +222 -0
- package/dist/types.js +43 -0
- package/dist/ui/progress.d.ts +29 -0
- package/dist/ui/progress.js +82 -0
- package/dist/ui/terminalDashboard.d.ts +88 -0
- package/dist/ui/terminalDashboard.js +295 -0
- package/dist/util/env.d.ts +3 -0
- package/dist/util/env.js +6 -0
- package/dist/util/exec.d.ts +23 -0
- package/dist/util/exec.js +116 -0
- package/dist/util/format.d.ts +47 -0
- package/dist/util/format.js +125 -0
- package/dist/util/fs.d.ts +18 -0
- package/dist/util/fs.js +140 -0
- package/dist/util/http.d.ts +28 -0
- package/dist/util/http.js +128 -0
- package/dist/util/instanceLock.d.ts +3 -0
- package/dist/util/instanceLock.js +108 -0
- package/dist/util/open.d.ts +16 -0
- package/dist/util/open.js +65 -0
- package/dist/util/storage.d.ts +10 -0
- package/dist/util/storage.js +69 -0
- package/dist/version.d.ts +15 -0
- package/dist/version.js +29 -0
- package/package.json +68 -4
package/dist/health.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { subletHome } from './config.js';
|
|
4
|
+
export function healthPath(env = process.env) {
|
|
5
|
+
return join(subletHome(env), 'health.json');
|
|
6
|
+
}
|
|
7
|
+
/** Local readiness only: not server attestation, billing approval, or a GPU benchmark. */
|
|
8
|
+
export async function readHealth(env = process.env) {
|
|
9
|
+
const data = JSON.parse(await readFile(healthPath(env), 'utf8'));
|
|
10
|
+
const now = Date.now();
|
|
11
|
+
if (!data || typeof data !== 'object' || data.schemaVersion !== 1 || !Number.isSafeInteger(data.pid) || data.pid <= 0
|
|
12
|
+
|| !Number.isFinite(data.updatedAt) || data.updatedAt > now + 5_000 || now - data.updatedAt > 15_000
|
|
13
|
+
|| typeof data.live !== 'boolean' || typeof data.ready !== 'boolean'
|
|
14
|
+
|| typeof data.draining !== 'boolean' || typeof data.simulation !== 'boolean'
|
|
15
|
+
|| !Number.isSafeInteger(data.activeJobs) || data.activeJobs < 0
|
|
16
|
+
|| typeof data.tunnel !== 'string' || typeof data.engine !== 'string'
|
|
17
|
+
|| typeof data.agentVersion !== 'string' || typeof data.loading !== 'boolean'
|
|
18
|
+
|| (data.ready && (!data.live || data.draining || data.loading || data.tunnel !== 'open'
|
|
19
|
+
|| (data.simulation ? data.engine !== 'simulation' : data.engine !== 'ready')))) {
|
|
20
|
+
throw new Error('Agent health snapshot is stale or invalid');
|
|
21
|
+
}
|
|
22
|
+
try {
|
|
23
|
+
process.kill(data.pid, 0);
|
|
24
|
+
}
|
|
25
|
+
catch (error) {
|
|
26
|
+
if (error.code !== 'EPERM')
|
|
27
|
+
throw new Error('Agent process is not running');
|
|
28
|
+
}
|
|
29
|
+
// Return a fixed allowlist: a damaged or user-edited snapshot must not cause
|
|
30
|
+
// arbitrary extra fields (credentials, prompts) to be printed by health.
|
|
31
|
+
return {
|
|
32
|
+
schemaVersion: 1, agentVersion: String(data.agentVersion), pid: data.pid,
|
|
33
|
+
updatedAt: data.updatedAt, live: data.live, ready: data.ready,
|
|
34
|
+
draining: data.draining, simulation: data.simulation, tunnel: data.tunnel,
|
|
35
|
+
engine: data.engine, activeJobs: data.activeJobs, loading: data.loading === true,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
//# sourceMappingURL=health.js.map
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { type GlobalFlags } from './commands.js';
|
|
3
|
+
/** `explicit` lists the flags typed on the command line, so settings never override them. */
|
|
4
|
+
type Parsed = {
|
|
5
|
+
flags: GlobalFlags;
|
|
6
|
+
command: string;
|
|
7
|
+
explicit: Set<string>;
|
|
8
|
+
};
|
|
9
|
+
export declare function parseArgv(argv: string[]): Parsed;
|
|
10
|
+
declare function main(): Promise<number>;
|
|
11
|
+
export { main };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import process from 'node:process';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { BRAND, VERSION } from './version.js';
|
|
5
|
+
import { isLogLevel, LogFile, Logger, redactSecrets } from './logger.js';
|
|
6
|
+
import { logsDir } from './config.js';
|
|
7
|
+
import { EXIT, UsageError } from './exitCodes.js';
|
|
8
|
+
import { applySettings } from './settings.js';
|
|
9
|
+
import { installFetchProxy } from './network/proxy.js';
|
|
10
|
+
import { colorEnabled } from './util/format.js';
|
|
11
|
+
import { readHealth } from './health.js';
|
|
12
|
+
import { storageAction, storageReport } from './storageCommand.js';
|
|
13
|
+
import { cmdConfig, cmdDoctor, cmdHost, cmdJobs, cmdLogin, cmdLogout, cmdStart, cmdStatus, globalHelp, } from './commands.js';
|
|
14
|
+
const BOOLEAN_FLAGS = {
|
|
15
|
+
'--json': 'json',
|
|
16
|
+
'--verbose': 'verbose',
|
|
17
|
+
'--no-color': 'color',
|
|
18
|
+
'--simulation': 'simulation',
|
|
19
|
+
'--fix': 'fix',
|
|
20
|
+
'--force': 'force',
|
|
21
|
+
'--once': 'once',
|
|
22
|
+
'--no-install': 'noInstall',
|
|
23
|
+
'--watch': 'watch',
|
|
24
|
+
'--require-checksum': 'requireChecksum',
|
|
25
|
+
'--no-browser': 'noBrowser',
|
|
26
|
+
'--non-interactive': 'nonInteractive',
|
|
27
|
+
'--help': 'help',
|
|
28
|
+
'--version': 'version',
|
|
29
|
+
};
|
|
30
|
+
const VALUE_FLAGS = {
|
|
31
|
+
'--server': 'server',
|
|
32
|
+
'--key': 'token',
|
|
33
|
+
'--token': 'token',
|
|
34
|
+
'--ws-path': 'wsPath',
|
|
35
|
+
'--model': 'model',
|
|
36
|
+
'--ctx': 'context',
|
|
37
|
+
'--slots': 'slots',
|
|
38
|
+
'--price': 'price',
|
|
39
|
+
'--price-in': 'priceIn',
|
|
40
|
+
'--price-out': 'priceOut',
|
|
41
|
+
'--price-hour': 'priceHour',
|
|
42
|
+
'--gpu': 'gpu',
|
|
43
|
+
'--vram': 'vram',
|
|
44
|
+
'--tok-s': 'tokPerSec',
|
|
45
|
+
'--claim': 'claim',
|
|
46
|
+
'--timeout': 'timeoutMs',
|
|
47
|
+
'--log-level': 'logLevel',
|
|
48
|
+
'--log-file': 'logFile',
|
|
49
|
+
'--drain-timeout': 'drainTimeoutMs',
|
|
50
|
+
};
|
|
51
|
+
const NUMERIC = new Set(['context', 'slots', 'price', 'priceIn', 'priceOut', 'priceHour', 'vram', 'tokPerSec', 'timeoutMs', 'drainTimeoutMs']);
|
|
52
|
+
const INTEGER = new Set(['context', 'slots', 'vram', 'drainTimeoutMs']);
|
|
53
|
+
function numberFlag(name, raw, integer) {
|
|
54
|
+
const value = raw.trim() ? Number(raw) : Number.NaN;
|
|
55
|
+
if (!Number.isFinite(value))
|
|
56
|
+
throw new Error(`${name} expects a number, got "${raw}"`);
|
|
57
|
+
if (value < 0)
|
|
58
|
+
throw new Error(`${name} must not be negative, got "${raw}"`);
|
|
59
|
+
if (integer && !Number.isInteger(value))
|
|
60
|
+
throw new Error(`${name} expects a whole number, got "${raw}"`);
|
|
61
|
+
return value;
|
|
62
|
+
}
|
|
63
|
+
export function parseArgv(argv) {
|
|
64
|
+
const flags = {
|
|
65
|
+
json: false,
|
|
66
|
+
verbose: false,
|
|
67
|
+
color: true,
|
|
68
|
+
simulation: false,
|
|
69
|
+
fix: false,
|
|
70
|
+
force: false,
|
|
71
|
+
once: false,
|
|
72
|
+
noInstall: false,
|
|
73
|
+
help: false,
|
|
74
|
+
version: false,
|
|
75
|
+
watch: false,
|
|
76
|
+
requireChecksum: false,
|
|
77
|
+
noBrowser: false,
|
|
78
|
+
positionals: [],
|
|
79
|
+
};
|
|
80
|
+
let command = '';
|
|
81
|
+
const explicit = new Set();
|
|
82
|
+
const args = [...argv];
|
|
83
|
+
while (args.length) {
|
|
84
|
+
const arg = args.shift();
|
|
85
|
+
if (arg === '--') {
|
|
86
|
+
flags.positionals.push(...args.splice(0));
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
if (arg === '-h') {
|
|
90
|
+
flags.help = true;
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (arg === '-v' || arg === '-V') {
|
|
94
|
+
flags.version = true;
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (arg.startsWith('--')) {
|
|
98
|
+
const eq = arg.indexOf('=');
|
|
99
|
+
const name = eq >= 0 ? arg.slice(0, eq) : arg;
|
|
100
|
+
const inline = eq >= 0 ? arg.slice(eq + 1) : undefined;
|
|
101
|
+
const booleanKey = Object.hasOwn(BOOLEAN_FLAGS, name) ? BOOLEAN_FLAGS[name] : undefined;
|
|
102
|
+
if (booleanKey) {
|
|
103
|
+
if (inline !== undefined && inline !== 'true' && inline !== 'false')
|
|
104
|
+
throw new Error(`${name} accepts true or false`);
|
|
105
|
+
const enabled = inline !== 'false';
|
|
106
|
+
if (booleanKey === 'color')
|
|
107
|
+
flags.color = !enabled;
|
|
108
|
+
else
|
|
109
|
+
flags[booleanKey] = enabled;
|
|
110
|
+
explicit.add(booleanKey);
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
const valueKey = Object.hasOwn(VALUE_FLAGS, name) ? VALUE_FLAGS[name] : undefined;
|
|
114
|
+
if (valueKey) {
|
|
115
|
+
const value = inline ?? (args[0]?.startsWith('--') ? undefined : args.shift());
|
|
116
|
+
if (value == null || (!NUMERIC.has(valueKey) && !value.trim()))
|
|
117
|
+
throw new Error(`${name} needs a value`);
|
|
118
|
+
if (NUMERIC.has(valueKey))
|
|
119
|
+
flags[valueKey] = numberFlag(name, value, INTEGER.has(valueKey));
|
|
120
|
+
else if (valueKey === 'logLevel' && !isLogLevel(value))
|
|
121
|
+
throw new Error(`--log-level must be one of debug, info, warn, error, silent; got "${value}"`);
|
|
122
|
+
else
|
|
123
|
+
flags[valueKey] = value;
|
|
124
|
+
explicit.add(valueKey);
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
throw new Error(`Unknown option: ${name}`);
|
|
128
|
+
}
|
|
129
|
+
if (arg.startsWith('-') && arg !== '-')
|
|
130
|
+
throw new Error(`Unknown option: ${arg}`);
|
|
131
|
+
if (!command)
|
|
132
|
+
command = arg.toLowerCase();
|
|
133
|
+
else
|
|
134
|
+
flags.positionals.push(arg);
|
|
135
|
+
}
|
|
136
|
+
if (explicit.has('slots') && (flags.slots < 1 || flags.slots > 16))
|
|
137
|
+
throw new Error('--slots must be between 1 and 16');
|
|
138
|
+
if (explicit.has('context') && flags.context < 256)
|
|
139
|
+
throw new Error('--ctx must be at least 256 tokens');
|
|
140
|
+
if (explicit.has('priceHour') && flags.priceHour > 50)
|
|
141
|
+
throw new Error('--price-hour must be between 0 and 50 USD');
|
|
142
|
+
return { flags, command, explicit };
|
|
143
|
+
}
|
|
144
|
+
/** Commands that run for hours keep a rotating log file by default; quick commands only when asked. */
|
|
145
|
+
const LONG_RUNNING = new Set(['start', 'host', 'jobs']);
|
|
146
|
+
function logFileFor(command, flags) {
|
|
147
|
+
const setting = flags.logFile?.trim();
|
|
148
|
+
if (setting && /^(off|none|false|0)$/i.test(setting))
|
|
149
|
+
return null;
|
|
150
|
+
if (!setting && !LONG_RUNNING.has(command))
|
|
151
|
+
return null;
|
|
152
|
+
return new LogFile({ path: setting || join(logsDir(), 'agent.log') });
|
|
153
|
+
}
|
|
154
|
+
const COMMANDS = new Set(['login', 'doctor', 'start', 'host', 'jobs', 'status', 'logout', 'config', 'health', 'storage']);
|
|
155
|
+
function reportError(error, json, code) {
|
|
156
|
+
const message = redactSecrets(error instanceof Error ? error.message : String(error));
|
|
157
|
+
if (json)
|
|
158
|
+
process.stdout.write(`${JSON.stringify({ ok: false, error: message, exitCode: code })}\n`);
|
|
159
|
+
process.stderr.write(`${message}\n`);
|
|
160
|
+
}
|
|
161
|
+
async function main() {
|
|
162
|
+
let parsed;
|
|
163
|
+
try {
|
|
164
|
+
parsed = parseArgv(process.argv.slice(2));
|
|
165
|
+
}
|
|
166
|
+
catch (error) {
|
|
167
|
+
const args = process.argv.slice(2);
|
|
168
|
+
const beforeSeparator = args.slice(0, args.indexOf('--') >= 0 ? args.indexOf('--') : args.length);
|
|
169
|
+
reportError(error, beforeSeparator.includes('--json') || beforeSeparator.includes('--json=true'), EXIT.USAGE);
|
|
170
|
+
return EXIT.USAGE;
|
|
171
|
+
}
|
|
172
|
+
const { flags, command, explicit } = parsed;
|
|
173
|
+
if (flags.version) {
|
|
174
|
+
process.stdout.write(`${BRAND} agent v${VERSION}\n`);
|
|
175
|
+
return EXIT.OK;
|
|
176
|
+
}
|
|
177
|
+
if (flags.help || !command) {
|
|
178
|
+
process.stdout.write(`${globalHelp()}\n`);
|
|
179
|
+
return EXIT.OK;
|
|
180
|
+
}
|
|
181
|
+
if (!COMMANDS.has(command)) {
|
|
182
|
+
reportError(new UsageError(`Unknown command: ${command}. Run sublera --help.`), flags.json, EXIT.USAGE);
|
|
183
|
+
return EXIT.USAGE;
|
|
184
|
+
}
|
|
185
|
+
let resolved;
|
|
186
|
+
try {
|
|
187
|
+
// SUBLET_* variables and agent.json fill in every flag that was not typed.
|
|
188
|
+
resolved = applySettings(flags, explicit);
|
|
189
|
+
// config and logout are local recovery commands; a broken proxy must not prevent them.
|
|
190
|
+
if (!['config', 'logout', 'health', 'storage'].includes(command))
|
|
191
|
+
await installFetchProxy();
|
|
192
|
+
}
|
|
193
|
+
catch (error) {
|
|
194
|
+
reportError(error, flags.json, EXIT.USAGE);
|
|
195
|
+
return EXIT.USAGE;
|
|
196
|
+
}
|
|
197
|
+
const level = flags.verbose ? 'debug' : isLogLevel(flags.logLevel) ? flags.logLevel : 'info';
|
|
198
|
+
const logger = new Logger({
|
|
199
|
+
level,
|
|
200
|
+
json: flags.json,
|
|
201
|
+
color: flags.color && colorEnabled(process.stderr),
|
|
202
|
+
file: logFileFor(command, flags),
|
|
203
|
+
});
|
|
204
|
+
if (logger.file)
|
|
205
|
+
logger.debug(`${BRAND} agent v${VERSION} ${command}`, { node: process.versions.node, platform: `${process.platform}/${process.arch}`, pid: process.pid });
|
|
206
|
+
try {
|
|
207
|
+
switch (command) {
|
|
208
|
+
case 'health': {
|
|
209
|
+
if (flags.positionals.length > 1 || (flags.positionals[0] && !['live', 'ready'].includes(flags.positionals[0])))
|
|
210
|
+
throw new UsageError('Usage: sublera health [live|ready] [--json]');
|
|
211
|
+
const health = await readHealth();
|
|
212
|
+
process.stdout.write(`${JSON.stringify(health)}\n`);
|
|
213
|
+
return (flags.positionals[0] === 'live' ? health.live : health.ready) ? EXIT.OK : EXIT.ERROR;
|
|
214
|
+
}
|
|
215
|
+
case 'storage': {
|
|
216
|
+
const action = storageAction(flags.positionals, flags.force);
|
|
217
|
+
const report = await storageReport(action.prune, action.apply);
|
|
218
|
+
process.stdout.write(`${JSON.stringify(report, null, flags.json ? undefined : 2)}\n`);
|
|
219
|
+
return EXIT.OK;
|
|
220
|
+
}
|
|
221
|
+
case 'login': return await cmdLogin(flags, logger);
|
|
222
|
+
case 'doctor': return await cmdDoctor(flags, logger);
|
|
223
|
+
case 'start': return await cmdStart(flags, logger);
|
|
224
|
+
case 'host': return await cmdHost(flags, logger);
|
|
225
|
+
case 'jobs': return await cmdJobs(flags, logger);
|
|
226
|
+
case 'status': return await cmdStatus(flags, logger);
|
|
227
|
+
case 'logout': return await cmdLogout(flags, logger);
|
|
228
|
+
case 'config': return await cmdConfig(flags, logger, resolved);
|
|
229
|
+
default: return EXIT.USAGE;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
catch (error) {
|
|
233
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
234
|
+
const code = error instanceof UsageError ? EXIT.USAGE : EXIT.ERROR;
|
|
235
|
+
if (flags.json)
|
|
236
|
+
process.stdout.write(`${JSON.stringify({ ok: false, error: redactSecrets(message), exitCode: code })}\n`);
|
|
237
|
+
logger.error(message);
|
|
238
|
+
if (error instanceof Error && error.stack)
|
|
239
|
+
logger.debug(error.stack);
|
|
240
|
+
return code;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
const invoked = process.argv[1] && (process.argv[1].endsWith('index.js') || process.argv[1].endsWith('index.ts') || process.argv[1].endsWith('sublera-cli') || process.argv[1].endsWith('sublera') || process.argv[1].endsWith('sublet-cli') || process.argv[1].endsWith('sublet'));
|
|
244
|
+
if (invoked) {
|
|
245
|
+
let fatal = false;
|
|
246
|
+
let fatalTimer;
|
|
247
|
+
// Stop serving after an unexpected failure, allowing existing SIGTERM cleanup a bounded grace period.
|
|
248
|
+
process.on('unhandledRejection', (reason) => {
|
|
249
|
+
if (fatal)
|
|
250
|
+
return;
|
|
251
|
+
fatal = true;
|
|
252
|
+
process.stderr.write(`Unexpected error: ${redactSecrets(reason instanceof Error ? reason.stack || reason.message : String(reason))}\n`);
|
|
253
|
+
process.exitCode = EXIT.ERROR;
|
|
254
|
+
fatalTimer = setTimeout(() => process.exit(EXIT.ERROR), 5_000);
|
|
255
|
+
fatalTimer.unref();
|
|
256
|
+
process.emit('SIGTERM');
|
|
257
|
+
});
|
|
258
|
+
main().then((code) => {
|
|
259
|
+
process.exitCode = fatal ? EXIT.ERROR : code;
|
|
260
|
+
if (fatalTimer)
|
|
261
|
+
clearTimeout(fatalTimer);
|
|
262
|
+
}).catch((error) => {
|
|
263
|
+
process.stderr.write(`${redactSecrets(error instanceof Error ? error.stack || error.message : String(error))}\n`);
|
|
264
|
+
process.exitCode = 1;
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
export { main };
|
|
268
|
+
//# sourceMappingURL=index.js.map
|
package/dist/logger.d.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
export type LogLevel = 'debug' | 'info' | 'warn' | 'error' | 'silent';
|
|
2
|
+
export declare const LOG_LEVELS: readonly LogLevel[];
|
|
3
|
+
export declare function isLogLevel(value: unknown): value is LogLevel;
|
|
4
|
+
export type LogFields = Record<string, unknown>;
|
|
5
|
+
export declare function redactSecrets(text: string): string;
|
|
6
|
+
export type LogFileOptions = {
|
|
7
|
+
path: string;
|
|
8
|
+
/** Rotate when the file reaches this size. Default 10 MB. */
|
|
9
|
+
maxBytes?: number;
|
|
10
|
+
/** Rotated files to keep (agent.log.1 ... agent.log.N). Default 5. */
|
|
11
|
+
keep?: number;
|
|
12
|
+
/** Called once if logging fails. No error text or credentials are passed. */
|
|
13
|
+
onError?: () => void;
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* A synchronous, size-rotated JSON-lines log file. Synchronous writes keep the
|
|
17
|
+
* last lines before a crash; agent logs are a few lines a minute, so the cost is negligible.
|
|
18
|
+
*/
|
|
19
|
+
export declare class LogFile {
|
|
20
|
+
readonly path: string;
|
|
21
|
+
private readonly maxBytes;
|
|
22
|
+
private readonly keep;
|
|
23
|
+
private size;
|
|
24
|
+
private failed;
|
|
25
|
+
private readonly onError;
|
|
26
|
+
constructor(options: LogFileOptions);
|
|
27
|
+
write(line: string): void;
|
|
28
|
+
private rotate;
|
|
29
|
+
}
|
|
30
|
+
export type LoggerOptions = {
|
|
31
|
+
level?: LogLevel;
|
|
32
|
+
json?: boolean;
|
|
33
|
+
color?: boolean;
|
|
34
|
+
stream?: NodeJS.WriteStream;
|
|
35
|
+
/** Every record (debug included) is also written here as JSON lines. */
|
|
36
|
+
file?: LogFile | null;
|
|
37
|
+
};
|
|
38
|
+
export declare class Logger {
|
|
39
|
+
readonly level: LogLevel;
|
|
40
|
+
readonly json: boolean;
|
|
41
|
+
readonly color: boolean;
|
|
42
|
+
readonly file: LogFile | null;
|
|
43
|
+
private readonly stream;
|
|
44
|
+
constructor(options?: LoggerOptions);
|
|
45
|
+
child(options: Partial<LoggerOptions>): Logger;
|
|
46
|
+
debug(message: string, fields?: LogFields): void;
|
|
47
|
+
info(message: string, fields?: LogFields): void;
|
|
48
|
+
warn(message: string, fields?: LogFields): void;
|
|
49
|
+
error(message: string, fields?: LogFields): void;
|
|
50
|
+
success(message: string, fields?: LogFields): void;
|
|
51
|
+
private write;
|
|
52
|
+
}
|
|
53
|
+
export declare function redactFields(fields: LogFields): LogFields;
|
package/dist/logger.js
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { appendFileSync, chmodSync, lstatSync, mkdirSync, renameSync, rmSync } from 'node:fs';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
3
|
+
import { colorEnabled, paint } from './util/format.js';
|
|
4
|
+
const ORDER = { debug: 10, info: 20, warn: 30, error: 40, silent: 100 };
|
|
5
|
+
export const LOG_LEVELS = ['debug', 'info', 'warn', 'error', 'silent'];
|
|
6
|
+
export function isLogLevel(value) {
|
|
7
|
+
return typeof value === 'string' && LOG_LEVELS.includes(value);
|
|
8
|
+
}
|
|
9
|
+
/** Host keys (sh_...), API keys (sk-...), and bearer tokens never reach a terminal or a log file in full. */
|
|
10
|
+
// Order matters: whole bearer values and token= parameters first, then any bare key left in free text.
|
|
11
|
+
const SECRET_PATTERNS = [
|
|
12
|
+
[/\b(Bearer|Basic)\s+[^\s"'<>]+/gi, (m) => `${m.split(/\s+/)[0]} ***`],
|
|
13
|
+
[/([?&](?:token|access_token|api_key|key|signature|sig)=)[^&\s"']+/gi, (m) => `${m.slice(0, m.indexOf('=') + 1)}***`],
|
|
14
|
+
[/[a-z][a-z0-9+.-]*:\/\/[^\s/@]+@/gi, (m) => `${m.slice(0, m.indexOf('://') + 3)}***@`],
|
|
15
|
+
[/\b(sh_|sk-)[A-Za-z0-9_-]{6,}/g, (m) => `${m.slice(0, 6)}***`],
|
|
16
|
+
];
|
|
17
|
+
export function redactSecrets(text) {
|
|
18
|
+
let out = text;
|
|
19
|
+
for (const [pattern, replace] of SECRET_PATTERNS)
|
|
20
|
+
out = out.replace(pattern, replace);
|
|
21
|
+
// Do not let a remote error string clear the terminal or forge a second log line.
|
|
22
|
+
return out.replace(/[\u0000-\u001f\u007f-\u009f]/g, (char) => `\\u${char.charCodeAt(0).toString(16).padStart(4, '0')}`);
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* A synchronous, size-rotated JSON-lines log file. Synchronous writes keep the
|
|
26
|
+
* last lines before a crash; agent logs are a few lines a minute, so the cost is negligible.
|
|
27
|
+
*/
|
|
28
|
+
export class LogFile {
|
|
29
|
+
path;
|
|
30
|
+
maxBytes;
|
|
31
|
+
keep;
|
|
32
|
+
size = -1;
|
|
33
|
+
failed = false;
|
|
34
|
+
onError;
|
|
35
|
+
constructor(options) {
|
|
36
|
+
this.path = options.path;
|
|
37
|
+
this.maxBytes = Number.isFinite(options.maxBytes) ? Math.max(64 * 1024, Math.floor(options.maxBytes)) : 10 * 1024 * 1024;
|
|
38
|
+
this.keep = Number.isFinite(options.keep) ? Math.max(1, Math.min(50, Math.floor(options.keep))) : 5;
|
|
39
|
+
this.onError = options.onError ?? (() => { process.stderr.write('Warning: agent file logging stopped; check disk space and log-file permissions.\n'); });
|
|
40
|
+
}
|
|
41
|
+
write(line) {
|
|
42
|
+
if (this.failed)
|
|
43
|
+
return;
|
|
44
|
+
try {
|
|
45
|
+
if (this.size < 0) {
|
|
46
|
+
mkdirSync(dirname(this.path), { recursive: true, mode: 0o700 });
|
|
47
|
+
try {
|
|
48
|
+
const stat = lstatSync(this.path);
|
|
49
|
+
if (!stat.isFile() || stat.isSymbolicLink())
|
|
50
|
+
throw new Error('Log must be a regular file');
|
|
51
|
+
this.size = stat.size;
|
|
52
|
+
if (process.platform !== 'win32')
|
|
53
|
+
chmodSync(this.path, 0o600);
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
if (error.code !== 'ENOENT')
|
|
57
|
+
throw error;
|
|
58
|
+
this.size = 0;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
// Do not persist an oversized record, even when the file was empty.
|
|
62
|
+
const data = Buffer.byteLength(line) + 1 > this.maxBytes
|
|
63
|
+
? `${JSON.stringify({ ts: new Date().toISOString(), level: 'warn', message: 'Oversized log record omitted', truncated: true })}\n`
|
|
64
|
+
: `${line}\n`;
|
|
65
|
+
if (this.size + Buffer.byteLength(data) > this.maxBytes && this.size > 0)
|
|
66
|
+
this.rotate();
|
|
67
|
+
appendFileSync(this.path, data, { encoding: 'utf8', mode: 0o600 });
|
|
68
|
+
this.size += Buffer.byteLength(data);
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
// A full disk or a read-only home must never stop a booking; the terminal still has the logs.
|
|
72
|
+
this.failed = true;
|
|
73
|
+
try {
|
|
74
|
+
this.onError();
|
|
75
|
+
}
|
|
76
|
+
catch { /* diagnostics must not stop serving */ }
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
rotate() {
|
|
80
|
+
rmSync(`${this.path}.${this.keep}`, { force: true });
|
|
81
|
+
for (let i = this.keep - 1; i >= 1; i--) {
|
|
82
|
+
try {
|
|
83
|
+
renameSync(`${this.path}.${i}`, `${this.path}.${i + 1}`);
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
if (error.code !== 'ENOENT')
|
|
87
|
+
throw error;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
try {
|
|
91
|
+
renameSync(this.path, `${this.path}.1`);
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
if (error.code !== 'ENOENT')
|
|
95
|
+
throw error;
|
|
96
|
+
}
|
|
97
|
+
this.size = 0;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
export class Logger {
|
|
101
|
+
level;
|
|
102
|
+
json;
|
|
103
|
+
color;
|
|
104
|
+
file;
|
|
105
|
+
stream;
|
|
106
|
+
constructor(options = {}) {
|
|
107
|
+
this.level = options.level ?? 'info';
|
|
108
|
+
this.json = options.json ?? false;
|
|
109
|
+
this.color = options.color ?? colorEnabled(process.stderr);
|
|
110
|
+
this.stream = options.stream ?? process.stderr;
|
|
111
|
+
this.file = options.file ?? null;
|
|
112
|
+
}
|
|
113
|
+
child(options) {
|
|
114
|
+
return new Logger({ level: this.level, json: this.json, color: this.color, stream: this.stream, file: this.file, ...options });
|
|
115
|
+
}
|
|
116
|
+
debug(message, fields) { this.write('debug', message, fields); }
|
|
117
|
+
info(message, fields) { this.write('info', message, fields); }
|
|
118
|
+
warn(message, fields) { this.write('warn', message, fields); }
|
|
119
|
+
error(message, fields) { this.write('error', message, fields); }
|
|
120
|
+
success(message, fields) {
|
|
121
|
+
this.write('info', message, { ...fields, ok: true });
|
|
122
|
+
}
|
|
123
|
+
write(level, rawMessage, rawFields) {
|
|
124
|
+
const toFile = this.file != null;
|
|
125
|
+
const toStream = ORDER[level] >= ORDER[this.level];
|
|
126
|
+
if (!toFile && !toStream)
|
|
127
|
+
return;
|
|
128
|
+
const message = redactSecrets(rawMessage);
|
|
129
|
+
const fields = rawFields ? redactFields(rawFields) : undefined;
|
|
130
|
+
// Redact BEFORE serialization: rewriting escaped JSON can corrupt records.
|
|
131
|
+
const record = (toFile || this.json) ? JSON.stringify({ ...fields, ts: new Date().toISOString(), level, message }) : '';
|
|
132
|
+
if (toFile)
|
|
133
|
+
this.file.write(record);
|
|
134
|
+
if (!toStream)
|
|
135
|
+
return;
|
|
136
|
+
if (this.json) {
|
|
137
|
+
this.stream.write(`${record}\n`);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
const label = level === 'info' ? '•' : level === 'warn' ? '!' : level === 'error' ? 'x' : '·';
|
|
141
|
+
const color = level === 'warn' ? 'yellow' : level === 'error' ? 'red' : level === 'debug' ? 'gray' : 'cyan';
|
|
142
|
+
const prefix = paint(label, color, this.color);
|
|
143
|
+
const suffix = fields && Object.keys(fields).length
|
|
144
|
+
? ` ${paint(formatFields(fields), 'gray', this.color)}`
|
|
145
|
+
: '';
|
|
146
|
+
this.stream.write(`${prefix} ${message}${suffix}\n`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
const SECRET_KEYS = /^(token|key|hostkey|apikey|authorization|proxyauthorization|password|secret|accesstoken|refreshtoken|devicecode|cookie|setcookie|prompt|messages|content)$/i;
|
|
150
|
+
export function redactFields(fields) {
|
|
151
|
+
const seen = new WeakSet();
|
|
152
|
+
const visit = (value, depth) => {
|
|
153
|
+
if (typeof value === 'string')
|
|
154
|
+
return redactSecrets(value);
|
|
155
|
+
if (typeof value === 'bigint')
|
|
156
|
+
return value.toString();
|
|
157
|
+
if (value == null || typeof value !== 'object')
|
|
158
|
+
return typeof value === 'function' ? '[function]' : value;
|
|
159
|
+
if (depth > 8)
|
|
160
|
+
return '[depth limit]';
|
|
161
|
+
if (seen.has(value))
|
|
162
|
+
return '[circular]';
|
|
163
|
+
seen.add(value);
|
|
164
|
+
try {
|
|
165
|
+
if (Array.isArray(value))
|
|
166
|
+
return value.slice(0, 100).map((item) => visit(item, depth + 1));
|
|
167
|
+
const out = Object.create(null);
|
|
168
|
+
for (const key of Object.keys(value).slice(0, 100)) {
|
|
169
|
+
if (SECRET_KEYS.test(key.replace(/[-_]/g, ''))) {
|
|
170
|
+
out[key] = '***';
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
174
|
+
out[redactSecrets(key)] = descriptor && 'value' in descriptor ? visit(descriptor.value, depth + 1) : '[accessor]';
|
|
175
|
+
}
|
|
176
|
+
return out;
|
|
177
|
+
}
|
|
178
|
+
finally {
|
|
179
|
+
seen.delete(value);
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
try {
|
|
183
|
+
return visit(fields, 0);
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
return { fields: '[unavailable]' };
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
function formatFields(fields) {
|
|
190
|
+
return Object.entries(fields)
|
|
191
|
+
.filter(([, value]) => value !== undefined && value !== null && value !== '')
|
|
192
|
+
.map(([key, value]) => {
|
|
193
|
+
if (typeof value === 'string')
|
|
194
|
+
return `${key}=${value.includes(' ') ? JSON.stringify(value) : value}`;
|
|
195
|
+
if (typeof value === 'number' || typeof value === 'boolean')
|
|
196
|
+
return `${key}=${value}`;
|
|
197
|
+
return `${key}=${JSON.stringify(value)}`;
|
|
198
|
+
})
|
|
199
|
+
.join(' ');
|
|
200
|
+
}
|
|
201
|
+
//# sourceMappingURL=logger.js.map
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { Agent as HttpAgent } from 'node:http';
|
|
2
|
+
import { Agent as HttpsAgent, type AgentOptions } from 'node:https';
|
|
3
|
+
import type { Duplex } from 'node:stream';
|
|
4
|
+
import { type Socket } from 'node:net';
|
|
5
|
+
/**
|
|
6
|
+
* Corporate network support. Standard proxy variables are honored for every
|
|
7
|
+
* outbound connection the agent makes (API, HuggingFace/GitHub downloads, and
|
|
8
|
+
* the WebSocket tunnel). llama-server on 127.0.0.1 is never proxied.
|
|
9
|
+
*
|
|
10
|
+
* HTTPS_PROXY / https_proxy proxy for https:// and wss:// (most common)
|
|
11
|
+
* HTTP_PROXY / http_proxy proxy for http:// and ws://
|
|
12
|
+
* NO_PROXY / no_proxy comma-separated hosts, .domains, host:port, or *
|
|
13
|
+
* NODE_EXTRA_CA_CERTS extra corporate root CA (read by Node itself at startup)
|
|
14
|
+
*/
|
|
15
|
+
export type ProxySettings = {
|
|
16
|
+
httpsProxy: string | null;
|
|
17
|
+
httpProxy: string | null;
|
|
18
|
+
noProxy: string[];
|
|
19
|
+
};
|
|
20
|
+
export declare function proxySettings(env?: NodeJS.ProcessEnv): ProxySettings;
|
|
21
|
+
export declare function proxyConfigured(settings?: ProxySettings): boolean;
|
|
22
|
+
/** True when NO_PROXY (or loopback) says this host must be reached directly. */
|
|
23
|
+
export declare function bypassProxy(target: URL, noProxy: string[]): boolean;
|
|
24
|
+
/** The proxy URL to use for a target, or null for a direct connection. */
|
|
25
|
+
export declare function proxyFor(target: string | URL, settings?: ProxySettings): URL | null;
|
|
26
|
+
export declare function parseProxyUrl(raw: string): URL;
|
|
27
|
+
/** Proxy URL safe for logs and doctor output: credentials are hidden. */
|
|
28
|
+
export declare function redactProxy(raw: string | URL | null): string;
|
|
29
|
+
/** Opens an HTTP CONNECT tunnel through the proxy and returns the raw socket to host:port. */
|
|
30
|
+
export declare function openConnectTunnel(proxy: URL, host: string, port: number, timeoutMs?: number, signal?: AbortSignal): Promise<Socket>;
|
|
31
|
+
type ConnectionCallback = (error: Error | null, stream: Duplex) => void;
|
|
32
|
+
/**
|
|
33
|
+
* An https.Agent that reaches the target through an HTTP CONNECT proxy. Used
|
|
34
|
+
* for the wss:// tunnel, because the `ws` package takes a Node agent and ignores proxy variables.
|
|
35
|
+
*/
|
|
36
|
+
export declare class ConnectProxyAgent extends HttpsAgent {
|
|
37
|
+
readonly proxy: URL;
|
|
38
|
+
private readonly pending;
|
|
39
|
+
constructor(proxy: URL, options?: AgentOptions);
|
|
40
|
+
createConnection(rawOptions: object, callback?: ConnectionCallback): Duplex | null | undefined;
|
|
41
|
+
destroy(): void;
|
|
42
|
+
}
|
|
43
|
+
/** The plain-HTTP twin of ConnectProxyAgent, for ws:// servers (local or test setups). */
|
|
44
|
+
export declare class ConnectProxyHttpAgent extends HttpAgent {
|
|
45
|
+
readonly proxy: URL;
|
|
46
|
+
private readonly pending;
|
|
47
|
+
constructor(proxy: URL);
|
|
48
|
+
createConnection(rawOptions: object, callback?: ConnectionCallback): Duplex | null | undefined;
|
|
49
|
+
destroy(): void;
|
|
50
|
+
}
|
|
51
|
+
/** A Node agent for the `ws` tunnel when a proxy applies to this URL, else undefined (direct). */
|
|
52
|
+
export declare function websocketAgentFor(url: string, settings?: ProxySettings): HttpAgent | undefined;
|
|
53
|
+
/**
|
|
54
|
+
* Routes the global fetch() (API calls, model and engine downloads) through the
|
|
55
|
+
* configured proxy. Loads nothing and changes nothing when no proxy is set.
|
|
56
|
+
*/
|
|
57
|
+
export declare function installFetchProxy(env?: NodeJS.ProcessEnv): Promise<boolean>;
|
|
58
|
+
export {};
|