create-amiki 1.0.2 → 1.0.3
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/index.js +729 -9
- package/package.json +2 -4
package/index.js
CHANGED
|
@@ -3,21 +3,741 @@
|
|
|
3
3
|
// Copyright (c) 2026 amiki-framework Contributors
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
|
-
* create-amiki —
|
|
6
|
+
* create-amiki — Scaffolding wizard for amiki-framework projects.
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* simply delegates to the real implementation in @amiki/create.
|
|
8
|
+
* Self-contained standalone package (no external dependencies).
|
|
9
|
+
* Run via: bun create amiki, npx create-amiki, or bunx create-amiki
|
|
11
10
|
*
|
|
12
11
|
* Usage:
|
|
13
|
-
* bun create amiki
|
|
14
|
-
* bun create amiki my-project
|
|
15
|
-
*
|
|
12
|
+
* bun create amiki Interactive wizard
|
|
13
|
+
* bun create amiki my-project Create in ./my-project
|
|
14
|
+
* create-amiki --preset small my-project Quick-start with preset
|
|
16
15
|
*/
|
|
17
16
|
|
|
18
|
-
import {
|
|
17
|
+
import { writeFile, mkdir } from 'node:fs/promises';
|
|
18
|
+
import { join, dirname } from 'node:path';
|
|
19
|
+
import { existsSync } from 'node:fs';
|
|
20
|
+
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// Types
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @typedef {'small' | 'cluster' | 'multi-service'} Preset
|
|
27
|
+
* @typedef {{ projectName: string, preset: Preset, features: string[], useDocker: boolean }} Answers
|
|
28
|
+
* @typedef {{ path: string, content: string }} FileSpec
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// CLI argument parsing
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
const args = process.argv.slice(2);
|
|
36
|
+
|
|
37
|
+
function parseArgs() {
|
|
38
|
+
/** @type {string | undefined} */
|
|
39
|
+
let targetDir;
|
|
40
|
+
/** @type {string | undefined} */
|
|
41
|
+
let preset;
|
|
42
|
+
|
|
43
|
+
for (let i = 0; i < args.length; i++) {
|
|
44
|
+
const arg = args[i];
|
|
45
|
+
if (!arg) continue;
|
|
46
|
+
|
|
47
|
+
if (arg === '--preset' || arg === '-p') {
|
|
48
|
+
preset = args[++i];
|
|
49
|
+
} else if (!arg.startsWith('-')) {
|
|
50
|
+
targetDir = arg;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return { targetDir, preset };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
// Helpers
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
function tsconfigContent() {
|
|
62
|
+
return JSON.stringify({
|
|
63
|
+
compilerOptions: {
|
|
64
|
+
target: 'ESNext',
|
|
65
|
+
module: 'ESNext',
|
|
66
|
+
moduleResolution: 'bundler',
|
|
67
|
+
moduleDetection: 'force',
|
|
68
|
+
lib: ['ESNext'],
|
|
69
|
+
types: ['bun-types'],
|
|
70
|
+
strict: true,
|
|
71
|
+
noUncheckedIndexedAccess: true,
|
|
72
|
+
noImplicitOverride: true,
|
|
73
|
+
forceConsistentCasingInFileNames: true,
|
|
74
|
+
isolatedModules: true,
|
|
75
|
+
verbatimModuleSyntax: true,
|
|
76
|
+
esModuleInterop: true,
|
|
77
|
+
skipLibCheck: true,
|
|
78
|
+
allowImportingTsExtensions: true,
|
|
79
|
+
noEmit: true,
|
|
80
|
+
},
|
|
81
|
+
include: ['src'],
|
|
82
|
+
}, null, 2);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** @param {Answers} answers */
|
|
86
|
+
function readmeContent(answers) {
|
|
87
|
+
return `# ${answers.projectName}
|
|
88
|
+
|
|
89
|
+
A Discord bot built with [amiki-framework](https://github.com/amiki-framework/amiki).
|
|
90
|
+
|
|
91
|
+
## Preset: ${answers.preset}
|
|
92
|
+
|
|
93
|
+
This project was scaffolded with \`amiki init\`.
|
|
94
|
+
|
|
95
|
+
## Getting started
|
|
96
|
+
|
|
97
|
+
\`\`\`bash
|
|
98
|
+
# Install dependencies
|
|
99
|
+
bun install
|
|
100
|
+
|
|
101
|
+
# Set up your bot token
|
|
102
|
+
cp .env.example .env
|
|
103
|
+
# Edit .env with your Discord bot token
|
|
104
|
+
|
|
105
|
+
# Run
|
|
106
|
+
bun run src/index.ts
|
|
107
|
+
\`\`\`
|
|
108
|
+
|
|
109
|
+
## Commands
|
|
110
|
+
|
|
111
|
+
| Command | Description |
|
|
112
|
+
|---------|-------------|
|
|
113
|
+
| \`bun run src/index.ts\` | Start the bot |
|
|
114
|
+
| \`bun run typecheck\` | Type-check the project |
|
|
115
|
+
|
|
116
|
+
## License
|
|
117
|
+
|
|
118
|
+
MIT
|
|
119
|
+
`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function envContent() {
|
|
123
|
+
return '# Discord bot token (https://discord.com/developers/applications)\nDISCORD_TOKEN=\n\n# Environment\nNODE_ENV=development\n';
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function dockerfileContent() {
|
|
127
|
+
return 'FROM oven/bun:latest\n\nWORKDIR /app\n\nCOPY package.json bun.lock ./\nRUN bun install --frozen-lockfile\n\nCOPY . .\n\nCMD ["bun", "run", "src/index.ts"]\n';
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** @param {Answers} answers */
|
|
131
|
+
function dockerComposeContent(answers) {
|
|
132
|
+
if (answers.preset === 'multi-service') {
|
|
133
|
+
return `version: '3.8'
|
|
134
|
+
|
|
135
|
+
services:
|
|
136
|
+
gateway:
|
|
137
|
+
build:
|
|
138
|
+
context: ./services/gateway
|
|
139
|
+
dockerfile: Dockerfile
|
|
140
|
+
environment:
|
|
141
|
+
- NODE_ENV=production
|
|
142
|
+
- DISCORD_TOKEN=\${DISC_TOKEN}
|
|
143
|
+
restart: unless-stopped
|
|
144
|
+
|
|
145
|
+
api:
|
|
146
|
+
build:
|
|
147
|
+
context: ./services/api
|
|
148
|
+
dockerfile: Dockerfile
|
|
149
|
+
ports:
|
|
150
|
+
- "9090:9090"
|
|
151
|
+
environment:
|
|
152
|
+
- NODE_ENV=production
|
|
153
|
+
restart: unless-stopped
|
|
154
|
+
|
|
155
|
+
worker:
|
|
156
|
+
build:
|
|
157
|
+
context: ./services/worker
|
|
158
|
+
dockerfile: Dockerfile
|
|
159
|
+
environment:
|
|
160
|
+
- NODE_ENV=production
|
|
161
|
+
- DISCORD_TOKEN=\${DISC_TOKEN}
|
|
162
|
+
restart: unless-stopped
|
|
163
|
+
depends_on:
|
|
164
|
+
- gateway
|
|
165
|
+
`;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return `version: '3.8'
|
|
169
|
+
|
|
170
|
+
services:
|
|
171
|
+
app:
|
|
172
|
+
build: .
|
|
173
|
+
environment:
|
|
174
|
+
- NODE_ENV=production
|
|
175
|
+
- DISCORD_TOKEN=\${DISCORD_TOKEN}
|
|
176
|
+
restart: unless-stopped
|
|
177
|
+
`;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** @param {Record<string,string>} deps @param {Record<string,string>} devDeps */
|
|
181
|
+
function packageJsonContent(answers, deps, devDeps) {
|
|
182
|
+
return JSON.stringify({
|
|
183
|
+
name: answers.projectName,
|
|
184
|
+
version: '0.1.0',
|
|
185
|
+
private: true,
|
|
186
|
+
type: 'module',
|
|
187
|
+
scripts: {
|
|
188
|
+
start: 'bun run src/index.ts',
|
|
189
|
+
typecheck: 'tsc --noEmit',
|
|
190
|
+
},
|
|
191
|
+
dependencies: deps,
|
|
192
|
+
devDependencies: devDeps,
|
|
193
|
+
}, null, 2);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ---------------------------------------------------------------------------
|
|
197
|
+
// Preset: small
|
|
198
|
+
// ---------------------------------------------------------------------------
|
|
199
|
+
|
|
200
|
+
/** @param {Answers} answers @returns {FileSpec[]} */
|
|
201
|
+
function smallTemplate(answers) {
|
|
202
|
+
const files = [];
|
|
203
|
+
|
|
204
|
+
files.push({
|
|
205
|
+
path: 'package.json',
|
|
206
|
+
content: packageJsonContent(answers,
|
|
207
|
+
{ '@amiki/core': '^0.1.0', '@amiki/types': '^0.1.0' },
|
|
208
|
+
{ typescript: '^5.8.0', 'bun-types': '^1.3.0' },
|
|
209
|
+
),
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
files.push({ path: 'tsconfig.json', content: tsconfigContent() });
|
|
213
|
+
|
|
214
|
+
files.push({
|
|
215
|
+
path: 'src/index.ts',
|
|
216
|
+
content: `#!/usr/bin/env bun
|
|
217
|
+
// SPDX-License-Identifier: MIT
|
|
218
|
+
|
|
219
|
+
import { GatewayClient, GatewayIntent } from '@amiki/core';
|
|
220
|
+
import type { GatewayPayload, MessageCreateEventData } from '@amiki/types';
|
|
221
|
+
|
|
222
|
+
const token = process.env.DISCORD_TOKEN;
|
|
223
|
+
if (!token) {
|
|
224
|
+
console.error('DISCORD_TOKEN is required');
|
|
225
|
+
process.exit(1);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ---------------------------------------------------------------------------
|
|
229
|
+
// Gateway client
|
|
230
|
+
// ---------------------------------------------------------------------------
|
|
231
|
+
|
|
232
|
+
const client = new GatewayClient({
|
|
233
|
+
token,
|
|
234
|
+
intents:
|
|
235
|
+
GatewayIntent.Guilds |
|
|
236
|
+
GatewayIntent.GuildMessages |
|
|
237
|
+
GatewayIntent.MessageContent,
|
|
238
|
+
totalShards: 1,
|
|
239
|
+
shardId: 0,
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
// ---------------------------------------------------------------------------
|
|
243
|
+
// Event handlers
|
|
244
|
+
// ---------------------------------------------------------------------------
|
|
245
|
+
|
|
246
|
+
client.on('ready', (payload) => {
|
|
247
|
+
const user = payload.d?.user;
|
|
248
|
+
console.log(\`Logged in as \${user?.username ?? 'unknown'} (\${user?.id ?? '?'})\`);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
client.on('message_create', (payload) => {
|
|
252
|
+
const msg = payload.d;
|
|
253
|
+
if (!msg || msg.author?.bot) return;
|
|
254
|
+
|
|
255
|
+
console.log(\`[\${msg.channel_id}] \${msg.author?.username}: \${msg.content}\`);
|
|
256
|
+
|
|
257
|
+
// Simple ping-pong
|
|
258
|
+
if (msg.content === '!ping') {
|
|
259
|
+
console.log('Pong!');
|
|
260
|
+
}
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
// ---------------------------------------------------------------------------
|
|
264
|
+
// Start
|
|
265
|
+
// ---------------------------------------------------------------------------
|
|
266
|
+
|
|
267
|
+
console.log('Starting bot...');
|
|
268
|
+
await client.connect();
|
|
269
|
+
console.log('Bot is running. Press Ctrl+C to stop.');
|
|
270
|
+
|
|
271
|
+
// Graceful shutdown
|
|
272
|
+
process.on('SIGINT', async () => {
|
|
273
|
+
console.log('\\nShutting down...');
|
|
274
|
+
await client.disconnect();
|
|
275
|
+
process.exit(0);
|
|
276
|
+
});
|
|
277
|
+
`,
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
files.push({ path: '.env.example', content: envContent() });
|
|
281
|
+
files.push({ path: 'README.md', content: readmeContent(answers) });
|
|
282
|
+
|
|
283
|
+
if (answers.features.includes('docker')) {
|
|
284
|
+
files.push({ path: 'Dockerfile', content: dockerfileContent() });
|
|
285
|
+
if (answers.useDocker) {
|
|
286
|
+
files.push({ path: 'docker-compose.yml', content: dockerComposeContent(answers) });
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return files;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// ---------------------------------------------------------------------------
|
|
294
|
+
// Preset: cluster
|
|
295
|
+
// ---------------------------------------------------------------------------
|
|
296
|
+
|
|
297
|
+
/** @param {Answers} answers @returns {FileSpec[]} */
|
|
298
|
+
function clusterTemplate(answers) {
|
|
299
|
+
const files = [];
|
|
300
|
+
|
|
301
|
+
files.push({
|
|
302
|
+
path: 'package.json',
|
|
303
|
+
content: packageJsonContent(answers,
|
|
304
|
+
{
|
|
305
|
+
'@amiki/core': '^0.1.0',
|
|
306
|
+
'@amiki/cluster': '^0.1.0',
|
|
307
|
+
'@amiki/metrics': '^0.1.0',
|
|
308
|
+
'@amiki/types': '^0.1.0',
|
|
309
|
+
},
|
|
310
|
+
{ typescript: '^5.8.0', 'bun-types': '^1.3.0' },
|
|
311
|
+
),
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
files.push({ path: 'tsconfig.json', content: tsconfigContent() });
|
|
315
|
+
|
|
316
|
+
files.push({
|
|
317
|
+
path: 'src/index.ts',
|
|
318
|
+
content: `#!/usr/bin/env bun
|
|
319
|
+
// SPDX-License-Identifier: MIT
|
|
320
|
+
|
|
321
|
+
import { ClusterManager } from '@amiki/cluster';
|
|
322
|
+
import { GatewayIntent } from '@amiki/types';
|
|
323
|
+
|
|
324
|
+
const token = process.env.DISCORD_TOKEN;
|
|
325
|
+
if (!token) {
|
|
326
|
+
console.error('DISCORD_TOKEN is required');
|
|
327
|
+
process.exit(1);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// ---------------------------------------------------------------------------
|
|
331
|
+
// Cluster configuration
|
|
332
|
+
// ---------------------------------------------------------------------------
|
|
333
|
+
|
|
334
|
+
const cluster = new ClusterManager({
|
|
335
|
+
token,
|
|
336
|
+
intents:
|
|
337
|
+
GatewayIntent.Guilds |
|
|
338
|
+
GatewayIntent.GuildMessages |
|
|
339
|
+
GatewayIntent.MessageContent,
|
|
340
|
+
totalShards: Number(process.env.TOTAL_SHARDS ?? 1),
|
|
341
|
+
shardsPerWorker: Number(process.env.SHARDS_PER_WORKER ?? 8),
|
|
342
|
+
maxConcurrency: Number(process.env.MAX_CONCURRENCY ?? 1),
|
|
343
|
+
mode: /** @type {'direct' | 'proxy'} */ (process.env.CLUSTER_MODE ?? 'direct'),
|
|
344
|
+
|
|
345
|
+
onEvent: (event) => {
|
|
346
|
+
switch (event.type) {
|
|
347
|
+
case 'cluster_started':
|
|
348
|
+
console.log(\`Cluster started: \${event.topology.workerCount} workers, \${event.topology.totalShards} shards\`);
|
|
349
|
+
break;
|
|
350
|
+
case 'worker_started':
|
|
351
|
+
console.log(\`Worker \${event.workerId} started\`);
|
|
352
|
+
break;
|
|
353
|
+
case 'worker_unhealthy':
|
|
354
|
+
console.warn(\`Worker \${event.workerId} unhealthy — restarting\`);
|
|
355
|
+
break;
|
|
356
|
+
case 'worker_restarted':
|
|
357
|
+
console.log(\`Worker \${event.workerId} restarted\`);
|
|
358
|
+
break;
|
|
359
|
+
case 'cluster_stopped':
|
|
360
|
+
console.log('Cluster stopped');
|
|
361
|
+
break;
|
|
362
|
+
case 'error':
|
|
363
|
+
console.error(\`Cluster error: \${event.error.message}\`);
|
|
364
|
+
break;
|
|
365
|
+
}
|
|
366
|
+
},
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
// ---------------------------------------------------------------------------
|
|
370
|
+
// Start
|
|
371
|
+
// ---------------------------------------------------------------------------
|
|
372
|
+
|
|
373
|
+
try {
|
|
374
|
+
await cluster.start();
|
|
375
|
+
console.log(\`Cluster ready — \${cluster.shardTopology?.workerCount} workers\`);
|
|
376
|
+
} catch (err) {
|
|
377
|
+
console.error('Failed to start cluster:', err);
|
|
378
|
+
process.exit(1);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// Graceful shutdown
|
|
382
|
+
process.on('SIGINT', async () => {
|
|
383
|
+
console.log('\\nShutting down cluster...');
|
|
384
|
+
await cluster.stop('SIGINT');
|
|
385
|
+
process.exit(0);
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
process.on('SIGTERM', async () => {
|
|
389
|
+
console.log('\\nShutting down cluster...');
|
|
390
|
+
await cluster.stop('SIGTERM');
|
|
391
|
+
process.exit(0);
|
|
392
|
+
});
|
|
393
|
+
`,
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
if (answers.features.includes('metrics')) {
|
|
397
|
+
files.push({
|
|
398
|
+
path: 'src/metrics.ts',
|
|
399
|
+
content: `import { MetricRegistry, MetricsServer, registerProcessMetrics } from '@amiki/metrics';
|
|
400
|
+
|
|
401
|
+
export function createMetricsServer(port = 9090) {
|
|
402
|
+
const registry = new MetricRegistry({
|
|
403
|
+
prefix: 'amiki',
|
|
404
|
+
defaultLabels: {
|
|
405
|
+
service: 'discord-bot',
|
|
406
|
+
environment: process.env.NODE_ENV ?? 'development',
|
|
407
|
+
},
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
registerProcessMetrics(registry);
|
|
411
|
+
|
|
412
|
+
const server = new MetricsServer({ port, registry });
|
|
413
|
+
server.start();
|
|
414
|
+
|
|
415
|
+
console.log(\`Metrics: http://localhost:\${port}/metrics\`);
|
|
416
|
+
return server;
|
|
417
|
+
}
|
|
418
|
+
`,
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
files.push({
|
|
423
|
+
path: '.env.example',
|
|
424
|
+
content: `${envContent()}# Sharding\nTOTAL_SHARDS=1\nSHARDS_PER_WORKER=8\nMAX_CONCURRENCY=1\nCLUSTER_MODE=direct\n\n# Metrics\nMETRICS_PORT=9090\n`,
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
files.push({ path: 'README.md', content: readmeContent(answers) });
|
|
428
|
+
|
|
429
|
+
if (answers.features.includes('docker')) {
|
|
430
|
+
files.push({ path: 'Dockerfile', content: dockerfileContent() });
|
|
431
|
+
if (answers.useDocker) {
|
|
432
|
+
files.push({ path: 'docker-compose.yml', content: dockerComposeContent(answers) });
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
return files;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// ---------------------------------------------------------------------------
|
|
440
|
+
// Preset: multi-service
|
|
441
|
+
// ---------------------------------------------------------------------------
|
|
442
|
+
|
|
443
|
+
/** @param {Answers} answers @returns {FileSpec[]} */
|
|
444
|
+
function multiServiceTemplate(answers) {
|
|
445
|
+
const files = [];
|
|
446
|
+
|
|
447
|
+
// Root
|
|
448
|
+
files.push({
|
|
449
|
+
path: 'package.json',
|
|
450
|
+
content: JSON.stringify({
|
|
451
|
+
name: answers.projectName,
|
|
452
|
+
version: '0.1.0',
|
|
453
|
+
private: true,
|
|
454
|
+
type: 'module',
|
|
455
|
+
scripts: {
|
|
456
|
+
'dev:gateway': 'bun run --watch services/gateway/src/index.ts',
|
|
457
|
+
'dev:api': 'bun run --watch services/api/src/index.ts',
|
|
458
|
+
'dev:worker': 'bun run --watch services/worker/src/index.ts',
|
|
459
|
+
typecheck: 'tsc --noEmit',
|
|
460
|
+
},
|
|
461
|
+
devDependencies: { typescript: '^5.8.0', 'bun-types': '^1.3.0' },
|
|
462
|
+
}, null, 2),
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
files.push({ path: 'tsconfig.json', content: tsconfigContent() });
|
|
466
|
+
|
|
467
|
+
// Gateway service
|
|
468
|
+
files.push({
|
|
469
|
+
path: 'services/gateway/package.json',
|
|
470
|
+
content: packageJsonContent(answers,
|
|
471
|
+
{ '@amiki/core': '^0.1.0', '@amiki/cluster': '^0.1.0', '@amiki/transport': '^0.1.0', '@amiki/types': '^0.1.0' },
|
|
472
|
+
{},
|
|
473
|
+
),
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
files.push({
|
|
477
|
+
path: 'services/gateway/src/index.ts',
|
|
478
|
+
content: `#!/usr/bin/env bun
|
|
479
|
+
// SPDX-License-Identifier: MIT
|
|
480
|
+
|
|
481
|
+
import { ClusterManager } from '@amiki/cluster';
|
|
482
|
+
import { GatewayIntent } from '@amiki/types';
|
|
483
|
+
|
|
484
|
+
const token = process.env.DISCORD_TOKEN;
|
|
485
|
+
if (!token) throw new Error('DISCORD_TOKEN required');
|
|
486
|
+
|
|
487
|
+
const cluster = new ClusterManager({
|
|
488
|
+
token,
|
|
489
|
+
intents: GatewayIntent.Guilds | GatewayIntent.GuildMessages,
|
|
490
|
+
totalShards: Number(process.env.TOTAL_SHARDS ?? 1),
|
|
491
|
+
shardsPerWorker: Number(process.env.SHARDS_PER_WORKER ?? 4),
|
|
492
|
+
maxConcurrency: 1,
|
|
493
|
+
mode: 'proxy',
|
|
494
|
+
onEvent: (e) => {
|
|
495
|
+
if (e.type === 'cluster_started') {
|
|
496
|
+
console.log(\`Gateway ready: \${e.topology.workerCount} workers\`);
|
|
497
|
+
}
|
|
498
|
+
},
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
await cluster.start();
|
|
502
|
+
|
|
503
|
+
process.on('SIGINT', async () => { await cluster.stop('SIGINT'); process.exit(0); });
|
|
504
|
+
`,
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
// API service
|
|
508
|
+
files.push({
|
|
509
|
+
path: 'services/api/package.json',
|
|
510
|
+
content: packageJsonContent(answers,
|
|
511
|
+
{ '@amiki/core': '^0.1.0', '@amiki/metrics': '^0.1.0', '@amiki/transport': '^0.1.0', '@amiki/types': '^0.1.0' },
|
|
512
|
+
{},
|
|
513
|
+
),
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
files.push({
|
|
517
|
+
path: 'services/api/src/index.ts',
|
|
518
|
+
content: `#!/usr/bin/env bun
|
|
519
|
+
// SPDX-License-Identifier: MIT
|
|
520
|
+
|
|
521
|
+
import { MetricRegistry, MetricsServer, registerProcessMetrics } from '@amiki/metrics';
|
|
522
|
+
|
|
523
|
+
const registry = new MetricRegistry({
|
|
524
|
+
prefix: 'amiki',
|
|
525
|
+
defaultLabels: { service: 'api', environment: process.env.NODE_ENV ?? 'development' },
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
registerProcessMetrics(registry);
|
|
529
|
+
|
|
530
|
+
const server = new MetricsServer({ port: Number(process.env.PORT ?? 9090), registry });
|
|
531
|
+
server.start();
|
|
532
|
+
|
|
533
|
+
console.log(\`API server running on port \${process.env.PORT ?? 9090}\`);
|
|
534
|
+
|
|
535
|
+
process.on('SIGINT', () => { server.stop(); process.exit(0); });
|
|
536
|
+
`,
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
// Worker service
|
|
540
|
+
files.push({
|
|
541
|
+
path: 'services/worker/package.json',
|
|
542
|
+
content: packageJsonContent(answers,
|
|
543
|
+
{ '@amiki/core': '^0.1.0', '@amiki/transport': '^0.1.0', '@amiki/types': '^0.1.0' },
|
|
544
|
+
{},
|
|
545
|
+
),
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
files.push({
|
|
549
|
+
path: 'services/worker/src/index.ts',
|
|
550
|
+
content: `#!/usr/bin/env bun
|
|
551
|
+
// SPDX-License-Identifier: MIT
|
|
552
|
+
|
|
553
|
+
import { InProcTransport } from '@amiki/transport';
|
|
554
|
+
import type { GatewayPayload, MessageCreateEventData } from '@amiki/types';
|
|
555
|
+
|
|
556
|
+
const transport = new InProcTransport();
|
|
557
|
+
await transport.connect();
|
|
558
|
+
await transport.subscribe('proxy.events.*', async (msg) => {
|
|
559
|
+
const event = /** @type {string} */ (msg.type);
|
|
560
|
+
if (event === 'message_create') {
|
|
561
|
+
const payload = JSON.parse(new TextDecoder().decode(msg.payload));
|
|
562
|
+
const d = payload.d;
|
|
563
|
+
if (d && !d.author?.bot) {
|
|
564
|
+
console.log(\`[\${d.channel_id}] \${d.author?.username}: \${d.content}\`);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
console.log('Worker ready, waiting for events...');
|
|
570
|
+
|
|
571
|
+
process.on('SIGINT', async () => { await transport.disconnect(); process.exit(0); });
|
|
572
|
+
`,
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
files.push({
|
|
576
|
+
path: 'config/default.ts',
|
|
577
|
+
content: `export const config = {
|
|
578
|
+
env: process.env.NODE_ENV ?? 'development',
|
|
579
|
+
transport: { type: 'inproc' },
|
|
580
|
+
};
|
|
581
|
+
`,
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
files.push({
|
|
585
|
+
path: '.env.example',
|
|
586
|
+
content: `${envContent()}# Sharding\nTOTAL_SHARDS=1\nSHARDS_PER_WORKER=4\n\n# Services\nPORT=9090\n`,
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
files.push({
|
|
590
|
+
path: 'README.md',
|
|
591
|
+
content: `# ${answers.projectName}
|
|
592
|
+
|
|
593
|
+
Multi-service Discord bot built with amiki-framework.
|
|
594
|
+
|
|
595
|
+
## Services
|
|
596
|
+
|
|
597
|
+
| Service | Description |
|
|
598
|
+
|---------|-------------|
|
|
599
|
+
| \`gateway\` | Discord Gateway connection, shard management |
|
|
600
|
+
| \`api\` | HTTP API + Prometheus metrics |
|
|
601
|
+
| \`worker\` | Event processing, business logic |
|
|
602
|
+
|
|
603
|
+
## Getting started
|
|
604
|
+
|
|
605
|
+
\`\`\`bash
|
|
606
|
+
bun install
|
|
607
|
+
cp .env.example .env
|
|
608
|
+
# Edit .env with your Discord bot token
|
|
609
|
+
bun run dev:gateway # Terminal 1
|
|
610
|
+
bun run dev:api # Terminal 2
|
|
611
|
+
bun run dev:worker # Terminal 3
|
|
612
|
+
\`\`\`
|
|
613
|
+
|
|
614
|
+
## Docker
|
|
615
|
+
|
|
616
|
+
\`\`\`bash
|
|
617
|
+
docker compose up
|
|
618
|
+
\`\`\`
|
|
619
|
+
`,
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
files.push({ path: 'Dockerfile', content: dockerfileContent() });
|
|
623
|
+
files.push({ path: 'docker-compose.yml', content: dockerComposeContent(answers) });
|
|
624
|
+
|
|
625
|
+
return files;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
// ---------------------------------------------------------------------------
|
|
629
|
+
// Template selector
|
|
630
|
+
// ---------------------------------------------------------------------------
|
|
631
|
+
|
|
632
|
+
/** @param {Answers} answers @returns {FileSpec[]} */
|
|
633
|
+
function generateFiles(answers) {
|
|
634
|
+
switch (answers.preset) {
|
|
635
|
+
case 'small': return smallTemplate(answers);
|
|
636
|
+
case 'cluster': return clusterTemplate(answers);
|
|
637
|
+
case 'multi-service': return multiServiceTemplate(answers);
|
|
638
|
+
default: return smallTemplate(answers);
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
// ---------------------------------------------------------------------------
|
|
643
|
+
// Quick-start with preset (non-interactive)
|
|
644
|
+
// ---------------------------------------------------------------------------
|
|
645
|
+
|
|
646
|
+
/** @param {string} preset @param {string} targetDir */
|
|
647
|
+
async function quickStart(preset, targetDir) {
|
|
648
|
+
const projectName = targetDir.split(/[/\\]/).pop() ?? 'amiki-app';
|
|
649
|
+
|
|
650
|
+
/** @type {Answers} */
|
|
651
|
+
const answers = {
|
|
652
|
+
projectName,
|
|
653
|
+
preset: /** @type {Preset} */ (preset),
|
|
654
|
+
features: [],
|
|
655
|
+
useDocker: false,
|
|
656
|
+
};
|
|
657
|
+
|
|
658
|
+
const files = generateFiles(answers);
|
|
659
|
+
|
|
660
|
+
for (const file of files) {
|
|
661
|
+
const fullPath = join(process.cwd(), targetDir, file.path);
|
|
662
|
+
const dir = dirname(fullPath);
|
|
663
|
+
|
|
664
|
+
if (!existsSync(dir)) {
|
|
665
|
+
await mkdir(dir, { recursive: true });
|
|
666
|
+
}
|
|
667
|
+
await writeFile(fullPath, file.content, 'utf-8');
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
console.log(`\n✔ Created ${files.length} files in ${projectName}/\n`);
|
|
671
|
+
console.log(' Next steps:');
|
|
672
|
+
console.log(` cd ${projectName}`);
|
|
673
|
+
console.log(' bun install');
|
|
674
|
+
console.log(' cp .env.example .env');
|
|
675
|
+
console.log(' bun run src/index.ts\n');
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
// ---------------------------------------------------------------------------
|
|
679
|
+
// Interactive wizard (dynamic import of @amiki/create if available)
|
|
680
|
+
// ---------------------------------------------------------------------------
|
|
681
|
+
|
|
682
|
+
async function runWizard(targetDir) {
|
|
683
|
+
try {
|
|
684
|
+
const mod = await import('@amiki/create');
|
|
685
|
+
if (mod && typeof mod.runWizard === 'function') {
|
|
686
|
+
await mod.runWizard(targetDir);
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
689
|
+
} catch {
|
|
690
|
+
// @amiki/create not available — run minimal wizard inline
|
|
691
|
+
}
|
|
692
|
+
// Fallback: prompt for project name and preset
|
|
693
|
+
const projectName = targetDir ?? 'my-amiki-bot';
|
|
694
|
+
const preset = 'small';
|
|
695
|
+
|
|
696
|
+
/** @type {Answers} */
|
|
697
|
+
const answers = {
|
|
698
|
+
projectName,
|
|
699
|
+
preset: /** @type {Preset} */ (preset),
|
|
700
|
+
features: [],
|
|
701
|
+
useDocker: false,
|
|
702
|
+
};
|
|
703
|
+
|
|
704
|
+
const files = generateFiles(answers);
|
|
705
|
+
const resolvedDir = join(process.cwd(), projectName);
|
|
706
|
+
|
|
707
|
+
for (const file of files) {
|
|
708
|
+
const fullPath = join(resolvedDir, file.path);
|
|
709
|
+
const dir = dirname(fullPath);
|
|
710
|
+
if (!existsSync(dir)) {
|
|
711
|
+
await mkdir(dir, { recursive: true });
|
|
712
|
+
}
|
|
713
|
+
await writeFile(fullPath, file.content, 'utf-8');
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
console.log(`\n✔ Created ${files.length} files in ${projectName}/\n`);
|
|
717
|
+
console.log(' Next steps:');
|
|
718
|
+
console.log(` cd ${projectName}`);
|
|
719
|
+
console.log(' bun install');
|
|
720
|
+
console.log(' cp .env.example .env');
|
|
721
|
+
console.log(' bun run src/index.ts\n');
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
// ---------------------------------------------------------------------------
|
|
725
|
+
// Main
|
|
726
|
+
// ---------------------------------------------------------------------------
|
|
727
|
+
|
|
728
|
+
async function main() {
|
|
729
|
+
const { targetDir, preset } = parseArgs();
|
|
730
|
+
|
|
731
|
+
if (preset && targetDir) {
|
|
732
|
+
await quickStart(preset, targetDir);
|
|
733
|
+
return;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
// Interactive wizard
|
|
737
|
+
await runWizard(targetDir);
|
|
738
|
+
}
|
|
19
739
|
|
|
20
740
|
main().catch((err) => {
|
|
21
|
-
console.error('Error:', err);
|
|
741
|
+
console.error('Error:', err?.message ?? err);
|
|
22
742
|
process.exit(1);
|
|
23
743
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-amiki",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.3",
|
|
4
4
|
"description": "Scaffolding wizard for amiki-framework — thin wrapper around @amiki/create. Run: bun create amiki",
|
|
5
5
|
"license": "GPL-2.0-only",
|
|
6
6
|
"type": "module",
|
|
@@ -38,7 +38,5 @@
|
|
|
38
38
|
"bun": ">=1.3.0",
|
|
39
39
|
"node": ">=18.0.0"
|
|
40
40
|
},
|
|
41
|
-
"dependencies": {
|
|
42
|
-
"@amiki/create": "^1.0.0"
|
|
43
|
-
}
|
|
41
|
+
"dependencies": {}
|
|
44
42
|
}
|