@ajke/cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,266 @@
1
+ import { writeFileSync, existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { APP_MODULES_DIR, wiltImportPath, ensureDir } from "../utils/paths.js";
4
+
5
+ function toPascalCase(name: string): string {
6
+ return name
7
+ .replace(/[-_](.)/g, (_, c) => c.toUpperCase())
8
+ .replace(/^(.)/, (_, c) => c.toUpperCase());
9
+ }
10
+
11
+ function toCamelCase(name: string): string {
12
+ const pascal = toPascalCase(name);
13
+ return pascal.charAt(0).toLowerCase() + pascal.slice(1);
14
+ }
15
+
16
+ function toKebabCase(name: string): string {
17
+ return name
18
+ .replace(/([A-Z])/g, "-$1")
19
+ .replace(/^-/, "")
20
+ .replace(/_/g, "-")
21
+ .toLowerCase();
22
+ }
23
+
24
+ function pluralize(name: string): string {
25
+ return name.endsWith("s") ? name : name + "s";
26
+ }
27
+
28
+ function moduleTemplate(name: string, wiltPath: string): string {
29
+ const pascal = toPascalCase(name);
30
+ const kebab = toKebabCase(name);
31
+
32
+ return `import { Module } from "${wiltPath}";
33
+ import { ${pascal}Controller } from "./${kebab}.controller";
34
+ import { ${pascal}Service } from "./${kebab}.service";
35
+
36
+ @Module({
37
+ controllers: [${pascal}Controller],
38
+ providers: [${pascal}Service],
39
+ exports: [${pascal}Service],
40
+ })
41
+ export class ${pascal}Module {}
42
+ `;
43
+ }
44
+
45
+ function entityTemplate(name: string): string {
46
+ const camel = toCamelCase(name);
47
+ const kebab = toKebabCase(name);
48
+ const pascal = toPascalCase(name);
49
+ const entityVar = pluralize(camel);
50
+ const tableName = pluralize(kebab);
51
+
52
+ return `import { sqliteTable, integer } from "drizzle-orm/sqlite-core";
53
+
54
+ export const ${entityVar} = sqliteTable("${tableName}", {
55
+ id: integer("id").primaryKey({ autoIncrement: true }),
56
+ createdAt: integer("created_at", { mode: "timestamp" })
57
+ .$defaultFn(() => new Date())
58
+ .notNull(),
59
+ });
60
+
61
+ export type ${pascal} = typeof ${entityVar}.$inferSelect;
62
+ export type New${pascal} = typeof ${entityVar}.$inferInsert;
63
+ `;
64
+ }
65
+
66
+ function controllerTemplate(name: string, wiltPath: string): string {
67
+ const pascal = toPascalCase(name);
68
+ const camel = toCamelCase(name);
69
+ const kebab = toKebabCase(name);
70
+ return `import type { Context } from "hono";
71
+ import { Controller, Get, Inject } from "${wiltPath}";
72
+ import { ResponseUtil } from "${wiltPath}";
73
+ import { ${pascal}Service } from "./${kebab}.service";
74
+
75
+ @Controller("/${kebab}")
76
+ export class ${pascal}Controller {
77
+ constructor(@Inject(${pascal}Service) private ${camel}Service: ${pascal}Service) {}
78
+
79
+ @Get()
80
+ async getAll(c: Context) {
81
+ const data = await this.${camel}Service.findAll();
82
+ return ResponseUtil.success(c, data);
83
+ }
84
+ }
85
+ `;
86
+ }
87
+
88
+ function serviceTemplate(name: string, wiltPath: string): string {
89
+ const pascal = toPascalCase(name);
90
+ return `import { Injectable } from "${wiltPath}";
91
+ import { createDatabase } from "../../../database/connection";
92
+
93
+ @Injectable()
94
+ export class ${pascal}Service {
95
+
96
+ private getDatabase(env: CloudflareBindings) {
97
+ return createDatabase(env.DB);
98
+ }
99
+
100
+ async findAll() {
101
+ return [];
102
+ }
103
+ }
104
+ `;
105
+ }
106
+
107
+ function testTemplate(name: string): string {
108
+ const kebab = toKebabCase(name);
109
+ const pascal = toPascalCase(name);
110
+ const table = pluralize(kebab.replace(/-/g, "_"));
111
+ return `import { describe, it, expect, beforeAll, afterEach } from "vitest";
112
+ import { SELF, env, applyD1Migrations } from "cloudflare:test";
113
+ import { ${pascal}Service } from "./${kebab}.service";
114
+
115
+ const BASE = "http://localhost";
116
+ const service = new ${pascal}Service();
117
+
118
+ beforeAll(async () => {
119
+ await applyD1Migrations(env.DB, JSON.parse(env.TEST_MIGRATIONS));
120
+ });
121
+
122
+ afterEach(async () => {
123
+ await env.DB.prepare("DELETE FROM ${table}").run();
124
+ });
125
+
126
+ describe("${pascal}Service", () => {
127
+ it("should be defined", () => {
128
+ expect(service).toBeDefined();
129
+ });
130
+ });
131
+
132
+ describe("GET /${kebab}", () => {
133
+ it("returns an empty list", async () => {
134
+ const res = await SELF.fetch(\`\${BASE}/${kebab}\`);
135
+ expect(res.status).toBe(200);
136
+ const { data } = await res.json() as { data: unknown[] };
137
+ expect(data).toEqual([]);
138
+ });
139
+ });
140
+ `;
141
+ }
142
+
143
+ function dtoTemplate(name: string): string {
144
+ const pascal = toPascalCase(name);
145
+ return `import { z } from "zod";
146
+
147
+ export const Create${pascal}Dto = z.object({
148
+ // TODO: define your fields
149
+ });
150
+
151
+ export const Update${pascal}Dto = Create${pascal}Dto.partial();
152
+
153
+ export type Create${pascal}DtoType = z.infer<typeof Create${pascal}Dto>;
154
+ export type Update${pascal}DtoType = z.infer<typeof Update${pascal}Dto>;
155
+ `;
156
+ }
157
+
158
+ export type GeneratableFile = "module" | "controller" | "service" | "dto" | "entity" | "test";
159
+
160
+ export interface GenerateModuleOptions {
161
+ /** Override output directory — defaults to config.modulesDir/<name>/ */
162
+ dir?: string;
163
+ /** Which files to generate — defaults to config.generate.files */
164
+ files?: GeneratableFile[];
165
+ /** Base directory for generated modules (from wilt.config.ts) */
166
+ modulesDir?: string;
167
+ /** Default file list (from wilt.config.ts) */
168
+ defaultFiles?: GeneratableFile[];
169
+ /** Skip generating the test file even if it is in defaultFiles */
170
+ noTest?: boolean;
171
+ }
172
+
173
+ export function generateModule(name: string, options: GenerateModuleOptions = {}): void {
174
+ const kebab = toKebabCase(name);
175
+ const pascal = toPascalCase(name);
176
+ const basedir = options.modulesDir ?? APP_MODULES_DIR;
177
+ const moduleDir = options.dir ?? join(basedir, kebab);
178
+ let filesToGen = options.files ?? options.defaultFiles ?? ["module", "controller", "service", "dto", "entity", "test"];
179
+ if (options.noTest) filesToGen = filesToGen.filter((f) => f !== "test");
180
+
181
+ if (existsSync(moduleDir)) {
182
+ console.error(` ✗ Directory already exists: ${moduleDir}`);
183
+ process.exit(1);
184
+ }
185
+
186
+ ensureDir(moduleDir);
187
+
188
+ const wiltPath = wiltImportPath();
189
+ const hasEntity = filesToGen.includes("entity");
190
+
191
+ const fileMap: Record<string, string> = {
192
+ module: moduleTemplate(name, wiltPath),
193
+ controller: controllerTemplate(name, wiltPath),
194
+ service: serviceTemplate(name, wiltPath),
195
+ dto: dtoTemplate(name),
196
+ entity: entityTemplate(name),
197
+ test: testTemplate(name),
198
+ };
199
+
200
+ const extMap: Record<string, string> = {
201
+ module: `${kebab}.module.ts`,
202
+ controller: `${kebab}.controller.ts`,
203
+ service: `${kebab}.service.ts`,
204
+ dto: `${kebab}.dto.ts`,
205
+ entity: `${kebab}.entity.ts`,
206
+ test: `${kebab}.test.ts`,
207
+ };
208
+
209
+ for (const file of filesToGen) {
210
+ const filePath = join(moduleDir, extMap[file]);
211
+ writeFileSync(filePath, fileMap[file], "utf-8");
212
+ console.log(` ✔ Created ${filePath.replace(process.cwd() + "/", "")}`);
213
+ }
214
+
215
+ const relModuleDir = moduleDir.replace(process.cwd() + "/", "");
216
+ const entityLine = hasEntity
217
+ ? `\n 2. Add the entity to src/database/schema.ts:\n\n import { ${pluralize(toCamelCase(name))} } from "@app/${relModuleDir.replace(/^src\//, "")}/${kebab}.entity";\n\n export const schema = {\n ...existing,\n ${pluralize(toCamelCase(name))},\n };\n`
218
+ : "";
219
+ console.log(`
220
+ Next steps —
221
+
222
+ 1. Register the module in src/app.module.ts:
223
+
224
+ import { ${pascal}Module } from "./${relModuleDir.replace(/^src\//, "")}/${kebab}.module";
225
+
226
+ @Module({
227
+ imports: [..., ${pascal}Module],
228
+ })
229
+ export class AppModule {}
230
+ ${entityLine} Then run \`pnpm db:gen\` to generate a migration.
231
+ `);
232
+ }
233
+
234
+ export function generateService(name: string, dir?: string, modulesDir?: string): void {
235
+ const kebab = toKebabCase(name);
236
+ const basedir = modulesDir ?? APP_MODULES_DIR;
237
+ const targetDir = dir ?? join(basedir, kebab);
238
+ const wiltPath = wiltImportPath();
239
+ const filePath = join(targetDir, `${kebab}.service.ts`);
240
+
241
+ if (existsSync(filePath)) {
242
+ console.error(` ✗ File already exists: ${filePath}`);
243
+ process.exit(1);
244
+ }
245
+
246
+ ensureDir(targetDir);
247
+ writeFileSync(filePath, serviceTemplate(name, wiltPath), "utf-8");
248
+ console.log(` ✔ Created ${filePath.replace(process.cwd() + "/", "")}`);
249
+ }
250
+
251
+ export function generateController(name: string, dir?: string, modulesDir?: string): void {
252
+ const kebab = toKebabCase(name);
253
+ const basedir = modulesDir ?? APP_MODULES_DIR;
254
+ const targetDir = dir ?? join(basedir, kebab);
255
+ const wiltPath = wiltImportPath();
256
+ const filePath = join(targetDir, `${kebab}.controller.ts`);
257
+
258
+ if (existsSync(filePath)) {
259
+ console.error(` ✗ File already exists: ${filePath}`);
260
+ process.exit(1);
261
+ }
262
+
263
+ ensureDir(targetDir);
264
+ writeFileSync(filePath, controllerTemplate(name, wiltPath), "utf-8");
265
+ console.log(` ✔ Created ${filePath.replace(process.cwd() + "/", "")}`);
266
+ }
@@ -0,0 +1,43 @@
1
+ import { existsSync } from "node:fs";
2
+ import { join, resolve } from "node:path";
3
+
4
+ export interface WiltConfig {
5
+ modulesDir?: string;
6
+ srcDir?: string;
7
+ generate?: {
8
+ files?: Array<"module" | "controller" | "service" | "dto" | "entity">;
9
+ };
10
+ }
11
+
12
+ export interface ResolvedWiltConfig {
13
+ modulesDir: string;
14
+ srcDir: string;
15
+ generate: {
16
+ files: Array<"module" | "controller" | "service" | "dto" | "entity">;
17
+ };
18
+ }
19
+
20
+ export async function loadConfig(cwd: string = process.cwd()): Promise<ResolvedWiltConfig> {
21
+ let userConfig: WiltConfig = {};
22
+
23
+ for (const name of ["ajke.config.ts", "ajke.config.js", "ajke.config.mjs", "wilt.config.ts", "wilt.config.js", "wilt.config.mjs"]) {
24
+ const configPath = join(cwd, name);
25
+ if (existsSync(configPath)) {
26
+ try {
27
+ const mod = await import(configPath);
28
+ userConfig = mod.default ?? {};
29
+ } catch {
30
+ // .ts files require tsx; compiled binary falls back to defaults
31
+ }
32
+ break;
33
+ }
34
+ }
35
+
36
+ return {
37
+ modulesDir: resolve(cwd, userConfig.modulesDir ?? "src/modules/app"),
38
+ srcDir: resolve(cwd, userConfig.srcDir ?? "src"),
39
+ generate: {
40
+ files: userConfig.generate?.files ?? ["module", "controller", "service", "dto", "entity"],
41
+ },
42
+ };
43
+ }
@@ -0,0 +1,32 @@
1
+ import { join } from "node:path";
2
+ import { existsSync, mkdirSync } from "node:fs";
3
+
4
+ /** Absolute path to the project root (where the CLI is invoked from) */
5
+ export const PROJECT_ROOT = process.cwd();
6
+
7
+ /** Absolute path to src/ */
8
+ export const SRC_DIR = join(PROJECT_ROOT, "src");
9
+
10
+ /** Absolute path to src/modules/ */
11
+ export const MODULES_DIR = join(SRC_DIR, "modules");
12
+
13
+ /** Absolute path to src/modules/app/ (default location for generated modules) */
14
+ export const APP_MODULES_DIR = join(MODULES_DIR, "app");
15
+
16
+ /** Absolute path to wrangler.jsonc */
17
+ export const WRANGLER_JSONC = join(PROJECT_ROOT, "wrangler.jsonc");
18
+
19
+ /** Absolute path to src/app.module.ts */
20
+ export const APP_MODULE_FILE = join(SRC_DIR, "app.module.ts");
21
+
22
+ /** The @ajke/core import used in generated files */
23
+ export function wiltImportPath(): string {
24
+ return "@ajke/core";
25
+ }
26
+
27
+ /** Ensure a directory exists, creating it recursively if needed */
28
+ export function ensureDir(dir: string): void {
29
+ if (!existsSync(dir)) {
30
+ mkdirSync(dir, { recursive: true });
31
+ }
32
+ }
@@ -0,0 +1,216 @@
1
+ import { readFileSync, writeFileSync } from "node:fs";
2
+ import { WRANGLER_JSONC } from "./paths.js";
3
+
4
+ type WranglerConfig = Record<string, any>;
5
+
6
+ /** Strip `//` line comments from a JSONC string so JSON.parse can handle it. */
7
+ function stripComments(text: string): string {
8
+ return text
9
+ .split("\n")
10
+ .map((line) => line.replace(/\s*\/\/.*$/, ""))
11
+ .join("\n");
12
+ }
13
+
14
+ export function readWrangler(): WranglerConfig {
15
+ const raw = readFileSync(WRANGLER_JSONC, "utf-8");
16
+ return JSON.parse(stripComments(raw));
17
+ }
18
+
19
+ export function writeWrangler(config: WranglerConfig): void {
20
+ const header = `{\n "$schema": "node_modules/wrangler/config-schema.json",`;
21
+ const body = JSON.stringify(config, null, 2);
22
+
23
+ // Remove opening brace and $schema from body (we add them above with the comment header)
24
+ const bodyWithoutSchema = body
25
+ .replace(/^\{/, "")
26
+ .replace(/\s+"?\$schema"?\s*:\s*"[^"]*",?\n/, "\n");
27
+
28
+ writeFileSync(WRANGLER_JSONC, header + bodyWithoutSchema, "utf-8");
29
+ }
30
+
31
+ // ─── Binding mutators ────────────────────────────────────────────────────────
32
+
33
+ export function addD1(binding: string, databaseName: string): void {
34
+ const config = readWrangler();
35
+ if (!config.d1_databases) config.d1_databases = [];
36
+
37
+ const alreadyExists = config.d1_databases.some(
38
+ (db: any) => db.binding === binding
39
+ );
40
+ if (alreadyExists) {
41
+ console.error(` ✗ D1 binding "${binding}" already exists in wrangler.jsonc`);
42
+ process.exit(1);
43
+ }
44
+
45
+ config.d1_databases.push({
46
+ binding,
47
+ database_name: databaseName,
48
+ database_id: "00000000-0000-0000-0000-000000000000",
49
+ migrations_dir: "migrations",
50
+ });
51
+
52
+ writeWrangler(config);
53
+ console.log(` ✔ Added D1 binding "${binding}" (database: ${databaseName})`);
54
+ console.log(` ! Remember to replace database_id with your actual D1 database ID.`);
55
+ }
56
+
57
+ export function addR2(binding: string, bucketName: string): void {
58
+ const config = readWrangler();
59
+ if (!config.r2_buckets) config.r2_buckets = [];
60
+
61
+ if (config.r2_buckets.some((b: any) => b.binding === binding)) {
62
+ console.error(` ✗ R2 binding "${binding}" already exists in wrangler.jsonc`);
63
+ process.exit(1);
64
+ }
65
+
66
+ config.r2_buckets.push({
67
+ binding,
68
+ bucket_name: bucketName,
69
+ preview_bucket_name: `${bucketName}-preview`,
70
+ });
71
+
72
+ writeWrangler(config);
73
+ console.log(` ✔ Added R2 binding "${binding}" (bucket: ${bucketName})`);
74
+ }
75
+
76
+ export function addKv(binding: string): void {
77
+ const config = readWrangler();
78
+ if (!config.kv_namespaces) config.kv_namespaces = [];
79
+
80
+ if (config.kv_namespaces.some((k: any) => k.binding === binding)) {
81
+ console.error(` ✗ KV binding "${binding}" already exists in wrangler.jsonc`);
82
+ process.exit(1);
83
+ }
84
+
85
+ config.kv_namespaces.push({
86
+ binding,
87
+ id: "00000000000000000000000000000000",
88
+ });
89
+
90
+ writeWrangler(config);
91
+ console.log(` ✔ Added KV namespace binding "${binding}"`);
92
+ console.log(` ! Remember to replace id with your actual KV namespace ID.`);
93
+ }
94
+
95
+ export function addQueue(binding: string, queueName: string): void {
96
+ const config = readWrangler();
97
+ if (!config.queues) config.queues = { producers: [], consumers: [] };
98
+ if (!config.queues.producers) config.queues.producers = [];
99
+ if (!config.queues.consumers) config.queues.consumers = [];
100
+
101
+ if (config.queues.producers.some((p: any) => p.binding === binding)) {
102
+ console.error(` ✗ Queue producer binding "${binding}" already exists in wrangler.jsonc`);
103
+ process.exit(1);
104
+ }
105
+
106
+ config.queues.producers.push({ binding, queue: queueName });
107
+ config.queues.consumers.push({
108
+ queue: queueName,
109
+ max_batch_size: 10,
110
+ max_batch_timeout: 10,
111
+ max_retries: 3,
112
+ });
113
+
114
+ writeWrangler(config);
115
+ console.log(` ✔ Added Queue binding "${binding}" (queue: ${queueName})`);
116
+ }
117
+
118
+ export function addAi(binding: string): void {
119
+ const config = readWrangler();
120
+
121
+ if (config.ai) {
122
+ console.error(` ✗ AI binding already exists in wrangler.jsonc`);
123
+ process.exit(1);
124
+ }
125
+
126
+ config.ai = { binding };
127
+ writeWrangler(config);
128
+ console.log(` ✔ Added AI binding "${binding}"`);
129
+ }
130
+
131
+ export function addDurableObject(binding: string, className: string): void {
132
+ const config = readWrangler();
133
+ if (!config.durable_objects) config.durable_objects = { bindings: [] };
134
+ if (!config.durable_objects.bindings) config.durable_objects.bindings = [];
135
+
136
+ if (config.durable_objects.bindings.some((b: any) => b.name === binding)) {
137
+ console.error(` ✗ Durable Object binding "${binding}" already exists in wrangler.jsonc`);
138
+ process.exit(1);
139
+ }
140
+
141
+ config.durable_objects.bindings.push({
142
+ name: binding,
143
+ class_name: className,
144
+ });
145
+
146
+ // Also add a migration entry
147
+ if (!config.migrations) config.migrations = [];
148
+ const existingClasses: string[] = config.migrations.flatMap(
149
+ (m: any) => m.new_sqlite_classes ?? m.new_classes ?? []
150
+ );
151
+ if (!existingClasses.includes(className)) {
152
+ const nextTag = `v${config.migrations.length + 1}`;
153
+ config.migrations.push({
154
+ tag: nextTag,
155
+ new_sqlite_classes: [className],
156
+ });
157
+ console.log(` ! Added migration entry "${nextTag}" for ${className}.`);
158
+ }
159
+
160
+ writeWrangler(config);
161
+ console.log(` ✔ Added Durable Object binding "${binding}" (class: ${className})`);
162
+ }
163
+
164
+ export function addVectorize(binding: string, indexName: string, dimensions: number = 1536): void {
165
+ const config = readWrangler();
166
+ if (!config.vectorize) config.vectorize = [];
167
+
168
+ if (config.vectorize.some((v: any) => v.binding === binding)) {
169
+ console.error(` ✗ Vectorize binding "${binding}" already exists in wrangler.jsonc`);
170
+ process.exit(1);
171
+ }
172
+
173
+ config.vectorize.push({
174
+ binding,
175
+ index_name: indexName,
176
+ dimensions,
177
+ metric: "cosine",
178
+ });
179
+
180
+ writeWrangler(config);
181
+ console.log(` ✔ Added Vectorize binding "${binding}" (index: ${indexName}, dimensions: ${dimensions})`);
182
+ console.log(` ! Create the index with: wrangler vectorize create ${indexName} --dimensions=${dimensions} --metric=cosine`);
183
+ }
184
+
185
+ export function addBrowser(binding: string): void {
186
+ const config = readWrangler();
187
+
188
+ if (config.browser) {
189
+ console.error(` ✗ Browser binding already exists in wrangler.jsonc`);
190
+ process.exit(1);
191
+ }
192
+
193
+ config.browser = { binding };
194
+ writeWrangler(config);
195
+ console.log(` ✔ Added Browser rendering binding "${binding}"`);
196
+ }
197
+
198
+ export function addHyperdrive(binding: string, connectionString: string): void {
199
+ const config = readWrangler();
200
+ if (!config.hyperdrive) config.hyperdrive = [];
201
+
202
+ if (config.hyperdrive.some((h: any) => h.binding === binding)) {
203
+ console.error(` ✗ Hyperdrive binding "${binding}" already exists in wrangler.jsonc`);
204
+ process.exit(1);
205
+ }
206
+
207
+ config.hyperdrive.push({
208
+ binding,
209
+ id: "00000000000000000000000000000000",
210
+ localConnectionString: connectionString,
211
+ });
212
+
213
+ writeWrangler(config);
214
+ console.log(` ✔ Added Hyperdrive binding "${binding}"`);
215
+ console.log(` ! Remember to replace id with your actual Hyperdrive config ID.`);
216
+ }