@tsproxy/cli 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +676 -0
- package/package.json +42 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,676 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { Command } from "commander";
|
|
5
|
+
|
|
6
|
+
// src/commands/init.ts
|
|
7
|
+
import * as p from "@clack/prompts";
|
|
8
|
+
import pc from "picocolors";
|
|
9
|
+
import { writeFileSync, existsSync } from "fs";
|
|
10
|
+
import { execSync } from "child_process";
|
|
11
|
+
import { resolve } from "path";
|
|
12
|
+
async function init() {
|
|
13
|
+
p.intro(pc.bgCyan(pc.black(" tsproxy init ")));
|
|
14
|
+
const scope = await p.select({
|
|
15
|
+
message: "What do you want to set up?",
|
|
16
|
+
options: [
|
|
17
|
+
{ value: "both", label: "Backend + Frontend", hint: "proxy server and search UI" },
|
|
18
|
+
{ value: "backend", label: "Backend only", hint: "proxy server" },
|
|
19
|
+
{ value: "frontend", label: "Frontend only", hint: "search client and components" }
|
|
20
|
+
]
|
|
21
|
+
});
|
|
22
|
+
if (p.isCancel(scope)) return process.exit(0);
|
|
23
|
+
const needsBackend = scope === "both" || scope === "backend";
|
|
24
|
+
const needsFrontend = scope === "both" || scope === "frontend";
|
|
25
|
+
let typesenseMode = "docker";
|
|
26
|
+
let tsHost = "localhost";
|
|
27
|
+
let tsPort = "8108";
|
|
28
|
+
let tsApiKey = "test-api-key";
|
|
29
|
+
let tsProtocol = "http";
|
|
30
|
+
let wantsRedis = false;
|
|
31
|
+
let redisHost = "localhost";
|
|
32
|
+
let redisPort = "6379";
|
|
33
|
+
if (needsBackend) {
|
|
34
|
+
typesenseMode = await p.select({
|
|
35
|
+
message: "How will you run Typesense?",
|
|
36
|
+
options: [
|
|
37
|
+
{ value: "docker", label: "Docker (local)", hint: "generates docker-compose.yml" },
|
|
38
|
+
{ value: "cloud", label: "Typesense Cloud" },
|
|
39
|
+
{ value: "self-hosted", label: "Self-hosted" }
|
|
40
|
+
]
|
|
41
|
+
});
|
|
42
|
+
if (p.isCancel(typesenseMode)) return process.exit(0);
|
|
43
|
+
if (typesenseMode === "cloud" || typesenseMode === "self-hosted") {
|
|
44
|
+
const hostInput = await p.text({
|
|
45
|
+
message: "Typesense host",
|
|
46
|
+
placeholder: typesenseMode === "cloud" ? "xyz.a1.typesense.net" : "localhost",
|
|
47
|
+
validate: (v) => v.length === 0 ? "Host is required" : void 0
|
|
48
|
+
});
|
|
49
|
+
if (p.isCancel(hostInput)) return process.exit(0);
|
|
50
|
+
tsHost = hostInput;
|
|
51
|
+
const portInput = await p.text({
|
|
52
|
+
message: "Typesense port",
|
|
53
|
+
initialValue: typesenseMode === "cloud" ? "443" : "8108"
|
|
54
|
+
});
|
|
55
|
+
if (p.isCancel(portInput)) return process.exit(0);
|
|
56
|
+
tsPort = portInput;
|
|
57
|
+
if (typesenseMode === "cloud") {
|
|
58
|
+
tsProtocol = "https";
|
|
59
|
+
}
|
|
60
|
+
const keyInput = await p.text({
|
|
61
|
+
message: "Typesense API key",
|
|
62
|
+
validate: (v) => v.length === 0 ? "API key is required" : void 0
|
|
63
|
+
});
|
|
64
|
+
if (p.isCancel(keyInput)) return process.exit(0);
|
|
65
|
+
tsApiKey = keyInput;
|
|
66
|
+
}
|
|
67
|
+
const redisChoice = await p.confirm({
|
|
68
|
+
message: "Use Redis for persistent queue?",
|
|
69
|
+
initialValue: typesenseMode === "docker"
|
|
70
|
+
});
|
|
71
|
+
if (p.isCancel(redisChoice)) return process.exit(0);
|
|
72
|
+
wantsRedis = redisChoice;
|
|
73
|
+
if (wantsRedis && typesenseMode !== "docker") {
|
|
74
|
+
const rHost = await p.text({
|
|
75
|
+
message: "Redis host",
|
|
76
|
+
initialValue: "localhost"
|
|
77
|
+
});
|
|
78
|
+
if (p.isCancel(rHost)) return process.exit(0);
|
|
79
|
+
redisHost = rHost;
|
|
80
|
+
const rPort = await p.text({
|
|
81
|
+
message: "Redis port",
|
|
82
|
+
initialValue: "6379"
|
|
83
|
+
});
|
|
84
|
+
if (p.isCancel(rPort)) return process.exit(0);
|
|
85
|
+
redisPort = rPort;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
let frontendType = "react";
|
|
89
|
+
if (needsFrontend) {
|
|
90
|
+
frontendType = await p.select({
|
|
91
|
+
message: "Frontend framework?",
|
|
92
|
+
options: [
|
|
93
|
+
{ value: "react", label: "React", hint: "headless components + InstantSearch" },
|
|
94
|
+
{ value: "vanilla", label: "Vanilla JS", hint: "search client only" }
|
|
95
|
+
]
|
|
96
|
+
});
|
|
97
|
+
if (p.isCancel(frontendType)) return process.exit(0);
|
|
98
|
+
}
|
|
99
|
+
const s = p.spinner();
|
|
100
|
+
s.start("Generating files");
|
|
101
|
+
const cwd = process.cwd();
|
|
102
|
+
if (needsBackend) {
|
|
103
|
+
const configContent = generateConfig({
|
|
104
|
+
tsHost,
|
|
105
|
+
tsPort,
|
|
106
|
+
tsProtocol,
|
|
107
|
+
tsApiKey,
|
|
108
|
+
wantsRedis,
|
|
109
|
+
redisHost,
|
|
110
|
+
redisPort,
|
|
111
|
+
isDocker: typesenseMode === "docker"
|
|
112
|
+
});
|
|
113
|
+
writeFileSync(resolve(cwd, "tsproxy.config.ts"), configContent);
|
|
114
|
+
}
|
|
115
|
+
if (typesenseMode === "docker") {
|
|
116
|
+
const dockerContent = generateDockerCompose(wantsRedis);
|
|
117
|
+
if (!existsSync(resolve(cwd, "docker-compose.yml"))) {
|
|
118
|
+
writeFileSync(resolve(cwd, "docker-compose.yml"), dockerContent);
|
|
119
|
+
} else {
|
|
120
|
+
p.log.warn("docker-compose.yml already exists, skipping");
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
const envContent = generateEnv({
|
|
124
|
+
tsHost,
|
|
125
|
+
tsPort,
|
|
126
|
+
tsProtocol,
|
|
127
|
+
tsApiKey,
|
|
128
|
+
isDocker: typesenseMode === "docker",
|
|
129
|
+
wantsRedis,
|
|
130
|
+
redisHost,
|
|
131
|
+
redisPort
|
|
132
|
+
});
|
|
133
|
+
if (!existsSync(resolve(cwd, ".env"))) {
|
|
134
|
+
writeFileSync(resolve(cwd, ".env"), envContent);
|
|
135
|
+
}
|
|
136
|
+
s.stop("Files generated");
|
|
137
|
+
const deps = [];
|
|
138
|
+
if (needsBackend) deps.push("@tsproxy/api");
|
|
139
|
+
if (needsFrontend) {
|
|
140
|
+
deps.push("@tsproxy/js");
|
|
141
|
+
if (frontendType === "react") {
|
|
142
|
+
deps.push("@tsproxy/react", "react-instantsearch");
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
if (deps.length > 0) {
|
|
146
|
+
const pm = detectPackageManager();
|
|
147
|
+
s.start(`Installing ${deps.join(", ")}`);
|
|
148
|
+
try {
|
|
149
|
+
const installCmd = pm === "pnpm" ? `pnpm add ${deps.join(" ")}` : pm === "yarn" ? `yarn add ${deps.join(" ")}` : pm === "bun" ? `bun add ${deps.join(" ")}` : `npm install ${deps.join(" ")}`;
|
|
150
|
+
execSync(installCmd, { stdio: "pipe", cwd });
|
|
151
|
+
s.stop("Dependencies installed");
|
|
152
|
+
} catch {
|
|
153
|
+
s.stop("Failed to install \u2014 run manually:");
|
|
154
|
+
p.log.info(` ${pm} add ${deps.join(" ")}`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
p.note(
|
|
158
|
+
[
|
|
159
|
+
typesenseMode === "docker" && "docker compose up -d",
|
|
160
|
+
needsBackend && "npx tsproxy dev",
|
|
161
|
+
needsFrontend && "# Import in your app:",
|
|
162
|
+
needsFrontend && frontendType === "react" ? ' import { SearchBox, Hits } from "@tsproxy/react"' : needsFrontend ? ' import { createSearchClient } from "@tsproxy/js"' : null
|
|
163
|
+
].filter(Boolean).join("\n"),
|
|
164
|
+
"Next steps"
|
|
165
|
+
);
|
|
166
|
+
p.outro(pc.green("Ready!"));
|
|
167
|
+
}
|
|
168
|
+
function generateConfig(opts) {
|
|
169
|
+
const redis = opts.wantsRedis ? `
|
|
170
|
+
redis: { host: "${opts.isDocker ? "localhost" : opts.redisHost}", port: ${opts.isDocker ? 6379 : opts.redisPort} },` : "";
|
|
171
|
+
return `import { defineConfig } from "@tsproxy/api";
|
|
172
|
+
|
|
173
|
+
export default defineConfig({
|
|
174
|
+
typesense: {
|
|
175
|
+
host: "${opts.isDocker ? "localhost" : opts.tsHost}",
|
|
176
|
+
port: ${opts.isDocker ? 8108 : opts.tsPort},
|
|
177
|
+
protocol: "${opts.tsProtocol}",
|
|
178
|
+
apiKey: process.env.TYPESENSE_API_KEY || "${opts.tsApiKey}",
|
|
179
|
+
},
|
|
180
|
+
|
|
181
|
+
server: {
|
|
182
|
+
port: 3000,
|
|
183
|
+
apiKey: process.env.PROXY_API_KEY || "change-me",
|
|
184
|
+
},
|
|
185
|
+
|
|
186
|
+
cache: {
|
|
187
|
+
ttl: 60,
|
|
188
|
+
maxSize: 1000,
|
|
189
|
+
},
|
|
190
|
+
|
|
191
|
+
queue: {
|
|
192
|
+
concurrency: 5,
|
|
193
|
+
maxSize: 10000,${redis}
|
|
194
|
+
},
|
|
195
|
+
|
|
196
|
+
rateLimit: {
|
|
197
|
+
search: 100,
|
|
198
|
+
ingest: 30,
|
|
199
|
+
},
|
|
200
|
+
|
|
201
|
+
collections: {
|
|
202
|
+
// Define your collections here:
|
|
203
|
+
// products: {
|
|
204
|
+
// fields: {
|
|
205
|
+
// name: { type: "string", searchable: true },
|
|
206
|
+
// price: { type: "float", sortable: true },
|
|
207
|
+
// category: { type: "string", facet: true },
|
|
208
|
+
// },
|
|
209
|
+
// },
|
|
210
|
+
},
|
|
211
|
+
});
|
|
212
|
+
`;
|
|
213
|
+
}
|
|
214
|
+
function generateDockerCompose(wantsRedis) {
|
|
215
|
+
let content = `services:
|
|
216
|
+
typesense:
|
|
217
|
+
image: typesense/typesense:30.0
|
|
218
|
+
restart: unless-stopped
|
|
219
|
+
ports:
|
|
220
|
+
- "8108:8108"
|
|
221
|
+
volumes:
|
|
222
|
+
- typesense-data:/data
|
|
223
|
+
command: >
|
|
224
|
+
--data-dir /data
|
|
225
|
+
--api-key=\${TYPESENSE_API_KEY:-test-api-key}
|
|
226
|
+
--enable-cors
|
|
227
|
+
healthcheck:
|
|
228
|
+
test: ["CMD", "curl", "-sf", "http://localhost:8108/health"]
|
|
229
|
+
interval: 10s
|
|
230
|
+
timeout: 5s
|
|
231
|
+
retries: 3
|
|
232
|
+
`;
|
|
233
|
+
if (wantsRedis) {
|
|
234
|
+
content += `
|
|
235
|
+
redis:
|
|
236
|
+
image: redis:7-alpine
|
|
237
|
+
restart: unless-stopped
|
|
238
|
+
ports:
|
|
239
|
+
- "6379:6379"
|
|
240
|
+
volumes:
|
|
241
|
+
- redis-data:/data
|
|
242
|
+
command: redis-server --appendonly yes
|
|
243
|
+
healthcheck:
|
|
244
|
+
test: ["CMD", "redis-cli", "ping"]
|
|
245
|
+
interval: 10s
|
|
246
|
+
timeout: 5s
|
|
247
|
+
retries: 3
|
|
248
|
+
`;
|
|
249
|
+
}
|
|
250
|
+
content += `
|
|
251
|
+
volumes:
|
|
252
|
+
typesense-data:`;
|
|
253
|
+
if (wantsRedis) {
|
|
254
|
+
content += `
|
|
255
|
+
redis-data:`;
|
|
256
|
+
}
|
|
257
|
+
return content + "\n";
|
|
258
|
+
}
|
|
259
|
+
function generateEnv(opts) {
|
|
260
|
+
let content = `# Typesense
|
|
261
|
+
TYPESENSE_HOST=${opts.isDocker ? "localhost" : opts.tsHost}
|
|
262
|
+
TYPESENSE_PORT=${opts.isDocker ? "8108" : opts.tsPort}
|
|
263
|
+
TYPESENSE_PROTOCOL=${opts.tsProtocol}
|
|
264
|
+
TYPESENSE_API_KEY=${opts.tsApiKey}
|
|
265
|
+
|
|
266
|
+
# Proxy
|
|
267
|
+
PROXY_PORT=3000
|
|
268
|
+
PROXY_API_KEY=change-me
|
|
269
|
+
`;
|
|
270
|
+
if (opts.wantsRedis) {
|
|
271
|
+
content += `
|
|
272
|
+
# Redis
|
|
273
|
+
REDIS_HOST=${opts.isDocker ? "localhost" : opts.redisHost}
|
|
274
|
+
REDIS_PORT=${opts.isDocker ? "6379" : opts.redisPort}
|
|
275
|
+
`;
|
|
276
|
+
}
|
|
277
|
+
return content;
|
|
278
|
+
}
|
|
279
|
+
function detectPackageManager() {
|
|
280
|
+
const cwd = process.cwd();
|
|
281
|
+
if (existsSync(resolve(cwd, "pnpm-lock.yaml"))) return "pnpm";
|
|
282
|
+
if (existsSync(resolve(cwd, "yarn.lock"))) return "yarn";
|
|
283
|
+
if (existsSync(resolve(cwd, "bun.lockb"))) return "bun";
|
|
284
|
+
return "npm";
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// src/commands/dev.ts
|
|
288
|
+
import { execSync as execSync2 } from "child_process";
|
|
289
|
+
import { resolve as resolve2 } from "path";
|
|
290
|
+
import { existsSync as existsSync2 } from "fs";
|
|
291
|
+
async function dev(opts) {
|
|
292
|
+
const args = ["dev"];
|
|
293
|
+
if (opts.port) args.push("--port", opts.port);
|
|
294
|
+
if (opts.config) args.push("--config", opts.config);
|
|
295
|
+
const hasTsx = existsSync2(resolve2(process.cwd(), "node_modules/.bin/tsx"));
|
|
296
|
+
const cliPath = findCliEntry();
|
|
297
|
+
if (!cliPath) {
|
|
298
|
+
console.error("@tsproxy/api not found. Run: npm install @tsproxy/api");
|
|
299
|
+
process.exit(1);
|
|
300
|
+
}
|
|
301
|
+
const cmd = hasTsx ? `npx tsx watch ${cliPath} ${args.join(" ")}` : `node ${cliPath} ${args.join(" ")}`;
|
|
302
|
+
try {
|
|
303
|
+
execSync2(cmd, { stdio: "inherit", cwd: process.cwd() });
|
|
304
|
+
} catch {
|
|
305
|
+
process.exit(1);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
function findCliEntry() {
|
|
309
|
+
const paths = [
|
|
310
|
+
resolve2(process.cwd(), "node_modules/@tsproxy/api/dist/cli.js"),
|
|
311
|
+
resolve2(process.cwd(), "node_modules/@tsproxy/api/src/cli.ts")
|
|
312
|
+
];
|
|
313
|
+
for (const p2 of paths) {
|
|
314
|
+
if (existsSync2(p2)) return p2;
|
|
315
|
+
}
|
|
316
|
+
return null;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// src/commands/start.ts
|
|
320
|
+
import { execSync as execSync3 } from "child_process";
|
|
321
|
+
import { resolve as resolve3 } from "path";
|
|
322
|
+
import { existsSync as existsSync3 } from "fs";
|
|
323
|
+
async function start(opts) {
|
|
324
|
+
const cliPath = resolve3(process.cwd(), "node_modules/@tsproxy/api/dist/cli.js");
|
|
325
|
+
if (!existsSync3(cliPath)) {
|
|
326
|
+
console.error("@tsproxy/api not found or not built. Run: npm install @tsproxy/api");
|
|
327
|
+
process.exit(1);
|
|
328
|
+
}
|
|
329
|
+
const args = ["start"];
|
|
330
|
+
if (opts.port) args.push("--port", opts.port);
|
|
331
|
+
if (opts.config) args.push("--config", opts.config);
|
|
332
|
+
try {
|
|
333
|
+
execSync3(`node ${cliPath} ${args.join(" ")}`, {
|
|
334
|
+
stdio: "inherit",
|
|
335
|
+
cwd: process.cwd()
|
|
336
|
+
});
|
|
337
|
+
} catch {
|
|
338
|
+
process.exit(1);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// src/commands/build.ts
|
|
343
|
+
import { execSync as execSync4 } from "child_process";
|
|
344
|
+
async function build() {
|
|
345
|
+
console.log("Building @tsproxy/api for production...");
|
|
346
|
+
try {
|
|
347
|
+
execSync4("npx tsup node_modules/@tsproxy/api/src/index.ts node_modules/@tsproxy/api/src/server.ts node_modules/@tsproxy/api/src/cli.ts --format esm --outDir dist", {
|
|
348
|
+
stdio: "inherit",
|
|
349
|
+
cwd: process.cwd()
|
|
350
|
+
});
|
|
351
|
+
console.log("Build complete. Start with: tsproxy start");
|
|
352
|
+
} catch {
|
|
353
|
+
console.error("Build failed.");
|
|
354
|
+
process.exit(1);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// src/commands/seed.ts
|
|
359
|
+
import { readFileSync } from "fs";
|
|
360
|
+
import { resolve as resolve4 } from "path";
|
|
361
|
+
import pc2 from "picocolors";
|
|
362
|
+
async function seed(file, opts) {
|
|
363
|
+
const proxyUrl = process.env.PROXY_URL || "http://localhost:3000";
|
|
364
|
+
const apiKey = process.env.PROXY_API_KEY || "change-me";
|
|
365
|
+
const collection = opts.collection || "products";
|
|
366
|
+
if (!file) {
|
|
367
|
+
console.error("Usage: tsproxy seed <file.json> --collection <name>");
|
|
368
|
+
console.error(" File should be a JSON array of documents.");
|
|
369
|
+
process.exit(1);
|
|
370
|
+
}
|
|
371
|
+
const filePath = resolve4(process.cwd(), file);
|
|
372
|
+
let documents;
|
|
373
|
+
try {
|
|
374
|
+
const content = readFileSync(filePath, "utf-8");
|
|
375
|
+
if (content.trim().startsWith("[")) {
|
|
376
|
+
documents = JSON.parse(content);
|
|
377
|
+
} else {
|
|
378
|
+
documents = content.trim().split("\n").map((line) => JSON.parse(line));
|
|
379
|
+
}
|
|
380
|
+
} catch (err) {
|
|
381
|
+
console.error(`Failed to read ${filePath}:`, err.message);
|
|
382
|
+
process.exit(1);
|
|
383
|
+
}
|
|
384
|
+
console.log(
|
|
385
|
+
`Seeding ${pc2.bold(String(documents.length))} documents into ${pc2.bold(collection)} via ingest API...`
|
|
386
|
+
);
|
|
387
|
+
const url = `${proxyUrl}/api/ingest/${collection}/documents/import`;
|
|
388
|
+
const headers = {
|
|
389
|
+
"Content-Type": "application/json",
|
|
390
|
+
"X-API-Key": apiKey
|
|
391
|
+
};
|
|
392
|
+
if (opts.locale) {
|
|
393
|
+
headers["X-Locale"] = opts.locale;
|
|
394
|
+
}
|
|
395
|
+
try {
|
|
396
|
+
const res = await fetch(url, {
|
|
397
|
+
method: "POST",
|
|
398
|
+
headers,
|
|
399
|
+
body: JSON.stringify(documents)
|
|
400
|
+
});
|
|
401
|
+
if (!res.ok) {
|
|
402
|
+
const body = await res.json().catch(() => ({}));
|
|
403
|
+
console.error(
|
|
404
|
+
pc2.red(`Failed: ${res.status} ${body.error || res.statusText}`)
|
|
405
|
+
);
|
|
406
|
+
process.exit(1);
|
|
407
|
+
}
|
|
408
|
+
const result = await res.json();
|
|
409
|
+
const results = Array.isArray(result) ? result : [result];
|
|
410
|
+
const successes = results.filter((r) => r.success !== false).length;
|
|
411
|
+
const failures = results.length - successes;
|
|
412
|
+
console.log(
|
|
413
|
+
pc2.green(`Imported ${successes} documents`) + (failures > 0 ? pc2.red(` (${failures} failures)`) : "")
|
|
414
|
+
);
|
|
415
|
+
if (opts.locales) {
|
|
416
|
+
console.log("\nTo seed locale-specific collections, run:");
|
|
417
|
+
console.log(
|
|
418
|
+
` tsproxy seed ${file} --collection ${collection} --locale en`
|
|
419
|
+
);
|
|
420
|
+
console.log(
|
|
421
|
+
` tsproxy seed ${file} --collection ${collection} --locale fr`
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
} catch (err) {
|
|
425
|
+
console.error(pc2.red("Connection failed:"), err.message);
|
|
426
|
+
console.error("Is the proxy running? Start with: tsproxy dev");
|
|
427
|
+
process.exit(1);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// src/commands/migrate.ts
|
|
432
|
+
import { resolve as resolve5 } from "path";
|
|
433
|
+
import { existsSync as existsSync4 } from "fs";
|
|
434
|
+
import { pathToFileURL } from "url";
|
|
435
|
+
import pc3 from "picocolors";
|
|
436
|
+
async function migrate(opts) {
|
|
437
|
+
const configPath = findConfig();
|
|
438
|
+
if (!configPath) {
|
|
439
|
+
console.error("No tsproxy.config.ts found. Run: tsproxy init");
|
|
440
|
+
process.exit(1);
|
|
441
|
+
}
|
|
442
|
+
let config;
|
|
443
|
+
try {
|
|
444
|
+
const mod = await import(pathToFileURL(configPath).href);
|
|
445
|
+
config = mod.default || mod;
|
|
446
|
+
} catch (err) {
|
|
447
|
+
console.error("Failed to load config:", err.message);
|
|
448
|
+
process.exit(1);
|
|
449
|
+
}
|
|
450
|
+
const collections = config.collections || {};
|
|
451
|
+
const collectionNames = Object.keys(collections);
|
|
452
|
+
if (collectionNames.length === 0) {
|
|
453
|
+
console.log("No collections defined in config.");
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
const tsHost = config.typesense?.host || process.env.TYPESENSE_HOST || "localhost";
|
|
457
|
+
const tsPort = config.typesense?.port || process.env.TYPESENSE_PORT || 8108;
|
|
458
|
+
const tsProtocol = config.typesense?.protocol || "http";
|
|
459
|
+
const tsApiKey = config.typesense?.apiKey || process.env.TYPESENSE_API_KEY;
|
|
460
|
+
if (!tsApiKey) {
|
|
461
|
+
console.error("TYPESENSE_API_KEY not set.");
|
|
462
|
+
process.exit(1);
|
|
463
|
+
}
|
|
464
|
+
const baseUrl = `${tsProtocol}://${tsHost}:${tsPort}`;
|
|
465
|
+
let existing = {};
|
|
466
|
+
try {
|
|
467
|
+
const res = await fetch(`${baseUrl}/collections`, {
|
|
468
|
+
headers: { "X-TYPESENSE-API-KEY": tsApiKey }
|
|
469
|
+
});
|
|
470
|
+
const cols = await res.json();
|
|
471
|
+
for (const col of cols) {
|
|
472
|
+
existing[col.name] = col;
|
|
473
|
+
}
|
|
474
|
+
} catch (err) {
|
|
475
|
+
console.error("Cannot connect to Typesense:", err.message);
|
|
476
|
+
process.exit(1);
|
|
477
|
+
}
|
|
478
|
+
console.log(pc3.bold("\nMigration plan:\n"));
|
|
479
|
+
const actions = [];
|
|
480
|
+
for (const [name, def] of Object.entries(collections)) {
|
|
481
|
+
const names = [name];
|
|
482
|
+
if (def.locales?.length) {
|
|
483
|
+
for (const locale of def.locales) {
|
|
484
|
+
names.push(`${name}_${locale}`);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
for (const colName of names) {
|
|
488
|
+
if (existing[colName]) {
|
|
489
|
+
if (opts.drop) {
|
|
490
|
+
actions.push({
|
|
491
|
+
type: "drop+create",
|
|
492
|
+
name: colName,
|
|
493
|
+
details: `Drop and recreate with ${Object.keys(def.fields || {}).length} fields`
|
|
494
|
+
});
|
|
495
|
+
} else {
|
|
496
|
+
const existingFields = new Set(
|
|
497
|
+
(existing[colName].fields || []).map((f) => f.name)
|
|
498
|
+
);
|
|
499
|
+
const configFields = Object.keys(def.fields || {}).filter(
|
|
500
|
+
(f) => !def.fields[f].compute
|
|
501
|
+
);
|
|
502
|
+
const newFields = configFields.filter((f) => !existingFields.has(f));
|
|
503
|
+
if (newFields.length > 0) {
|
|
504
|
+
actions.push({
|
|
505
|
+
type: "update",
|
|
506
|
+
name: colName,
|
|
507
|
+
details: `Add fields: ${newFields.join(", ")}`
|
|
508
|
+
});
|
|
509
|
+
} else {
|
|
510
|
+
actions.push({
|
|
511
|
+
type: "ok",
|
|
512
|
+
name: colName,
|
|
513
|
+
details: "Up to date"
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
} else {
|
|
518
|
+
actions.push({
|
|
519
|
+
type: "create",
|
|
520
|
+
name: colName,
|
|
521
|
+
details: `Create with ${Object.keys(def.fields || {}).length} fields`
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
for (const action of actions) {
|
|
527
|
+
const icon = action.type === "ok" ? pc3.green("\u2713") : action.type === "create" ? pc3.cyan("+") : action.type === "update" ? pc3.yellow("~") : pc3.red("!");
|
|
528
|
+
console.log(` ${icon} ${pc3.bold(action.name)} \u2014 ${action.details}`);
|
|
529
|
+
}
|
|
530
|
+
const pendingActions = actions.filter((a) => a.type !== "ok");
|
|
531
|
+
if (pendingActions.length === 0) {
|
|
532
|
+
console.log(pc3.green("\nAll collections are up to date."));
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
if (!opts.apply) {
|
|
536
|
+
console.log(
|
|
537
|
+
pc3.yellow(`
|
|
538
|
+
Dry run \u2014 ${pendingActions.length} change(s). Run with --apply to execute.`)
|
|
539
|
+
);
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
console.log(pc3.bold("\nApplying...\n"));
|
|
543
|
+
for (const action of pendingActions) {
|
|
544
|
+
const colName = action.name;
|
|
545
|
+
const baseName = colName.includes("_") ? colName.split("_").slice(0, -1).join("_") : colName;
|
|
546
|
+
const def = collections[baseName] || collections[colName];
|
|
547
|
+
if (!def) continue;
|
|
548
|
+
const schema = buildSchema(colName, def);
|
|
549
|
+
try {
|
|
550
|
+
if (action.type === "drop+create") {
|
|
551
|
+
await fetch(`${baseUrl}/collections/${colName}`, {
|
|
552
|
+
method: "DELETE",
|
|
553
|
+
headers: { "X-TYPESENSE-API-KEY": tsApiKey }
|
|
554
|
+
});
|
|
555
|
+
await fetch(`${baseUrl}/collections`, {
|
|
556
|
+
method: "POST",
|
|
557
|
+
headers: {
|
|
558
|
+
"Content-Type": "application/json",
|
|
559
|
+
"X-TYPESENSE-API-KEY": tsApiKey
|
|
560
|
+
},
|
|
561
|
+
body: JSON.stringify(schema)
|
|
562
|
+
});
|
|
563
|
+
console.log(` ${pc3.green("\u2713")} ${colName} \u2014 dropped and recreated`);
|
|
564
|
+
} else if (action.type === "create") {
|
|
565
|
+
await fetch(`${baseUrl}/collections`, {
|
|
566
|
+
method: "POST",
|
|
567
|
+
headers: {
|
|
568
|
+
"Content-Type": "application/json",
|
|
569
|
+
"X-TYPESENSE-API-KEY": tsApiKey
|
|
570
|
+
},
|
|
571
|
+
body: JSON.stringify(schema)
|
|
572
|
+
});
|
|
573
|
+
console.log(` ${pc3.green("\u2713")} ${colName} \u2014 created`);
|
|
574
|
+
} else if (action.type === "update") {
|
|
575
|
+
const existingFields = new Set(
|
|
576
|
+
(existing[colName].fields || []).map((f) => f.name)
|
|
577
|
+
);
|
|
578
|
+
const newFields = Object.entries(def.fields || {}).filter(([name, f]) => !existingFields.has(name) && !f.compute).map(([name, f]) => ({
|
|
579
|
+
name,
|
|
580
|
+
type: f.type,
|
|
581
|
+
...f.facet ? { facet: true } : {},
|
|
582
|
+
...f.optional ? { optional: true } : {}
|
|
583
|
+
}));
|
|
584
|
+
await fetch(`${baseUrl}/collections/${colName}`, {
|
|
585
|
+
method: "PATCH",
|
|
586
|
+
headers: {
|
|
587
|
+
"Content-Type": "application/json",
|
|
588
|
+
"X-TYPESENSE-API-KEY": tsApiKey
|
|
589
|
+
},
|
|
590
|
+
body: JSON.stringify({ fields: newFields })
|
|
591
|
+
});
|
|
592
|
+
console.log(` ${pc3.green("\u2713")} ${colName} \u2014 updated`);
|
|
593
|
+
}
|
|
594
|
+
} catch (err) {
|
|
595
|
+
console.error(
|
|
596
|
+
` ${pc3.red("\u2717")} ${colName} \u2014 ${err.message}`
|
|
597
|
+
);
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
console.log(pc3.green("\nMigration complete."));
|
|
601
|
+
}
|
|
602
|
+
function buildSchema(name, def) {
|
|
603
|
+
const fields = Object.entries(def.fields || {}).filter(([, f]) => !f.compute).map(([fieldName, f]) => ({
|
|
604
|
+
name: fieldName,
|
|
605
|
+
type: f.type,
|
|
606
|
+
...f.facet ? { facet: true } : {},
|
|
607
|
+
...f.optional ? { optional: true } : {},
|
|
608
|
+
...f.sortable && !["int32", "int64", "float", "bool"].includes(f.type) ? { sort: true } : {},
|
|
609
|
+
...f.infix ? { infix: true } : {}
|
|
610
|
+
}));
|
|
611
|
+
return {
|
|
612
|
+
name,
|
|
613
|
+
fields,
|
|
614
|
+
...def.defaultSortBy ? { default_sorting_field: def.defaultSortBy } : {}
|
|
615
|
+
};
|
|
616
|
+
}
|
|
617
|
+
function findConfig() {
|
|
618
|
+
const names = ["tsproxy.config.ts", "tsproxy.config.js", "tsproxy.config.mjs"];
|
|
619
|
+
let dir = process.cwd();
|
|
620
|
+
const root = resolve5("/");
|
|
621
|
+
while (dir !== root) {
|
|
622
|
+
for (const name of names) {
|
|
623
|
+
const p2 = resolve5(dir, name);
|
|
624
|
+
if (existsSync4(p2)) return p2;
|
|
625
|
+
}
|
|
626
|
+
dir = resolve5(dir, "..");
|
|
627
|
+
}
|
|
628
|
+
return null;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// src/commands/health.ts
|
|
632
|
+
import pc4 from "picocolors";
|
|
633
|
+
async function health() {
|
|
634
|
+
const proxyUrl = process.env.PROXY_URL || "http://localhost:3000";
|
|
635
|
+
console.log(pc4.bold("\nHealth check\n"));
|
|
636
|
+
try {
|
|
637
|
+
const res = await fetch(`${proxyUrl}/api/health`);
|
|
638
|
+
const data = await res.json();
|
|
639
|
+
const proxyOk = data.proxy?.status === "ok";
|
|
640
|
+
const tsOk = data.typesense?.status === "ok";
|
|
641
|
+
const redisStatus = data.redis?.status;
|
|
642
|
+
const redisOk = redisStatus === "ok" || redisStatus === "not_configured";
|
|
643
|
+
console.log(
|
|
644
|
+
` ${proxyOk ? pc4.green("\u2713") : pc4.red("\u2717")} Proxy ${proxyOk ? "ok" : "error"}`
|
|
645
|
+
);
|
|
646
|
+
console.log(
|
|
647
|
+
` ${tsOk ? pc4.green("\u2713") : pc4.red("\u2717")} Typesense ${tsOk ? "ok" : data.typesense?.error || "error"} ${pc4.dim(data.typesense?.host || "")}`
|
|
648
|
+
);
|
|
649
|
+
console.log(
|
|
650
|
+
` ${redisStatus === "ok" ? pc4.green("\u2713") : redisStatus === "not_configured" ? pc4.dim("-") : pc4.red("\u2717")} Redis ${redisStatus} ${pc4.dim(data.redis?.host || "")}`
|
|
651
|
+
);
|
|
652
|
+
console.log(
|
|
653
|
+
`
|
|
654
|
+
Status: ${data.status === "healthy" ? pc4.green("healthy") : pc4.red("degraded")}`
|
|
655
|
+
);
|
|
656
|
+
} catch (err) {
|
|
657
|
+
console.error(
|
|
658
|
+
` ${pc4.red("\u2717")} Cannot connect to proxy at ${proxyUrl}`
|
|
659
|
+
);
|
|
660
|
+
console.error(` ${err.message}`);
|
|
661
|
+
console.error("\n Is the proxy running? Start with: tsproxy dev");
|
|
662
|
+
process.exit(1);
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// src/index.ts
|
|
667
|
+
var program = new Command();
|
|
668
|
+
program.name("tsproxy").description("Typesense search proxy framework").version("0.1.0");
|
|
669
|
+
program.command("init").description("Initialize a new tsproxy project").action(init);
|
|
670
|
+
program.command("dev").description("Start the proxy in development mode").option("-p, --port <port>", "Port to listen on").option("-c, --config <path>", "Path to config file").action(dev);
|
|
671
|
+
program.command("start").description("Start the proxy in production mode").option("-p, --port <port>", "Port to listen on").option("-c, --config <path>", "Path to config file").action(start);
|
|
672
|
+
program.command("build").description("Build the proxy for production").action(build);
|
|
673
|
+
program.command("seed [file]").description("Seed Typesense with data from a JSON/JSONL file").option("--collection <name>", "Collection name").option("--locale <locale>", "Locale for the collection").option("--locales", "Create locale-specific collections").action(seed);
|
|
674
|
+
program.command("migrate").description("Sync Typesense schema with config").option("--apply", "Apply changes (default is dry-run)").option("--drop", "Drop and recreate collections").action(migrate);
|
|
675
|
+
program.command("health").description("Check Typesense and Redis connectivity").action(health);
|
|
676
|
+
program.parse();
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@tsproxy/cli",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "CLI for tsproxy — Typesense search proxy framework",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"tsproxy": "./dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist"
|
|
11
|
+
],
|
|
12
|
+
"publishConfig": {
|
|
13
|
+
"access": "public"
|
|
14
|
+
},
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "https://github.com/akshitkrnagpal/tsproxy.git",
|
|
18
|
+
"directory": "packages/cli"
|
|
19
|
+
},
|
|
20
|
+
"license": "MIT",
|
|
21
|
+
"keywords": [
|
|
22
|
+
"typesense",
|
|
23
|
+
"search",
|
|
24
|
+
"proxy",
|
|
25
|
+
"cli"
|
|
26
|
+
],
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"@clack/prompts": "^0.10.0",
|
|
29
|
+
"commander": "^13.1.0",
|
|
30
|
+
"picocolors": "^1.1.1"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"@types/node": "^20",
|
|
34
|
+
"tsup": "^8.4.0",
|
|
35
|
+
"tsx": "^4.19.0",
|
|
36
|
+
"typescript": "^5"
|
|
37
|
+
},
|
|
38
|
+
"scripts": {
|
|
39
|
+
"build": "tsup src/index.ts --format esm --target node22",
|
|
40
|
+
"dev": "tsx src/index.ts"
|
|
41
|
+
}
|
|
42
|
+
}
|