@wenathlan/getry 2.0.155 → 2.0.159
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/cli.ts +943 -0
- package/docs/features.md +104 -0
- package/docs/gateway.md +116 -0
- package/package.json +41 -29
- package/tests/design.test.ts +9 -9
- package/tests/jsonpolicy.test.ts +1 -1
- package/tests/providercli.test.ts +1 -1
- package/tests/sandboxmodality.test.ts +4 -4
- package/tests/streamfallback.test.ts +5 -1
- package/docs/.gitkeep +0 -0
- /package/tests/{provider.constants.test.ts → providerconstants.test.ts} +0 -0
- /package/tests/{provider.fingerprint.test.ts → providerfingerprint.test.ts} +0 -0
- /package/tests/{provider.models.test.ts → providermodels.test.ts} +0 -0
- /package/tests/{provider.oauth.test.ts → provideroauth.test.ts} +0 -0
- /package/tests/{server.auth.test.ts → serverauth.test.ts} +0 -0
- /package/tests/{server.engine.test.ts → serverengine.test.ts} +0 -0
- /package/tests/{server.property.test.ts → serverproperty.test.ts} +0 -0
- /package/tests/{server.utils.test.ts → serverutils.test.ts} +0 -0
- /package/tests/{torture.llm.test.ts → torturellm.test.ts} +0 -0
package/cli.ts
ADDED
|
@@ -0,0 +1,943 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file cli.ts
|
|
3
|
+
* @module gateway/cli
|
|
4
|
+
* @description
|
|
5
|
+
* The gateway console cli of the ownership wave (wave/ownership.gateway,
|
|
6
|
+
* step 4 — the final double move): moved verbatim from devthink/server.ts
|
|
7
|
+
* (the grand-merge interned gateway console cli), so the gateway owns its
|
|
8
|
+
* console family — one owner, the family consumes, and devthink re-exports
|
|
9
|
+
* the public face for the compat consumers.
|
|
10
|
+
*
|
|
11
|
+
* Responsibilities isolated here (one section, one module):
|
|
12
|
+
* - the console family — the zero-dependency arg parser, the version
|
|
13
|
+
* probe, the ansi colors, the shared stdin question stream, the config
|
|
14
|
+
* search and the scaffold generators (config.mjs, schema.prisma,
|
|
15
|
+
* prismaconfig.ts, .env.example), the interactive version builder, the
|
|
16
|
+
* command dispatch (init add list show validate keys models serve
|
|
17
|
+
* export help), the family entry runclifamily and the console main.
|
|
18
|
+
*
|
|
19
|
+
* Architectural constraints:
|
|
20
|
+
* - Verbatim move: every export name and signature preserved — the
|
|
21
|
+
* devthink server keeps answering the family through one re-export
|
|
22
|
+
* shim, so no consumer edits with the move.
|
|
23
|
+
* - The serve seam (the step 3 report): cmdserve boots THIS owner's
|
|
24
|
+
* transport main (./server.js — the getry transport boot). The devthink
|
|
25
|
+
* server entry keeps its own composed boot for its os lanes and answers
|
|
26
|
+
* serve there itself; the autorun guard below moved with the family and
|
|
27
|
+
* is re-keyed on the cli module, so only a direct `bun cli.ts` (or a
|
|
28
|
+
* built cli.js entry) run executes the console here.
|
|
29
|
+
* - The banner text keeps the historical string the cli test pins
|
|
30
|
+
* ("@wenathlan/devthink gateway"); the house may rename faces in a
|
|
31
|
+
* later wave.
|
|
32
|
+
*
|
|
33
|
+
* Version: 2.1.8 — the ownership wave console cli extraction
|
|
34
|
+
*
|
|
35
|
+
* Related modules:
|
|
36
|
+
* - server.ts — the transport this cli's serve boots (main/runserver)
|
|
37
|
+
* - devthink/server.ts — the compat re-export face of the moved family
|
|
38
|
+
*
|
|
39
|
+
* @author devthink
|
|
40
|
+
* @license MIT
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
44
|
+
import { basename, join, relative, resolve } from "node:path";
|
|
45
|
+
import { stdin, stdout } from "node:process";
|
|
46
|
+
import { createInterface, type Interface } from "node:readline/promises";
|
|
47
|
+
import { main } from "./server.js";
|
|
48
|
+
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
// arg parsing — zero dependency
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
type parsedargs = { flags: Record<string, string | boolean>; positional: string[] };
|
|
54
|
+
|
|
55
|
+
function parseargs(args: string[]): parsedargs {
|
|
56
|
+
const flags: Record<string, string | boolean> = {};
|
|
57
|
+
const positional: string[] = [];
|
|
58
|
+
for (let i = 0; i < args.length; i++) {
|
|
59
|
+
const value = args[i];
|
|
60
|
+
if (!value.startsWith("-")) {
|
|
61
|
+
positional.push(value);
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
const key = value.replace(/^-+/, "");
|
|
65
|
+
const next = args[i + 1];
|
|
66
|
+
if (next && !next.startsWith("-")) {
|
|
67
|
+
flags[key] = next;
|
|
68
|
+
i += 1;
|
|
69
|
+
} else flags[key] = true;
|
|
70
|
+
}
|
|
71
|
+
return { flags, positional };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function stringflag(parsed: parsedargs, key: string): string | undefined {
|
|
75
|
+
const value = parsed.flags[key];
|
|
76
|
+
return typeof value === "string" ? value : undefined;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
// version — from package.json or env override
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
function version(): string {
|
|
84
|
+
const embedded = process.env.DEVTHINK_VERSION?.trim();
|
|
85
|
+
if (embedded) return embedded;
|
|
86
|
+
// probe both the module directory (source tree) and its parent (the
|
|
87
|
+
// built build/ layout) so the bundled cli.js resolves the version too
|
|
88
|
+
for (const candidate of ["./package.json", "../package.json"]) {
|
|
89
|
+
try {
|
|
90
|
+
const file = new URL(candidate, import.meta.url);
|
|
91
|
+
return String((JSON.parse(readFileSync(file, "utf8")) as { version?: string }).version || "0.0.0");
|
|
92
|
+
} catch {
|
|
93
|
+
/* the guarded best-effort operation falls through: the outer flow owns the failure */
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return "0.0.0";
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
// console colors — ansi escape codes
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
const colors = stdout.isTTY
|
|
104
|
+
? {
|
|
105
|
+
cyan: "\u001b[36m",
|
|
106
|
+
green: "\u001b[32m",
|
|
107
|
+
yellow: "\u001b[33m",
|
|
108
|
+
red: "\u001b[31m",
|
|
109
|
+
bold: "\u001b[1m",
|
|
110
|
+
dim: "\u001b[2m",
|
|
111
|
+
reset: "\u001b[0m",
|
|
112
|
+
}
|
|
113
|
+
: {
|
|
114
|
+
cyan: "",
|
|
115
|
+
green: "",
|
|
116
|
+
yellow: "",
|
|
117
|
+
red: "",
|
|
118
|
+
bold: "",
|
|
119
|
+
dim: "",
|
|
120
|
+
reset: "",
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
// readline — interactive question helper
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
/** the stdin question stream — ONE readline interface serves every question
|
|
128
|
+
* of a command and buffers lines that arrive while no question is pending
|
|
129
|
+
* (sequential question() calls drop piped input: the 'line' events fire
|
|
130
|
+
* with no listener and the answers are lost). interactive typing resolves
|
|
131
|
+
* the pending question directly; piped answers queue; end-of-input answers
|
|
132
|
+
* every remaining question with its default so piped/automation runs
|
|
133
|
+
* complete. main() closes the interface so the process never hangs. */
|
|
134
|
+
let sharedrl: Interface | undefined;
|
|
135
|
+
const pendinglines: string[] = [];
|
|
136
|
+
let lineresolver: ((line: string) => void) | undefined;
|
|
137
|
+
let stdineof = false;
|
|
138
|
+
|
|
139
|
+
function questionstream(): Interface {
|
|
140
|
+
if (sharedrl) return sharedrl;
|
|
141
|
+
sharedrl = createInterface({ input: stdin, output: stdout });
|
|
142
|
+
sharedrl.on("line", (line: string) => {
|
|
143
|
+
if (lineresolver) {
|
|
144
|
+
const resolve = lineresolver;
|
|
145
|
+
lineresolver = undefined;
|
|
146
|
+
resolve(line);
|
|
147
|
+
} else {
|
|
148
|
+
pendinglines.push(line);
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
sharedrl.on("close", () => {
|
|
152
|
+
stdineof = true;
|
|
153
|
+
if (lineresolver) {
|
|
154
|
+
const resolve = lineresolver;
|
|
155
|
+
lineresolver = undefined;
|
|
156
|
+
resolve("");
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
return sharedrl;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function ask(question: string, defaultanswer = ""): Promise<string> {
|
|
163
|
+
questionstream();
|
|
164
|
+
const suffix = defaultanswer ? ` (${defaultanswer})` : "";
|
|
165
|
+
stdout.write(`${question}${suffix}: `);
|
|
166
|
+
let answer: string;
|
|
167
|
+
if (pendinglines.length > 0) {
|
|
168
|
+
answer = pendinglines.shift() as string;
|
|
169
|
+
} else if (stdineof) {
|
|
170
|
+
answer = "";
|
|
171
|
+
} else {
|
|
172
|
+
answer = await new Promise<string>((resolve) => {
|
|
173
|
+
lineresolver = resolve;
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
return answer.trim() || defaultanswer;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function askyesno(question: string, defaultanswer = false): Promise<boolean> {
|
|
180
|
+
const hint = defaultanswer ? "Y/n" : "y/N";
|
|
181
|
+
const answer = await ask(`${question} [${hint}]`);
|
|
182
|
+
if (!answer) return defaultanswer;
|
|
183
|
+
return answer.toLowerCase().startsWith("y");
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ---------------------------------------------------------------------------
|
|
187
|
+
// config file management
|
|
188
|
+
// ---------------------------------------------------------------------------
|
|
189
|
+
|
|
190
|
+
/** the config search names — .mjs first: it is the only flavor immune to
|
|
191
|
+
* the nearest package.json type field (a plain `npm init -y` consumer has
|
|
192
|
+
* no "type": "module", so node reads an esm-syntax .ts or .js as commonjs
|
|
193
|
+
* and the import dies — the .mjs extension is module-typed by the file
|
|
194
|
+
* name itself and loads everywhere) */
|
|
195
|
+
const confignames = [
|
|
196
|
+
"config.mjs",
|
|
197
|
+
"config.ts",
|
|
198
|
+
"config.js",
|
|
199
|
+
"sol/config.mjs",
|
|
200
|
+
"sol/config.ts",
|
|
201
|
+
"sol/config.js",
|
|
202
|
+
"gatewayconfig.mjs",
|
|
203
|
+
"gatewayconfig.ts",
|
|
204
|
+
"gatewayconfig.js",
|
|
205
|
+
];
|
|
206
|
+
|
|
207
|
+
/** config path — the scaffold target: config.mjs in cwd (the legacy sol/config.mjs location stays a load candidate) */
|
|
208
|
+
function configpath(): string {
|
|
209
|
+
return resolve(process.cwd(), "config.mjs");
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** find the config file — returns path plus parsed config or null */
|
|
213
|
+
async function findconfig(): Promise<{
|
|
214
|
+
path: string;
|
|
215
|
+
config: Record<string, unknown>;
|
|
216
|
+
} | null> {
|
|
217
|
+
for (const name of confignames) {
|
|
218
|
+
const path = resolve(process.cwd(), name);
|
|
219
|
+
if (!existsSync(path)) continue;
|
|
220
|
+
try {
|
|
221
|
+
const mod = await import(/* @vite-ignore */ path);
|
|
222
|
+
const config = (mod.default ?? mod.config ?? mod) as Record<string, unknown>;
|
|
223
|
+
if (config && typeof config === "object" && "versions" in config) {
|
|
224
|
+
return { path, config };
|
|
225
|
+
}
|
|
226
|
+
} catch {
|
|
227
|
+
// unreadable config flavor — try the next candidate
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return null;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** read the existing config or return null */
|
|
234
|
+
async function readconfig(): Promise<Record<string, unknown> | null> {
|
|
235
|
+
return (await findconfig())?.config ?? null;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** generate the config file content from a definition object */
|
|
239
|
+
function generateconfigcontent(def: Record<string, unknown>): string {
|
|
240
|
+
return [
|
|
241
|
+
"/**",
|
|
242
|
+
" * web config — user customization layer for the gateway",
|
|
243
|
+
" * generated by the cli — edit freely",
|
|
244
|
+
" * every value here is what you customize",
|
|
245
|
+
" */",
|
|
246
|
+
"",
|
|
247
|
+
`export const config = ${jsontopretty(def)}`,
|
|
248
|
+
"",
|
|
249
|
+
"export default config",
|
|
250
|
+
"",
|
|
251
|
+
].join("\n");
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** json to pretty typescript literal */
|
|
255
|
+
function jsontopretty(value: unknown, indent = 0): string {
|
|
256
|
+
const pad = " ".repeat(indent);
|
|
257
|
+
const padinner = " ".repeat(indent + 1);
|
|
258
|
+
if (value === null || value === undefined) return "null";
|
|
259
|
+
if (typeof value === "string") return JSON.stringify(value);
|
|
260
|
+
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
|
261
|
+
if (Array.isArray(value)) {
|
|
262
|
+
if (value.length === 0) return "[]";
|
|
263
|
+
const items = value.map((v) => `${padinner}${jsontopretty(v, indent + 1)}`);
|
|
264
|
+
return `[\n${items.join(",\n")},\n${pad}]`;
|
|
265
|
+
}
|
|
266
|
+
if (typeof value === "object") {
|
|
267
|
+
const entries = Object.entries(value as Record<string, unknown>);
|
|
268
|
+
if (entries.length === 0) return "{}";
|
|
269
|
+
const lines = entries.map(([k, v]) => `${padinner}${k}: ${jsontopretty(v, indent + 1)}`);
|
|
270
|
+
return `{\n${lines.join(",\n")},\n${pad}}`;
|
|
271
|
+
}
|
|
272
|
+
return JSON.stringify(value);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// ---------------------------------------------------------------------------
|
|
276
|
+
// schema template — the prisma schema scaffolded for new users
|
|
277
|
+
// (prisma 7 style: no url in the schema — the datasource url lives in
|
|
278
|
+
// prismaconfig.ts, scaffolded below)
|
|
279
|
+
// ---------------------------------------------------------------------------
|
|
280
|
+
|
|
281
|
+
const schematemplate = `generator client {
|
|
282
|
+
provider = "prisma-client-js"
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
datasource db {
|
|
286
|
+
provider = "sqlite"
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
model ChatMessage {
|
|
290
|
+
id String @id @default(cuid())
|
|
291
|
+
requestid String?
|
|
292
|
+
sessionid String?
|
|
293
|
+
route String?
|
|
294
|
+
provider String?
|
|
295
|
+
version String?
|
|
296
|
+
model String?
|
|
297
|
+
modelvariant String?
|
|
298
|
+
content String?
|
|
299
|
+
reasoningcontent String?
|
|
300
|
+
completion String?
|
|
301
|
+
prompttokens Int?
|
|
302
|
+
completiontokens Int?
|
|
303
|
+
totaltokens Int?
|
|
304
|
+
stream Boolean?
|
|
305
|
+
streammode String?
|
|
306
|
+
endpoint String?
|
|
307
|
+
twocalls Boolean?
|
|
308
|
+
thinkingbudget Int?
|
|
309
|
+
status String?
|
|
310
|
+
httpstatus Int?
|
|
311
|
+
error String?
|
|
312
|
+
rotationindex Int?
|
|
313
|
+
messagenumber Int?
|
|
314
|
+
contextshared Boolean?
|
|
315
|
+
startedat DateTime? @default(now())
|
|
316
|
+
createdat DateTime @default(now())
|
|
317
|
+
|
|
318
|
+
@@index([sessionid])
|
|
319
|
+
@@index([route])
|
|
320
|
+
@@index([model])
|
|
321
|
+
@@index([provider])
|
|
322
|
+
@@index([status])
|
|
323
|
+
@@index([createdat])
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
model ApiKey {
|
|
327
|
+
id String @id @default(cuid())
|
|
328
|
+
keyValue String @unique
|
|
329
|
+
label String?
|
|
330
|
+
active Boolean @default(true)
|
|
331
|
+
status String @default("active")
|
|
332
|
+
useCount Int @default(0)
|
|
333
|
+
errorCount Int @default(0)
|
|
334
|
+
lastUsedAt DateTime?
|
|
335
|
+
lastError String?
|
|
336
|
+
lastErrorAt DateTime?
|
|
337
|
+
createdat DateTime @default(now())
|
|
338
|
+
|
|
339
|
+
@@index([active, status])
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
model NvidiaKey {
|
|
343
|
+
id String @id @default(cuid())
|
|
344
|
+
keyValue String @unique
|
|
345
|
+
label String?
|
|
346
|
+
active Boolean @default(true)
|
|
347
|
+
status String @default("active")
|
|
348
|
+
useCount Int @default(0)
|
|
349
|
+
errorCount Int @default(0)
|
|
350
|
+
lastUsedAt DateTime?
|
|
351
|
+
lastError String?
|
|
352
|
+
lastErrorAt DateTime?
|
|
353
|
+
createdat DateTime @default(now())
|
|
354
|
+
|
|
355
|
+
@@index([active, status])
|
|
356
|
+
}
|
|
357
|
+
`;
|
|
358
|
+
|
|
359
|
+
// ---------------------------------------------------------------------------
|
|
360
|
+
// prisma config template — scaffolded for new users (prisma 7: the
|
|
361
|
+
// datasource url lives here, not in the schema; the default matches the
|
|
362
|
+
// library runtime fallback so db push and serve share one sqlite)
|
|
363
|
+
// ---------------------------------------------------------------------------
|
|
364
|
+
|
|
365
|
+
const prismaconfigtemplate = `/** prisma config — the datasource url lives here (prisma 7 moved it
|
|
366
|
+
* out of the schema file). the schema is schema.prisma at the project
|
|
367
|
+
* root; the url comes from DEVTHINK_DATABASE_URL or DATABASE_URL,
|
|
368
|
+
* defaulting to the local sqlite at devthink.db — the same default the
|
|
369
|
+
* gateway library runtime uses, so the pushed database and the serving
|
|
370
|
+
* process agree on one file with zero configuration */
|
|
371
|
+
|
|
372
|
+
import { existsSync } from "node:fs";
|
|
373
|
+
import path from "node:path";
|
|
374
|
+
import { defineConfig } from "prisma/config";
|
|
375
|
+
|
|
376
|
+
/** resolve schema path — schema.prisma at the root is the standard location */
|
|
377
|
+
function resolveschemapath(): string {
|
|
378
|
+
const candidates = [
|
|
379
|
+
path.resolve(process.cwd(), "schema.prisma"),
|
|
380
|
+
path.resolve(process.cwd(), "sol", "schema.prisma"),
|
|
381
|
+
path.resolve(process.cwd(), "prisma", "schema.prisma"),
|
|
382
|
+
];
|
|
383
|
+
for (const candidate of candidates) {
|
|
384
|
+
if (existsSync(candidate)) return candidate;
|
|
385
|
+
}
|
|
386
|
+
return candidates[0];
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
export default defineConfig({
|
|
390
|
+
schema: resolveschemapath(),
|
|
391
|
+
datasource: {
|
|
392
|
+
url:
|
|
393
|
+
process.env.DEVTHINK_DATABASE_URL ||
|
|
394
|
+
process.env.DATABASE_URL ||
|
|
395
|
+
"file:./devthink.db",
|
|
396
|
+
},
|
|
397
|
+
});
|
|
398
|
+
`;
|
|
399
|
+
|
|
400
|
+
// ---------------------------------------------------------------------------
|
|
401
|
+
// init — scaffold the whole project
|
|
402
|
+
// ---------------------------------------------------------------------------
|
|
403
|
+
|
|
404
|
+
async function cmdinit(): Promise<void> {
|
|
405
|
+
console.log(`\n${colors.cyan}${colors.bold}@wenathlan/devthink gateway${colors.reset} v${version()} — init\n`);
|
|
406
|
+
|
|
407
|
+
const targetdir = process.cwd();
|
|
408
|
+
|
|
409
|
+
// check existing config — any flavor counts, the scaffold writes .mjs
|
|
410
|
+
const existing = await findconfig();
|
|
411
|
+
if (existing || existsSync(configpath())) {
|
|
412
|
+
const overwrite = await askyesno(
|
|
413
|
+
`config already exists${existing ? ` (${relative(process.cwd(), existing.path)})` : ""} — overwrite?`,
|
|
414
|
+
false,
|
|
415
|
+
);
|
|
416
|
+
if (!overwrite) {
|
|
417
|
+
console.log("keeping existing config");
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// interactive scaffold — how many versions
|
|
423
|
+
console.log("\nthis scaffolds your gateway configuration");
|
|
424
|
+
console.log(
|
|
425
|
+
"each version gets all 7 routes: chat/completions completions messages responses embeddings keys models\n",
|
|
426
|
+
);
|
|
427
|
+
|
|
428
|
+
const versioncountanswer = await ask("how many versions (route groups) do you want", "1");
|
|
429
|
+
const versioncount = Math.max(1, Math.min(20, Number(versioncountanswer) || 1));
|
|
430
|
+
|
|
431
|
+
const versions: Record<string, unknown> = {};
|
|
432
|
+
|
|
433
|
+
for (let i = 0; i < versioncount; i++) {
|
|
434
|
+
console.log(`\n${colors.bold}--- version ${i + 1} of ${versioncount} ---${colors.reset}`);
|
|
435
|
+
const vconfig = await askversiondetails(i + 1);
|
|
436
|
+
versions[vconfig.id as string] = vconfig;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
const definition = {
|
|
440
|
+
name: await ask("gateway name", "My Gateway"),
|
|
441
|
+
description: await ask("gateway description", "multi provider ai gateway"),
|
|
442
|
+
versions,
|
|
443
|
+
};
|
|
444
|
+
|
|
445
|
+
// write config — .mjs loads under every package.json type (a plain
|
|
446
|
+
// npm init -y consumer has no type module: an esm .ts or .js dies there)
|
|
447
|
+
writeFileSync(configpath(), generateconfigcontent(definition));
|
|
448
|
+
console.log(`\n${colors.green}created${colors.reset} config.mjs`);
|
|
449
|
+
|
|
450
|
+
// write schema
|
|
451
|
+
const schemapath = join(targetdir, "schema.prisma");
|
|
452
|
+
writeFileSync(schemapath, schematemplate);
|
|
453
|
+
console.log(`${colors.green}created${colors.reset} schema.prisma`);
|
|
454
|
+
|
|
455
|
+
// write prisma config — prisma 7 carries the datasource url here (out of
|
|
456
|
+
// the schema file), and the default matches the library runtime fallback
|
|
457
|
+
// so db push and serve agree on one sqlite with zero configuration
|
|
458
|
+
writeFileSync(join(targetdir, "prismaconfig.ts"), prismaconfigtemplate);
|
|
459
|
+
console.log(`${colors.green}created${colors.reset} prismaconfig.ts`);
|
|
460
|
+
|
|
461
|
+
// write env example
|
|
462
|
+
const envpath = join(targetdir, ".env.example");
|
|
463
|
+
const envlines: string[] = [
|
|
464
|
+
"# gateway environment",
|
|
465
|
+
"# database url — libsql http postgres or file",
|
|
466
|
+
"# DEVTHINK_DATABASE_URL=file:./devthink.db",
|
|
467
|
+
"",
|
|
468
|
+
];
|
|
469
|
+
for (const [id, v] of Object.entries(versions)) {
|
|
470
|
+
const auth = (v as Record<string, unknown>).auth as Record<string, unknown> | undefined;
|
|
471
|
+
if (auth?.envvar) {
|
|
472
|
+
envlines.push(`# ${id} api key`);
|
|
473
|
+
envlines.push(`${auth.envvar}=`);
|
|
474
|
+
envlines.push("");
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
writeFileSync(envpath, envlines.join("\n"));
|
|
478
|
+
console.log(`${colors.green}created${colors.reset} .env.example`);
|
|
479
|
+
|
|
480
|
+
console.log(`\n${colors.green}${colors.bold}done${colors.reset} — next steps:`);
|
|
481
|
+
console.log(" 1. edit config.mjs to tune your configuration");
|
|
482
|
+
console.log(" 2. register keys: npx @wenathlan/devthink gateway keys <version>");
|
|
483
|
+
console.log(" 3. install prisma: npm i -D prisma");
|
|
484
|
+
console.log(" 4. push + generate: npx prisma db push --config prismaconfig.ts && npx prisma generate --config prismaconfig.ts");
|
|
485
|
+
console.log(" 5. start the server: npx @wenathlan/devthink gateway serve");
|
|
486
|
+
console.log("");
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// ---------------------------------------------------------------------------
|
|
490
|
+
// askversiondetails — interactive version builder
|
|
491
|
+
// ---------------------------------------------------------------------------
|
|
492
|
+
|
|
493
|
+
async function askversiondetails(index: number): Promise<Record<string, unknown>> {
|
|
494
|
+
const id = await ask(`version id (path prefix)`, `v${index}`);
|
|
495
|
+
const providername = await ask("provider name (zai nvidia openai openrouter custom)", "custom");
|
|
496
|
+
const baseurl = await ask("upstream base url", "https://gateway.example/v1");
|
|
497
|
+
|
|
498
|
+
// auth
|
|
499
|
+
console.log(
|
|
500
|
+
"\nauth methods: bearer apikeyheader queryparam basic oauth2clientcredentials jwtsign sigv4 anonymous keylesssdk none",
|
|
501
|
+
);
|
|
502
|
+
const authmode = await ask("auth method", "bearer");
|
|
503
|
+
const needskey = ["bearer", "apikeyheader", "queryparam", "basic"].includes(authmode);
|
|
504
|
+
const auth: Record<string, unknown> = { mode: authmode };
|
|
505
|
+
if (needskey) {
|
|
506
|
+
auth.required = await askyesno("is the api key required?", true);
|
|
507
|
+
const envvar = await ask(
|
|
508
|
+
"env var name for the key",
|
|
509
|
+
`${providername.toUpperCase().replace(/[^A-Z]/g, "_")}_API_KEY`,
|
|
510
|
+
);
|
|
511
|
+
auth.envvar = envvar;
|
|
512
|
+
const usedb = await askyesno("store keys in the database (rotation)?", false);
|
|
513
|
+
auth.keysources = usedb ? ["db", "env"] : ["env"];
|
|
514
|
+
if (usedb) auth.dbmodel = await ask("prisma key model name", "apiKey");
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// models
|
|
518
|
+
const modelcountanswer = await ask("how many models", "2");
|
|
519
|
+
const modelcount = Math.max(1, Number(modelcountanswer) || 2);
|
|
520
|
+
const models: Array<Record<string, unknown>> = [];
|
|
521
|
+
for (let m = 0; m < modelcount; m++) {
|
|
522
|
+
console.log(`\nmodel ${m + 1} of ${modelcount}`);
|
|
523
|
+
const mid = await ask("model id", m === 0 ? "default-model" : `model-${m + 1}`);
|
|
524
|
+
const context = Number(await ask("context window", "131072"));
|
|
525
|
+
const maxoutput = Number(await ask("max output tokens", "32768"));
|
|
526
|
+
models.push({ id: mid, context, maxoutput, free: true });
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// rotation
|
|
530
|
+
const userotation = await askyesno("\nrotate models (meta model pattern)?", modelcount > 1);
|
|
531
|
+
let rotation: Record<string, unknown> | undefined;
|
|
532
|
+
if (userotation) {
|
|
533
|
+
rotation = {
|
|
534
|
+
mode: await ask("rotation mode (persession perrequest)", "persession"),
|
|
535
|
+
models: models.map((m) => m.id),
|
|
536
|
+
everynmessages: Number(await ask("rotate every n messages", "6")),
|
|
537
|
+
maxmodelrotations: Number(await ask("max model rotations per request", "4")),
|
|
538
|
+
rotateonstatus: [529, 404, 410],
|
|
539
|
+
rotateontimeout: true,
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// retry
|
|
544
|
+
const useretry = await askyesno("\nenable retry with backoff?", true);
|
|
545
|
+
let retry: Record<string, unknown> | undefined;
|
|
546
|
+
if (useretry) {
|
|
547
|
+
retry = {
|
|
548
|
+
maxretries: Number(await ask("max retries", "3")),
|
|
549
|
+
backoffbasems: Number(await ask("backoff base ms", "400")),
|
|
550
|
+
backoffcapms: Number(await ask("backoff cap ms", "8000")),
|
|
551
|
+
jitter: 0.2,
|
|
552
|
+
statuses: [429, 502, 503, 504],
|
|
553
|
+
nonretryable: [400],
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// context
|
|
558
|
+
const usecontext = await askyesno("\nshare context across sessions (db history)?", false);
|
|
559
|
+
const context: Record<string, unknown> | undefined = usecontext
|
|
560
|
+
? {
|
|
561
|
+
restoresessionhistory: true,
|
|
562
|
+
historylimit: 1000,
|
|
563
|
+
truncatemargin: 4096,
|
|
564
|
+
percallfallback: 1048576,
|
|
565
|
+
}
|
|
566
|
+
: undefined;
|
|
567
|
+
|
|
568
|
+
// meta model
|
|
569
|
+
const metaid = await ask("\nmeta model id (the rotation facade)", "devthink");
|
|
570
|
+
|
|
571
|
+
return {
|
|
572
|
+
id,
|
|
573
|
+
providername,
|
|
574
|
+
upstreams: [{ name: providername, baseurl }],
|
|
575
|
+
auth,
|
|
576
|
+
models,
|
|
577
|
+
defaultmodel: rotation ? metaid : models[0].id,
|
|
578
|
+
...(rotation ? { rotation } : {}),
|
|
579
|
+
...(retry ? { retry } : {}),
|
|
580
|
+
...(context ? { context } : {}),
|
|
581
|
+
timeout: { requestms: 300000, streamms: 2147483647 },
|
|
582
|
+
thinking: { defaultlevel: "high" },
|
|
583
|
+
bodybuild: { optionalparamspolicy: "omit-unspecified", clampmaxtokens: true },
|
|
584
|
+
metamodel: {
|
|
585
|
+
id: metaid,
|
|
586
|
+
fallbackcontext: 1048576,
|
|
587
|
+
maxoutput: 32768,
|
|
588
|
+
maskupstreammodel: true,
|
|
589
|
+
alwaysdisplay: true,
|
|
590
|
+
},
|
|
591
|
+
headers: { useragent: `Gateway/${id}` },
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// ---------------------------------------------------------------------------
|
|
596
|
+
// add — add a new version to the existing config
|
|
597
|
+
// ---------------------------------------------------------------------------
|
|
598
|
+
|
|
599
|
+
async function cmdadd(_versionarg: string | undefined): Promise<void> {
|
|
600
|
+
const existing = await findconfig();
|
|
601
|
+
if (!existing) {
|
|
602
|
+
console.log(`${colors.red}no config found${colors.reset} — run init first`);
|
|
603
|
+
process.exit(1);
|
|
604
|
+
}
|
|
605
|
+
const nextindex = Object.keys((existing.config.versions as Record<string, unknown>) ?? {}).length + 1;
|
|
606
|
+
console.log(`\n${colors.cyan}${colors.bold}add version${colors.reset}\n`);
|
|
607
|
+
const vconfig = await askversiondetails(nextindex);
|
|
608
|
+
const versions = (existing.config.versions as Record<string, unknown>) ?? {};
|
|
609
|
+
versions[vconfig.id as string] = vconfig;
|
|
610
|
+
existing.config.versions = versions;
|
|
611
|
+
writeFileSync(existing.path, generateconfigcontent(existing.config));
|
|
612
|
+
console.log(
|
|
613
|
+
`\n${colors.green}added${colors.reset} version ${vconfig.id} to ${relative(process.cwd(), existing.path)}`,
|
|
614
|
+
);
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// ---------------------------------------------------------------------------
|
|
618
|
+
// list — show configured versions
|
|
619
|
+
// ---------------------------------------------------------------------------
|
|
620
|
+
|
|
621
|
+
async function cmdlist(): Promise<void> {
|
|
622
|
+
const config = await readconfig();
|
|
623
|
+
if (!config) {
|
|
624
|
+
console.log(`${colors.red}no config found${colors.reset} — run init first`);
|
|
625
|
+
process.exit(1);
|
|
626
|
+
}
|
|
627
|
+
const versions = (config.versions as Record<string, Record<string, unknown>>) ?? {};
|
|
628
|
+
console.log(
|
|
629
|
+
`\n${colors.bold}${config.name ?? "gateway"}${colors.reset} — ${Object.keys(versions).length} versions\n`,
|
|
630
|
+
);
|
|
631
|
+
for (const [id, v] of Object.entries(versions)) {
|
|
632
|
+
const models = (v.models as unknown[])?.length ?? 0;
|
|
633
|
+
const auth = v.auth as Record<string, unknown> | undefined;
|
|
634
|
+
const rotation = v.rotation as Record<string, unknown> | undefined;
|
|
635
|
+
console.log(` ${colors.cyan}${id}${colors.reset}`);
|
|
636
|
+
console.log(
|
|
637
|
+
` provider: ${v.providername ?? "?"} upstream: ${(v.upstreams as Array<Record<string, unknown>>)?.[0]?.baseurl ?? "?"}`,
|
|
638
|
+
);
|
|
639
|
+
console.log(` models: ${models} auth: ${auth?.mode ?? "?"} rotation: ${rotation?.mode ?? "none"}`);
|
|
640
|
+
}
|
|
641
|
+
console.log("");
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// ---------------------------------------------------------------------------
|
|
645
|
+
// show — show one version detail
|
|
646
|
+
// ---------------------------------------------------------------------------
|
|
647
|
+
|
|
648
|
+
async function cmdshow(versionarg: string | undefined): Promise<void> {
|
|
649
|
+
if (!versionarg) {
|
|
650
|
+
console.log("usage: gateway show <version>");
|
|
651
|
+
process.exit(1);
|
|
652
|
+
}
|
|
653
|
+
const config = await readconfig();
|
|
654
|
+
const versions = (config?.versions as Record<string, unknown>) ?? {};
|
|
655
|
+
const v = versions[versionarg];
|
|
656
|
+
if (!v) {
|
|
657
|
+
console.log(`${colors.red}version ${versionarg} not found${colors.reset}`);
|
|
658
|
+
process.exit(1);
|
|
659
|
+
}
|
|
660
|
+
console.log(jsontopretty(v));
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
// ---------------------------------------------------------------------------
|
|
664
|
+
// validate — check the config
|
|
665
|
+
// ---------------------------------------------------------------------------
|
|
666
|
+
|
|
667
|
+
async function cmdvalidate(): Promise<void> {
|
|
668
|
+
const config = await readconfig();
|
|
669
|
+
if (!config) {
|
|
670
|
+
console.log(`${colors.red}no config found${colors.reset} — run init first`);
|
|
671
|
+
process.exit(1);
|
|
672
|
+
}
|
|
673
|
+
const versions = (config.versions as Record<string, Record<string, unknown>>) ?? {};
|
|
674
|
+
const problems: string[] = [];
|
|
675
|
+
if (Object.keys(versions).length === 0) problems.push("no versions defined");
|
|
676
|
+
for (const [id, v] of Object.entries(versions)) {
|
|
677
|
+
if (!v.providername) problems.push(`version ${id}: missing providername`);
|
|
678
|
+
if (!v.upstreams) problems.push(`version ${id}: missing upstreams`);
|
|
679
|
+
if (!v.auth) problems.push(`version ${id}: missing auth`);
|
|
680
|
+
if (!v.models || (v.models as unknown[]).length === 0) problems.push(`version ${id}: no models`);
|
|
681
|
+
if (!v.metamodel) problems.push(`version ${id}: missing metamodel`);
|
|
682
|
+
}
|
|
683
|
+
if (problems.length === 0) {
|
|
684
|
+
console.log(`${colors.green}config is valid${colors.reset} — ${Object.keys(versions).length} versions`);
|
|
685
|
+
} else {
|
|
686
|
+
console.log(`${colors.yellow}problems found${colors.reset}:`);
|
|
687
|
+
for (const p of problems) console.log(` - ${p}`);
|
|
688
|
+
process.exit(1);
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
// ---------------------------------------------------------------------------
|
|
693
|
+
// keys — register api keys
|
|
694
|
+
// ---------------------------------------------------------------------------
|
|
695
|
+
|
|
696
|
+
async function cmdkeys(versionarg: string | undefined): Promise<void> {
|
|
697
|
+
if (!versionarg) {
|
|
698
|
+
console.log("usage: gateway keys <version>");
|
|
699
|
+
process.exit(1);
|
|
700
|
+
}
|
|
701
|
+
const config = await readconfig();
|
|
702
|
+
const versions = (config?.versions as Record<string, Record<string, unknown>>) ?? {};
|
|
703
|
+
const v = versions[versionarg];
|
|
704
|
+
if (!v) {
|
|
705
|
+
console.log(`${colors.red}version ${versionarg} not found${colors.reset}`);
|
|
706
|
+
process.exit(1);
|
|
707
|
+
}
|
|
708
|
+
const auth = v.auth as Record<string, unknown>;
|
|
709
|
+
const envvar = auth?.envvar as string | undefined;
|
|
710
|
+
const dbmodel = auth?.dbmodel as string | undefined;
|
|
711
|
+
|
|
712
|
+
console.log(`\n${colors.bold}register keys for ${versionarg}${colors.reset}`);
|
|
713
|
+
console.log(`auth mode: ${auth?.mode ?? "?"}`);
|
|
714
|
+
|
|
715
|
+
if (envvar) {
|
|
716
|
+
console.log(`\nset keys via environment variable:`);
|
|
717
|
+
console.log(` export ${envvar}=key1,key2,key3 (comma separated for rotation)`);
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
if (dbmodel) {
|
|
721
|
+
console.log(`\nregister keys in the database (rotation with round robin):`);
|
|
722
|
+
const keycountanswer = await ask("how many keys to register now", "0");
|
|
723
|
+
const keycount = Number(keycountanswer) || 0;
|
|
724
|
+
if (keycount > 0) {
|
|
725
|
+
const keys: string[] = [];
|
|
726
|
+
for (let i = 0; i < keycount; i++) {
|
|
727
|
+
const key = await ask(`key ${i + 1}`);
|
|
728
|
+
if (key) keys.push(key);
|
|
729
|
+
}
|
|
730
|
+
// write to a seed script the user can run
|
|
731
|
+
const seedpath = resolve(process.cwd(), "tests", "seedkeys.mjs");
|
|
732
|
+
const seedcontent = [
|
|
733
|
+
"/** seed keys — generated by gateway cli */",
|
|
734
|
+
'import { PrismaClient } from "@prisma/client"',
|
|
735
|
+
"",
|
|
736
|
+
`const keys = ${JSON.stringify(keys, null, 2)}`,
|
|
737
|
+
"",
|
|
738
|
+
"const db = new PrismaClient()",
|
|
739
|
+
"",
|
|
740
|
+
`for (const [idx, key] of keys.entries()) {`,
|
|
741
|
+
` await db.${dbmodel}.upsert({`,
|
|
742
|
+
" where: { keyValue: key },",
|
|
743
|
+
' update: { active: true, status: "active" },',
|
|
744
|
+
` create: { keyValue: key, label: \`key-\${idx}\`, active: true, status: "active" },`,
|
|
745
|
+
" })",
|
|
746
|
+
"}",
|
|
747
|
+
"",
|
|
748
|
+
"await db.$disconnect()",
|
|
749
|
+
`console.log(\`registered \${keys.length} keys\`)`,
|
|
750
|
+
"",
|
|
751
|
+
].join("\n");
|
|
752
|
+
mkdirSync(resolve(process.cwd(), "tests"), { recursive: true });
|
|
753
|
+
writeFileSync(seedpath, seedcontent);
|
|
754
|
+
console.log(`\n${colors.green}created${colors.reset} tests/seedkeys.mjs — run: node tests/seedkeys.mjs`);
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
console.log("");
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
// ---------------------------------------------------------------------------
|
|
761
|
+
// models — list models for a version
|
|
762
|
+
// ---------------------------------------------------------------------------
|
|
763
|
+
|
|
764
|
+
async function cmdmodels(versionarg: string | undefined): Promise<void> {
|
|
765
|
+
if (!versionarg) {
|
|
766
|
+
console.log("usage: gateway models <version>");
|
|
767
|
+
process.exit(1);
|
|
768
|
+
}
|
|
769
|
+
const config = await readconfig();
|
|
770
|
+
const versions = (config?.versions as Record<string, Record<string, unknown>>) ?? {};
|
|
771
|
+
const v = versions[versionarg];
|
|
772
|
+
if (!v) {
|
|
773
|
+
console.log(`${colors.red}version ${versionarg} not found${colors.reset}`);
|
|
774
|
+
process.exit(1);
|
|
775
|
+
}
|
|
776
|
+
const models = (v.models as Array<Record<string, unknown>>) ?? [];
|
|
777
|
+
console.log(`\n${colors.bold}models for ${versionarg}${colors.reset}\n`);
|
|
778
|
+
for (const m of models) {
|
|
779
|
+
console.log(
|
|
780
|
+
` ${colors.cyan}${m.id}${colors.reset} ctx=${m.context} out=${m.maxoutput}${m.vision ? " vision" : ""}${m.reasoning ? " reasoning" : ""}`,
|
|
781
|
+
);
|
|
782
|
+
}
|
|
783
|
+
const rotation = v.rotation as Record<string, unknown> | undefined;
|
|
784
|
+
if (rotation?.models) {
|
|
785
|
+
console.log(`\n${colors.bold}rotation pool${colors.reset}: ${JSON.stringify(rotation.models)}`);
|
|
786
|
+
}
|
|
787
|
+
console.log("");
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
// ---------------------------------------------------------------------------
|
|
791
|
+
// serve — start the server
|
|
792
|
+
// ---------------------------------------------------------------------------
|
|
793
|
+
|
|
794
|
+
async function cmdserve(portarg: string | undefined): Promise<void> {
|
|
795
|
+
const port = Number(portarg) || Number(process.env.PORT) || 3001;
|
|
796
|
+
process.env.PORT = String(port);
|
|
797
|
+
console.log(`${colors.cyan}starting gateway on port ${port}${colors.reset}`);
|
|
798
|
+
await main();
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
// ---------------------------------------------------------------------------
|
|
802
|
+
// export — export the web scaffold
|
|
803
|
+
// ---------------------------------------------------------------------------
|
|
804
|
+
|
|
805
|
+
async function cmdexport(dirarg: string | undefined): Promise<void> {
|
|
806
|
+
const target = resolve(process.cwd(), dirarg ?? "export");
|
|
807
|
+
mkdirSync(target, { recursive: true });
|
|
808
|
+
const found = await findconfig();
|
|
809
|
+
if (found) {
|
|
810
|
+
const name = basename(found.path);
|
|
811
|
+
writeFileSync(join(target, name), readFileSync(found.path, "utf8"));
|
|
812
|
+
console.log(`${colors.green}exported${colors.reset} ${name}`);
|
|
813
|
+
}
|
|
814
|
+
const schemaroot = resolve(process.cwd(), "schema.prisma");
|
|
815
|
+
const schemapath = existsSync(schemaroot) ? schemaroot : resolve(process.cwd(), "sol", "schema.prisma");
|
|
816
|
+
if (existsSync(schemapath)) {
|
|
817
|
+
writeFileSync(join(target, "schema.prisma"), readFileSync(schemapath, "utf8"));
|
|
818
|
+
console.log(`${colors.green}exported${colors.reset} schema.prisma`);
|
|
819
|
+
}
|
|
820
|
+
console.log(`\nexported to ${target} — deploy this folder to vercel or netlify`);
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
// ---------------------------------------------------------------------------
|
|
824
|
+
// help
|
|
825
|
+
// ---------------------------------------------------------------------------
|
|
826
|
+
|
|
827
|
+
function printhelp(): void {
|
|
828
|
+
console.log(
|
|
829
|
+
[
|
|
830
|
+
`${colors.cyan}${colors.bold}@wenathlan/devthink gateway${colors.reset} v${version()}`,
|
|
831
|
+
"",
|
|
832
|
+
"the universal ai gateway library — any llm any baseurl any api key",
|
|
833
|
+
"",
|
|
834
|
+
"Usage: devthink gateway <command> [options]",
|
|
835
|
+
"",
|
|
836
|
+
"Commands:",
|
|
837
|
+
" init scaffold config.mjs schema and env interactively",
|
|
838
|
+
" add add a new version to the config",
|
|
839
|
+
" list list configured versions",
|
|
840
|
+
" show <version> show one version config",
|
|
841
|
+
" validate validate the config for errors",
|
|
842
|
+
" keys <version> register api keys for a version",
|
|
843
|
+
" models <version> list models for a version",
|
|
844
|
+
" serve [--port <n>] start the gateway server",
|
|
845
|
+
" export [--dir <path>] export the web folder scaffold",
|
|
846
|
+
" help show this help",
|
|
847
|
+
"",
|
|
848
|
+
"Library usage:",
|
|
849
|
+
' import { createversion, loadconfig } from "@wenathlan/devthink"',
|
|
850
|
+
"",
|
|
851
|
+
"Documentation: https://github.com/wenathlan/devthink",
|
|
852
|
+
"",
|
|
853
|
+
].join("\n"),
|
|
854
|
+
);
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
// ---------------------------------------------------------------------------
|
|
858
|
+
// main — dispatch commands
|
|
859
|
+
// ---------------------------------------------------------------------------
|
|
860
|
+
|
|
861
|
+
/**
|
|
862
|
+
* Family entry of the grand merge: the single devthink binary routes the
|
|
863
|
+
* extension command family here (`devthink ext <command>`); the argv is
|
|
864
|
+
* spliced into the process position the original entry reading expects,
|
|
865
|
+
* the family main runs untouched and the caller argv is restored.
|
|
866
|
+
*
|
|
867
|
+
* @param argv the family arguments (without the family prefix).
|
|
868
|
+
*/
|
|
869
|
+
export async function runclifamily(argv: string[]): Promise<void> {
|
|
870
|
+
const saved = process.argv.slice();
|
|
871
|
+
process.argv = [saved[0], saved[1], ...argv];
|
|
872
|
+
try {
|
|
873
|
+
await consolemain();
|
|
874
|
+
} finally {
|
|
875
|
+
process.argv = saved;
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
async function consolemain(): Promise<void> {
|
|
880
|
+
const args = process.argv.slice(2);
|
|
881
|
+
const parsed = parseargs(args);
|
|
882
|
+
const command = parsed.positional[0];
|
|
883
|
+
|
|
884
|
+
try {
|
|
885
|
+
switch (command) {
|
|
886
|
+
case "init":
|
|
887
|
+
await cmdinit();
|
|
888
|
+
break;
|
|
889
|
+
case "add":
|
|
890
|
+
await cmdadd(parsed.positional[1]);
|
|
891
|
+
break;
|
|
892
|
+
case "list":
|
|
893
|
+
await cmdlist();
|
|
894
|
+
break;
|
|
895
|
+
case "show":
|
|
896
|
+
await cmdshow(parsed.positional[1]);
|
|
897
|
+
break;
|
|
898
|
+
case "validate":
|
|
899
|
+
await cmdvalidate();
|
|
900
|
+
break;
|
|
901
|
+
case "keys":
|
|
902
|
+
await cmdkeys(parsed.positional[1]);
|
|
903
|
+
break;
|
|
904
|
+
case "models":
|
|
905
|
+
await cmdmodels(parsed.positional[1]);
|
|
906
|
+
break;
|
|
907
|
+
case "serve":
|
|
908
|
+
await cmdserve(stringflag(parsed, "port"));
|
|
909
|
+
break;
|
|
910
|
+
case "export":
|
|
911
|
+
await cmdexport(stringflag(parsed, "dir"));
|
|
912
|
+
break;
|
|
913
|
+
case "help":
|
|
914
|
+
case "--help":
|
|
915
|
+
case "-h":
|
|
916
|
+
case undefined:
|
|
917
|
+
printhelp();
|
|
918
|
+
break;
|
|
919
|
+
default:
|
|
920
|
+
console.log(`${colors.red}unknown command${colors.reset}: ${command}`);
|
|
921
|
+
printhelp();
|
|
922
|
+
process.exit(1);
|
|
923
|
+
}
|
|
924
|
+
} catch (err) {
|
|
925
|
+
console.error(`${colors.red}error${colors.reset}: ${err instanceof Error ? err.message : String(err)}`);
|
|
926
|
+
process.exit(1);
|
|
927
|
+
} finally {
|
|
928
|
+
/** release the shared stdin handle so the process exits cleanly */
|
|
929
|
+
sharedrl?.close();
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
/* the grand-merge guard (the cli move of the gateway ownership wave,
|
|
934
|
+
wave/ownership.gateway step 4): when this module is imported as a library
|
|
935
|
+
surface by the family consumers (the devthink server compat shim, the
|
|
936
|
+
devthink router), the module-load main must not fire — only a direct
|
|
937
|
+
`bun cli.ts` (or a built cli.js entry) run executes the console family,
|
|
938
|
+
and its serve boots the getry transport main the cli moved with (the
|
|
939
|
+
devthink server entry keeps its own composed boot for its os lanes and
|
|
940
|
+
answers serve there itself). */
|
|
941
|
+
const cliDirectEntry = process.argv[1]?.endsWith("cli.ts") || process.argv[1]?.endsWith("cli.js");
|
|
942
|
+
const cliBunEntry = (import.meta as ImportMeta & { main?: boolean }).main === true;
|
|
943
|
+
if (cliDirectEntry || cliBunEntry) void consolemain();
|