routstrd 0.4.4 → 0.4.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -0
- package/dist/daemon/index.js +74735 -91633
- package/dist/index.js +203 -41
- package/package.json +9 -2
- package/src/cli.test.ts +138 -1
- package/src/cli.ts +289 -49
- package/src/daemon/http/index.ts +1862 -0
- package/src/daemon/index.ts +408 -0
- package/src/daemon/wallet/index.ts +360 -0
- package/src/index.ts +4 -0
- package/src/integrations/index.ts +159 -0
- package/src/tui/usage/index.ts +1 -0
- package/.claude/settings.local.json +0 -10
- package/.dockerignore +0 -12
- package/COCO-2.0.0-MIGRATION-PLAN.md +0 -1191
- package/Dockerfile +0 -11
- package/IMPLEMENTATION.md +0 -253
- package/SKILL.md +0 -324
- package/bun.lock +0 -494
- package/docker-compose.yml +0 -15
- package/refund.js +0 -33
- package/refund_new.js +0 -20
- package/routstr-cost-logging.md +0 -71
- package/test_box.ts +0 -15
- package/test_chat.sh +0 -29
- package/test_curl.sh +0 -11
- package/test_split_box.ts +0 -17
- package/test_split_box2.ts +0 -23
- package/tests/daemon/config-store.perms.test.ts +0 -55
- package/tests/daemon/config-store.scenario.ts +0 -113
- package/tests/integrations/hermes.test.ts +0 -134
- package/tests/utils/daemon-client.test.ts +0 -61
- package/tests/wallet/short-keyset-token.test.ts +0 -106
- package/tsconfig.json +0 -20
- package/v1-messages-format-report.md +0 -223
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
import { createServer } from "http";
|
|
2
|
+
import { existsSync, unlinkSync } from "fs";
|
|
3
|
+
import {
|
|
4
|
+
ProviderManager,
|
|
5
|
+
createStorageAdapterFromStore,
|
|
6
|
+
createSdkStore,
|
|
7
|
+
} from "@routstr/sdk";
|
|
8
|
+
// ModelManager must come from the bun entrypoint so persistent Nostr event
|
|
9
|
+
// storage (eventStoreDbPath) gets its SQLite-backed factory. The default
|
|
10
|
+
// "@routstr/sdk" export is browser-safe and throws without that factory
|
|
11
|
+
// (SDK 0.3.7+ browser-safe entrypoint split).
|
|
12
|
+
import { ModelManager } from "@routstr/sdk/bun";
|
|
13
|
+
import type { SdkLogger } from "@routstr/sdk";
|
|
14
|
+
import {
|
|
15
|
+
CONFIG_DIR,
|
|
16
|
+
DB_PATH,
|
|
17
|
+
SOCKET_PATH,
|
|
18
|
+
PID_FILE,
|
|
19
|
+
REQUEST_RESPONSE_LOGS_DIR,
|
|
20
|
+
} from "../utils/config";
|
|
21
|
+
import { logger } from "../utils/logger";
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
function makeSdkLogger(prefix?: string): SdkLogger {
|
|
25
|
+
const tag = prefix ? `[${prefix}]` : undefined;
|
|
26
|
+
const fmt = (...args: unknown[]) => (tag ? [tag, ...args] : args);
|
|
27
|
+
return {
|
|
28
|
+
log: (...args: unknown[]) => logger.log(...fmt(...args)),
|
|
29
|
+
warn: (...args: unknown[]) => logger.warn(...fmt(...args)),
|
|
30
|
+
error: (...args: unknown[]) => logger.error(...fmt(...args)),
|
|
31
|
+
debug: (...args: unknown[]) => logger.debug(...fmt(...args)),
|
|
32
|
+
child: (p: string) => makeSdkLogger(prefix ? `${prefix}:${p}` : p),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
const daemonSdkLogger: SdkLogger = makeSdkLogger();
|
|
36
|
+
const STARTUP_LOG_PREFIX = "[routstrd:start]";
|
|
37
|
+
|
|
38
|
+
function startupProgress(message: string): void {
|
|
39
|
+
logger.info(message);
|
|
40
|
+
console.log(`${STARTUP_LOG_PREFIX} ${message}`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
import { parseArgs } from "./args";
|
|
44
|
+
import { ensureDirs, loadDaemonConfig, loadDaemonConfigSync, saveDaemonConfig } from "./config-store";
|
|
45
|
+
import {
|
|
46
|
+
createBunSqliteDriver,
|
|
47
|
+
createBunSqliteUsageTrackingDriver,
|
|
48
|
+
createShardedDiscoveryAdapter,
|
|
49
|
+
} from "@routstr/sdk/storage/bun";
|
|
50
|
+
import { createWalletAdapter } from "./wallet";
|
|
51
|
+
import type { AutoRefillConfig } from "./wallet/auto-refill";
|
|
52
|
+
import { createModelService } from "./models";
|
|
53
|
+
import { createDaemonRequestHandler } from "./http";
|
|
54
|
+
import { FileRequestResponseLogSink } from "./request-response-log-sink";
|
|
55
|
+
import { refreshModelsAndIntegrations } from "../integrations";
|
|
56
|
+
import { RoutstrClient } from "@routstr/sdk";
|
|
57
|
+
import { mkdirSync } from "fs";
|
|
58
|
+
import { dirname } from "path";
|
|
59
|
+
import {
|
|
60
|
+
assertLegacyCocodNotRunning,
|
|
61
|
+
claimLegacyCocodPidFile,
|
|
62
|
+
createCocoClient,
|
|
63
|
+
stopLegacyCocod,
|
|
64
|
+
} from "./wallet/coco-client";
|
|
65
|
+
import { migrateLegacyWallet } from "./wallet/migration";
|
|
66
|
+
import {
|
|
67
|
+
legacyCocodPidPath,
|
|
68
|
+
legacyCocodSocketPath,
|
|
69
|
+
} from "./wallet/paths";
|
|
70
|
+
import { installGlobalErrorHandlers } from "./fatal-error";
|
|
71
|
+
|
|
72
|
+
// Global error handlers — the daemon is spawned detached with stdout/stderr
|
|
73
|
+
// redirected to a file, so without these, uncaught async errors would kill
|
|
74
|
+
// the process silently. Uncaught exceptions are fatal: the process state can
|
|
75
|
+
// no longer be trusted, so the daemon logs and exits for its supervisor to
|
|
76
|
+
// restart (see fatal-error.ts).
|
|
77
|
+
installGlobalErrorHandlers();
|
|
78
|
+
|
|
79
|
+
async function main(): Promise<void> {
|
|
80
|
+
// Install signal handlers before migration and wallet startup. If a signal
|
|
81
|
+
// arrives before the full shutdown path is wired, process.exit() still runs
|
|
82
|
+
// the synchronous PID-lock exit hooks registered by claimPidFile.
|
|
83
|
+
let shutdownDaemon: () => void = () => process.exit(0);
|
|
84
|
+
const shutdownForSignal = (signal: NodeJS.Signals) => {
|
|
85
|
+
logger.log(`Received ${signal}; shutting down...`);
|
|
86
|
+
shutdownDaemon();
|
|
87
|
+
};
|
|
88
|
+
process.once("SIGINT", shutdownForSignal);
|
|
89
|
+
process.once("SIGTERM", shutdownForSignal);
|
|
90
|
+
|
|
91
|
+
startupProgress("Loading configuration...");
|
|
92
|
+
const args = parseArgs(process.argv);
|
|
93
|
+
const config = await loadDaemonConfig();
|
|
94
|
+
|
|
95
|
+
const port = args.port;
|
|
96
|
+
const host = args.host || config.host || "127.0.0.1";
|
|
97
|
+
const provider = args.provider || config.provider;
|
|
98
|
+
const requestResponseLogDir =
|
|
99
|
+
process.env.ROUTSTRD_REQUEST_RESPONSE_LOG_DIR ||
|
|
100
|
+
(config.requestResponseLogging?.enabled
|
|
101
|
+
? config.requestResponseLogging.dir || REQUEST_RESPONSE_LOGS_DIR
|
|
102
|
+
: undefined);
|
|
103
|
+
const requestResponseLogSink = requestResponseLogDir
|
|
104
|
+
? new FileRequestResponseLogSink({
|
|
105
|
+
dir: requestResponseLogDir,
|
|
106
|
+
logger: daemonSdkLogger.child("request-response-log"),
|
|
107
|
+
})
|
|
108
|
+
: undefined;
|
|
109
|
+
|
|
110
|
+
await ensureDirs();
|
|
111
|
+
|
|
112
|
+
const updatedConfig = { ...config, port, host, provider };
|
|
113
|
+
saveDaemonConfig(updatedConfig);
|
|
114
|
+
|
|
115
|
+
startupProgress("Opening Routstr databases...");
|
|
116
|
+
const sqliteDriver = await createBunSqliteDriver(DB_PATH, { logger: daemonSdkLogger });
|
|
117
|
+
const { store, hydrate } = createSdkStore({ driver: sqliteDriver });
|
|
118
|
+
await hydrate;
|
|
119
|
+
const usageTrackingDriver = await createBunSqliteUsageTrackingDriver({
|
|
120
|
+
dbPath: DB_PATH,
|
|
121
|
+
legacyStorageDriver: sqliteDriver,
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
const discoveryAdapter = await createShardedDiscoveryAdapter({ driver: sqliteDriver });
|
|
125
|
+
const storageAdapter = createStorageAdapterFromStore(store);
|
|
126
|
+
startupProgress("Routstr databases ready.");
|
|
127
|
+
const modelManager = new ModelManager(discoveryAdapter, {
|
|
128
|
+
logger: daemonSdkLogger,
|
|
129
|
+
eventStoreDbPath: `${CONFIG_DIR}/events.db`,
|
|
130
|
+
routstrPubkey: config.routstrPubkey,
|
|
131
|
+
routstrModelsPubkey: config.routstrModelsPubkey,
|
|
132
|
+
nostrRelays: config.relays,
|
|
133
|
+
});
|
|
134
|
+
// Create shared ProviderManager for consistent failure tracking across all requests
|
|
135
|
+
const providerManager = new ProviderManager(discoveryAdapter, store, daemonSdkLogger);
|
|
136
|
+
const { ensureProvidersBootstrapped, getRoutstr21Models, getModelProviders, refreshProvidersAndModels } =
|
|
137
|
+
createModelService(modelManager, providerManager, store);
|
|
138
|
+
|
|
139
|
+
// The daemon may be launched directly (or by an older/global CLI), so do
|
|
140
|
+
// not rely on the parent command having stopped the external wallet first.
|
|
141
|
+
await stopLegacyCocod({
|
|
142
|
+
socketPath: legacyCocodSocketPath(),
|
|
143
|
+
pidFilePath: legacyCocodPidPath(),
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
const migration = await migrateLegacyWallet({
|
|
147
|
+
assertLegacyStopped: () =>
|
|
148
|
+
assertLegacyCocodNotRunning({
|
|
149
|
+
socketPath: legacyCocodSocketPath(),
|
|
150
|
+
}),
|
|
151
|
+
acquireLegacyLock: () => {
|
|
152
|
+
mkdirSync(dirname(legacyCocodPidPath()), {
|
|
153
|
+
recursive: true,
|
|
154
|
+
mode: 0o700,
|
|
155
|
+
});
|
|
156
|
+
return claimLegacyCocodPidFile({
|
|
157
|
+
pidFilePath: legacyCocodPidPath(),
|
|
158
|
+
});
|
|
159
|
+
},
|
|
160
|
+
});
|
|
161
|
+
if (migration.status === "migrated") {
|
|
162
|
+
startupProgress(`Wallet migrated from ${migration.from} to ${migration.to}.`);
|
|
163
|
+
for (const warning of migration.cleanupWarnings) logger.warn(warning);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const walletClient = await createCocoClient();
|
|
167
|
+
|
|
168
|
+
// ── Auto-refill configuration ────────────────────────────────
|
|
169
|
+
// Uses a getter that reads config from disk each cycle, so
|
|
170
|
+
// CLI changes take effect immediately without a daemon restart.
|
|
171
|
+
|
|
172
|
+
const getAutoRefillConfig = (): AutoRefillConfig | undefined => {
|
|
173
|
+
const cfg = loadDaemonConfigSync();
|
|
174
|
+
if (cfg.nwc?.autoRefill?.enabled && cfg.nwc?.connectionString) {
|
|
175
|
+
return {
|
|
176
|
+
threshold: cfg.nwc.autoRefill.threshold,
|
|
177
|
+
amount: cfg.nwc.autoRefill.amount,
|
|
178
|
+
cooldownMs: cfg.nwc.autoRefill.cooldownMs,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
return undefined;
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
const walletAdapter = await createWalletAdapter({
|
|
185
|
+
cocodPath: config.cocodPath,
|
|
186
|
+
walletClient,
|
|
187
|
+
getAutoRefillConfig,
|
|
188
|
+
nwcConnectionString: config.nwc?.connectionString,
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
const refundClient = new RoutstrClient(
|
|
192
|
+
walletAdapter,
|
|
193
|
+
storageAdapter,
|
|
194
|
+
discoveryAdapter,
|
|
195
|
+
"min",
|
|
196
|
+
"apikeys",
|
|
197
|
+
{ logger: daemonSdkLogger },
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
const server = createServer();
|
|
201
|
+
server.on(
|
|
202
|
+
"request",
|
|
203
|
+
createDaemonRequestHandler({
|
|
204
|
+
provider,
|
|
205
|
+
server,
|
|
206
|
+
shutdown: () => shutdownDaemon(),
|
|
207
|
+
store,
|
|
208
|
+
walletClient,
|
|
209
|
+
walletAdapter,
|
|
210
|
+
storageAdapter,
|
|
211
|
+
discoveryAdapter,
|
|
212
|
+
modelManager,
|
|
213
|
+
ensureProvidersBootstrapped,
|
|
214
|
+
getRoutstr21Models,
|
|
215
|
+
getModelProviders,
|
|
216
|
+
refreshProvidersAndModels,
|
|
217
|
+
mode: config.mode || "apikeys",
|
|
218
|
+
maxTokens: config.maxTokens ?? 64000,
|
|
219
|
+
routstrPubkey: config.routstrPubkey,
|
|
220
|
+
routstrModelsPubkey: config.routstrModelsPubkey,
|
|
221
|
+
usageTrackingDriver,
|
|
222
|
+
providerManager,
|
|
223
|
+
refundClient,
|
|
224
|
+
requestResponseLogSink,
|
|
225
|
+
}),
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
Bun.write(PID_FILE, String(process.pid));
|
|
229
|
+
|
|
230
|
+
try {
|
|
231
|
+
if (existsSync(SOCKET_PATH)) {
|
|
232
|
+
unlinkSync(SOCKET_PATH);
|
|
233
|
+
}
|
|
234
|
+
} catch {
|
|
235
|
+
// Ignore
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const REFRESH_INTERVAL_MS = 21 * 60 * 1000; // 21 mins
|
|
239
|
+
|
|
240
|
+
// Recurring job to refresh routstr21 models
|
|
241
|
+
let refreshInterval: ReturnType<typeof setInterval> | null = null;
|
|
242
|
+
|
|
243
|
+
const startModelRefreshJob = () => {
|
|
244
|
+
logger.log(
|
|
245
|
+
`Starting recurring model refresh job (every ${REFRESH_INTERVAL_MS / 1000 / 60 / 60} hours)`,
|
|
246
|
+
);
|
|
247
|
+
|
|
248
|
+
refreshInterval = setInterval(() => {
|
|
249
|
+
(async () => {
|
|
250
|
+
logger.log("Running scheduled Nostr event refresh...");
|
|
251
|
+
try {
|
|
252
|
+
await modelManager.refreshNostrEvents();
|
|
253
|
+
} catch (error) {
|
|
254
|
+
logger.error("Scheduled Nostr event refresh failed:", error);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
logger.log("Running scheduled model refresh...");
|
|
258
|
+
try {
|
|
259
|
+
await refreshModelsAndIntegrations(getRoutstr21Models, updatedConfig, "Scheduled");
|
|
260
|
+
} catch (error) {
|
|
261
|
+
logger.error("Scheduled model refresh failed:", error);
|
|
262
|
+
}
|
|
263
|
+
})().catch((error) => logger.error("Model refresh interval escaped:", error));
|
|
264
|
+
}, REFRESH_INTERVAL_MS);
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
const stopModelRefreshJob = () => {
|
|
268
|
+
if (refreshInterval) {
|
|
269
|
+
clearInterval(refreshInterval);
|
|
270
|
+
refreshInterval = null;
|
|
271
|
+
logger.log("Stopped recurring model refresh job.");
|
|
272
|
+
}
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
// Recurring job to refund pending tokens every 42 minutes
|
|
276
|
+
const REFUND_INTERVAL_MS = 42 * 60 * 1000; // 42 minutes
|
|
277
|
+
let refundInterval: ReturnType<typeof setInterval> | null = null;
|
|
278
|
+
|
|
279
|
+
const startRefundJob = async () => {
|
|
280
|
+
logger.log(
|
|
281
|
+
`Starting recurring refund job (every ${REFUND_INTERVAL_MS / 1000 / 60} minutes)`,
|
|
282
|
+
);
|
|
283
|
+
|
|
284
|
+
refundInterval = setInterval(() => {
|
|
285
|
+
(async () => {
|
|
286
|
+
logger.log("Running scheduled refund...");
|
|
287
|
+
try {
|
|
288
|
+
const state = store.getState() as any;
|
|
289
|
+
const pendingDistribution = (state.cachedTokens || []).map(
|
|
290
|
+
(t: { baseUrl: string; balance?: number }) => ({
|
|
291
|
+
baseUrl: t.baseUrl,
|
|
292
|
+
amount: t.balance || 0,
|
|
293
|
+
}),
|
|
294
|
+
);
|
|
295
|
+
const apiKeysStored = (state.apiKeys || []).map(
|
|
296
|
+
(k: { baseUrl: string; balance?: number }) => ({
|
|
297
|
+
baseUrl: k.baseUrl,
|
|
298
|
+
amount: k.balance || 0,
|
|
299
|
+
}),
|
|
300
|
+
);
|
|
301
|
+
|
|
302
|
+
if (pendingDistribution.length === 0 && apiKeysStored.length === 0) {
|
|
303
|
+
logger.log("No pending tokens to refund.");
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const mintUrl = walletAdapter.getActiveMintUrl();
|
|
308
|
+
if (!mintUrl) {
|
|
309
|
+
logger.log("No active mint URL for refund.");
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const spender = refundClient.getCashuSpender();
|
|
314
|
+
const results = await spender.refundProviders(mintUrl);
|
|
315
|
+
|
|
316
|
+
const successCount = results.filter(
|
|
317
|
+
(r: { success: boolean }) => r.success,
|
|
318
|
+
).length;
|
|
319
|
+
logger.log(
|
|
320
|
+
`Scheduled refund completed: ${successCount}/${results.length} providers refunded.`,
|
|
321
|
+
);
|
|
322
|
+
} catch (error) {
|
|
323
|
+
logger.error("Scheduled refund failed:", error);
|
|
324
|
+
}
|
|
325
|
+
})().catch((error) => logger.error("Refund interval escaped:", error));
|
|
326
|
+
}, REFUND_INTERVAL_MS);
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
const stopRefundJob = () => {
|
|
330
|
+
if (refundInterval) {
|
|
331
|
+
clearInterval(refundInterval);
|
|
332
|
+
refundInterval = null;
|
|
333
|
+
logger.log("Stopped recurring refund job.");
|
|
334
|
+
}
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
let walletDisposePromise: Promise<void> | undefined;
|
|
338
|
+
const disposeWallet = (): Promise<void> => {
|
|
339
|
+
walletDisposePromise ??= walletClient.dispose?.() || Promise.resolve();
|
|
340
|
+
return walletDisposePromise;
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
server.on("close", () => {
|
|
344
|
+
stopModelRefreshJob();
|
|
345
|
+
stopRefundJob();
|
|
346
|
+
void disposeWallet().catch((error) => {
|
|
347
|
+
logger.error("Failed to dispose wallet:", error);
|
|
348
|
+
});
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
shutdownDaemon = () => {
|
|
352
|
+
server.close(() => {
|
|
353
|
+
void disposeWallet().finally(() => process.exit(0));
|
|
354
|
+
});
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
// Without this a listen failure only reaches uncaughtException, which logs and
|
|
358
|
+
// returns, leaving the daemon alive holding the wallet locks with no listener.
|
|
359
|
+
server.on("error", (error) => {
|
|
360
|
+
logger.error("Failed to start Routstr daemon:", error);
|
|
361
|
+
console.error(
|
|
362
|
+
"Failed to start Routstr daemon:",
|
|
363
|
+
error instanceof Error ? error.message : error,
|
|
364
|
+
);
|
|
365
|
+
process.exit(1);
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
startupProgress("Starting HTTP server...");
|
|
369
|
+
server.listen(port, host, async () => {
|
|
370
|
+
logger.log(`Routstr daemon listening on http://${host}:${port}/v1`);
|
|
371
|
+
if (requestResponseLogDir) {
|
|
372
|
+
logger.log(`Raw request/response logs: ${requestResponseLogDir}`);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// Start the recurring model refresh job after initial bootstrap
|
|
376
|
+
void ensureProvidersBootstrapped()
|
|
377
|
+
.then(async () => {
|
|
378
|
+
// Catch up on any Nostr events published since last run
|
|
379
|
+
logger.log("Running initial Nostr event refresh...");
|
|
380
|
+
await modelManager.refreshNostrEvents();
|
|
381
|
+
|
|
382
|
+
startModelRefreshJob();
|
|
383
|
+
startRefundJob();
|
|
384
|
+
// Run an immediate refresh to populate models right away
|
|
385
|
+
logger.log("Running initial model refresh...");
|
|
386
|
+
await refreshModelsAndIntegrations(getRoutstr21Models, updatedConfig, "Initial");
|
|
387
|
+
})
|
|
388
|
+
.catch((error) => {
|
|
389
|
+
logger.error("Initial model refresh failed:", error);
|
|
390
|
+
// Still start the jobs even if initial refresh fails
|
|
391
|
+
startModelRefreshJob();
|
|
392
|
+
startRefundJob();
|
|
393
|
+
});
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
if (import.meta.main) {
|
|
398
|
+
main().catch((error) => {
|
|
399
|
+
logger.error("Failed to start Routstr daemon:", error);
|
|
400
|
+
// Also write to stderr so the spawning CLI can surface the real error
|
|
401
|
+
// (stdout/stderr are redirected to debug.log by start-daemon.ts).
|
|
402
|
+
console.error(
|
|
403
|
+
"Failed to start Routstr daemon:",
|
|
404
|
+
error instanceof Error ? error.message : error,
|
|
405
|
+
);
|
|
406
|
+
process.exit(1);
|
|
407
|
+
});
|
|
408
|
+
}
|