alepha 0.11.10 → 0.11.11

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.
@@ -1,16 +1,15 @@
1
- import { access, readFile, rm, writeFile } from "node:fs/promises";
1
+ import { access, rm } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
3
  import { $command } from "@alepha/command";
4
- import { $inject, AlephaError, boot, t } from "@alepha/core";
4
+ import { $inject, boot, t } from "@alepha/core";
5
5
  import { $logger } from "@alepha/logger";
6
- import { viteConfigTs } from "../assets/viteConfigTs.ts";
7
6
  import { ProcessRunner } from "../services/ProcessRunner.ts";
8
- import { CoreCommands } from "./CoreCommands.ts";
7
+ import { ProjectUtils } from "../services/ProjectUtils.ts";
9
8
 
10
9
  export class ViteCommands {
11
10
  protected readonly log = $logger();
12
11
  protected readonly runner = $inject(ProcessRunner);
13
- protected readonly core = $inject(CoreCommands);
12
+ protected readonly utils = $inject(ProjectUtils);
14
13
 
15
14
  public readonly run = $command({
16
15
  name: "run",
@@ -22,8 +21,8 @@ export class ViteCommands {
22
21
  }),
23
22
  summary: false,
24
23
  args: t.text({ title: "path", description: "Filepath to run" }),
25
- handler: async ({ args, flags }) => {
26
- await this.core.ensureTsConfig();
24
+ handler: async ({ args, flags, root }) => {
25
+ await this.utils.ensureTsConfig(root);
27
26
  await this.runner.exec(`tsx ${flags.watch ? "watch " : ""}${args}`);
28
27
  },
29
28
  });
@@ -38,10 +37,9 @@ export class ViteCommands {
38
37
  name: "dev",
39
38
  description: "Run the project in development mode",
40
39
  args: t.optional(t.text({ title: "path", description: "Filepath to run" })),
41
- handler: async ({ args }) => {
42
- const root = process.cwd();
43
- await this.core.ensureTsConfig(root);
44
- await this.ensurePackageJson(root);
40
+ handler: async ({ args, root }) => {
41
+ await this.utils.ensureTsConfig(root);
42
+ await this.utils.ensurePackageJsonModule(root);
45
43
  const entry = await boot.getServerEntry(root, args);
46
44
  this.log.trace("Entry file found", { entry });
47
45
 
@@ -49,11 +47,14 @@ export class ViteCommands {
49
47
  await access(join(root, "index.html"));
50
48
  } catch {
51
49
  this.log.trace("No index.html found, running entry file with tsx");
52
- //await this.runner.exec(`tsx watch ${entry}`);
53
- //return;
50
+ await this.runner.exec(`tsx watch ${entry}`);
51
+ return;
54
52
  }
55
53
 
56
- const configPath = await this.configPath(root, args ? entry : undefined);
54
+ const configPath = await this.utils.getViteConfigPath(
55
+ root,
56
+ args ? entry : undefined,
57
+ );
57
58
  this.log.trace("Vite config found", { configPath });
58
59
  await this.runner.exec(`vite -c=${configPath}`);
59
60
  },
@@ -77,8 +78,8 @@ export class ViteCommands {
77
78
  }),
78
79
  handler: async ({ flags, args }) => {
79
80
  const root = process.cwd();
80
- await this.core.ensureTsConfig(root);
81
- await this.ensurePackageJson(root);
81
+ await this.utils.ensureTsConfig(root);
82
+ await this.utils.ensurePackageJsonModule(root);
82
83
  const entry = await boot.getServerEntry(root, args);
83
84
  this.log.trace("Entry file found", { entry });
84
85
 
@@ -92,7 +93,10 @@ export class ViteCommands {
92
93
  // return;
93
94
  // }
94
95
 
95
- const configPath = await this.configPath(root, args ? entry : undefined);
96
+ const configPath = await this.utils.getViteConfigPath(
97
+ root,
98
+ args ? entry : undefined,
99
+ );
96
100
 
97
101
  const env: Record<string, string> = {};
98
102
  if (flags.stats) {
@@ -106,47 +110,10 @@ export class ViteCommands {
106
110
  public readonly test = $command({
107
111
  name: "test",
108
112
  description: "Run tests using Vitest",
109
- handler: async () => {
110
- await this.core.ensureTsConfig();
111
-
112
- const configPath = await this.configPath();
113
+ handler: async ({ root }) => {
114
+ await this.utils.ensureTsConfig(root);
115
+ const configPath = await this.utils.getViteConfigPath(root);
113
116
  await this.runner.exec(`vitest run -c=${configPath}`);
114
117
  },
115
118
  });
116
-
117
- // -------------------------------------------------------------------------------------------------------------------
118
-
119
- protected async configPath(
120
- root = process.cwd(),
121
- serverEntry?: string,
122
- ): Promise<string> {
123
- try {
124
- const viteConfigPath = join(root, "vite.config.ts");
125
- await access(viteConfigPath);
126
- return viteConfigPath;
127
- } catch {
128
- return this.runner.writeConfigFile(
129
- "vite.config.ts",
130
- viteConfigTs(serverEntry),
131
- );
132
- }
133
- }
134
-
135
- protected async ensurePackageJson(root = process.cwd()) {
136
- const packageJsonPath = join(root, "package.json");
137
- try {
138
- await access(packageJsonPath);
139
- } catch (error) {
140
- throw new AlephaError(
141
- "No package.json found in project root. Run 'npx alepha init' to create one.",
142
- );
143
- }
144
-
145
- const content = await readFile(packageJsonPath, "utf8");
146
- const packageJson = JSON.parse(content);
147
- if (!packageJson.type || packageJson.type !== "module") {
148
- packageJson.type = "module";
149
- await writeFile(packageJsonPath, JSON.stringify(packageJson, null, 2));
150
- }
151
- }
152
119
  }
@@ -0,0 +1,508 @@
1
+ import { access, readFile, writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { Readable } from "node:stream";
4
+ import { pipeline } from "node:stream/promises";
5
+ import { $inject, Alepha, AlephaError, boot } from "@alepha/core";
6
+ import { FileSystemProvider } from "@alepha/file";
7
+ import { $logger } from "@alepha/logger";
8
+ import type { RepositoryProvider } from "@alepha/orm";
9
+ import * as tar from "tar";
10
+ import { tsImport } from "tsx/esm/api";
11
+ import { biomeJson } from "../assets/biomeJson.ts";
12
+ import { tsconfigJson } from "../assets/tsconfigJson.ts";
13
+ import { viteConfigTs } from "../assets/viteConfigTs.ts";
14
+ import { version } from "../version.ts";
15
+ import { ProcessRunner } from "./ProcessRunner.ts";
16
+
17
+ /**
18
+ * Utility service for common project operations used by CLI commands.
19
+ *
20
+ * This service provides helper methods for:
21
+ * - Project configuration file management (tsconfig.json, package.json, etc.)
22
+ * - Package manager setup (Yarn, npm, pnpm)
23
+ * - Sample project downloading
24
+ * - Drizzle ORM/Kit utilities
25
+ * - Alepha instance loading
26
+ */
27
+ export class ProjectUtils {
28
+ protected readonly log = $logger();
29
+ protected readonly runner = $inject(ProcessRunner);
30
+ protected readonly fs = $inject(FileSystemProvider);
31
+
32
+ // ===================================================================================================================
33
+ // Package Manager & Project Setup
34
+ // ===================================================================================================================
35
+
36
+ /**
37
+ * Ensure Yarn is configured in the project directory.
38
+ *
39
+ * Creates a .yarnrc.yml file with node-modules linker if it doesn't exist.
40
+ *
41
+ * @param root - The root directory of the project
42
+ */
43
+ public async ensureYarn(root: string): Promise<void> {
44
+ const yarnrcPath = join(root, ".yarnrc.yml");
45
+ try {
46
+ await access(yarnrcPath);
47
+ } catch {
48
+ await writeFile(yarnrcPath, "nodeLinker: node-modules");
49
+ }
50
+
51
+ // remove lock files from other package managers
52
+ const npmLockPath = join(root, "package-lock.json");
53
+ const pnpmLockPath = join(root, "pnpm-lock.yaml");
54
+ await this.fs.rm(npmLockPath, { force: true });
55
+ await this.fs.rm(pnpmLockPath, { force: true });
56
+ }
57
+
58
+ /**
59
+ * Generate package.json content with Alepha dependencies.
60
+ *
61
+ * @param modes - Configuration for which dependencies to include
62
+ * @returns Package.json partial with dependencies, devDependencies, and scripts
63
+ */
64
+ public generatePackageJsonContent(modes: DependencyModes): {
65
+ dependencies: Record<string, string>;
66
+ devDependencies: Record<string, string>;
67
+ scripts: Record<string, string>;
68
+ } {
69
+ const dependencies: Record<string, string> = {
70
+ "@alepha/core": `^${version}`,
71
+ "@alepha/logger": `^${version}`,
72
+ "@alepha/datetime": `^${version}`,
73
+ };
74
+
75
+ const devDependencies: Record<string, string> = {
76
+ alepha: `^${version}`,
77
+ "@alepha/vite": `^${version}`,
78
+ };
79
+
80
+ if (modes.api) {
81
+ dependencies["@alepha/server"] = `^${version}`;
82
+ dependencies["@alepha/server-swagger"] = `^${version}`;
83
+ dependencies["@alepha/server-multipart"] = `^${version}`;
84
+ }
85
+
86
+ if (modes.orm) {
87
+ dependencies["@alepha/orm"] = `^${version}`;
88
+ }
89
+
90
+ if (modes.react) {
91
+ dependencies["@alepha/server"] = `^${version}`;
92
+ dependencies["@alepha/server-links"] = `^${version}`;
93
+ dependencies["@alepha/react"] = `^${version}`;
94
+
95
+ // React 19 support
96
+ dependencies.react = "^19.2.0";
97
+ devDependencies["@types/react"] = "^19.2.0";
98
+ }
99
+
100
+ return {
101
+ dependencies,
102
+ devDependencies,
103
+ scripts: {
104
+ dev: "alepha dev",
105
+ build: "alepha build",
106
+ },
107
+ };
108
+ }
109
+
110
+ /**
111
+ * Ensure package.json exists and has correct configuration.
112
+ *
113
+ * Creates a new package.json if none exists, or updates an existing one to:
114
+ * - Set "type": "module"
115
+ * - Add Alepha dependencies
116
+ * - Add standard scripts
117
+ *
118
+ * @param root - The root directory of the project
119
+ * @param modes - Configuration for which dependencies to include
120
+ */
121
+ public async ensurePackageJson(
122
+ root: string,
123
+ modes: DependencyModes,
124
+ ): Promise<void> {
125
+ const packageJsonPath = join(root, "package.json");
126
+ try {
127
+ await access(packageJsonPath);
128
+ } catch (error) {
129
+ await writeFile(
130
+ packageJsonPath,
131
+ JSON.stringify(this.generatePackageJsonContent(modes), null, 2),
132
+ );
133
+ return;
134
+ }
135
+
136
+ const content = await readFile(packageJsonPath, "utf8");
137
+ const packageJson = JSON.parse(content);
138
+ if (!packageJson.type || packageJson.type !== "module") {
139
+ packageJson.type = "module";
140
+ }
141
+ const newPackageJson = this.generatePackageJsonContent(modes);
142
+
143
+ packageJson.type = "module";
144
+ packageJson.dependencies ??= {};
145
+ packageJson.devDependencies ??= {};
146
+ packageJson.scripts ??= {};
147
+
148
+ Object.assign(packageJson.dependencies, newPackageJson.dependencies);
149
+ Object.assign(packageJson.devDependencies, newPackageJson.devDependencies);
150
+ Object.assign(packageJson.scripts, newPackageJson.scripts);
151
+
152
+ await writeFile(packageJsonPath, JSON.stringify(packageJson, null, 2));
153
+ }
154
+
155
+ /**
156
+ * Ensure package.json exists and is configured as ES module.
157
+ *
158
+ * Similar to ensurePackageJson but only validates/sets the "type": "module" field.
159
+ * Throws an error if no package.json exists.
160
+ *
161
+ * @param root - The root directory of the project
162
+ * @throws {AlephaError} If no package.json is found
163
+ */
164
+ public async ensurePackageJsonModule(root: string): Promise<void> {
165
+ const packageJsonPath = join(root, "package.json");
166
+ try {
167
+ await access(packageJsonPath);
168
+ } catch (error) {
169
+ throw new AlephaError(
170
+ "No package.json found in project root. Run 'npx alepha init' to create one.",
171
+ );
172
+ }
173
+
174
+ const content = await readFile(packageJsonPath, "utf8");
175
+ const packageJson = JSON.parse(content);
176
+ if (!packageJson.type || packageJson.type !== "module") {
177
+ packageJson.type = "module";
178
+ await writeFile(packageJsonPath, JSON.stringify(packageJson, null, 2));
179
+ }
180
+ }
181
+
182
+ /**
183
+ * Ensure tsconfig.json exists in the project.
184
+ *
185
+ * Creates a standard Alepha tsconfig.json if none exists.
186
+ *
187
+ * @param root - The root directory of the project
188
+ */
189
+ public async ensureTsConfig(root: string): Promise<void> {
190
+ const tsconfigPath = join(root, "tsconfig.json");
191
+ try {
192
+ await access(tsconfigPath);
193
+ } catch {
194
+ await writeFile(tsconfigPath, tsconfigJson);
195
+ }
196
+ }
197
+
198
+ /**
199
+ * Download Alepha starter project from GitHub.
200
+ *
201
+ * Downloads and extracts the apps/starter directory from the main Alepha repository.
202
+ *
203
+ * @param targetDir - The directory where the project should be extracted
204
+ * @throws {AlephaError} If the download fails
205
+ */
206
+ public async downloadSampleProject(targetDir: string): Promise<void> {
207
+ const url = "https://api.github.com/repos/feunard/alepha/tarball/main";
208
+ const response = await fetch(url, {
209
+ headers: {
210
+ "User-Agent": "Alepha-CLI", // GitHub API requires User-Agent
211
+ },
212
+ });
213
+
214
+ if (!response.ok) {
215
+ throw new AlephaError(`Failed to download: ${response.statusText}`);
216
+ }
217
+
218
+ const tarStream = Readable.fromWeb(response.body as any);
219
+ await pipeline(
220
+ tarStream,
221
+ tar.extract({
222
+ cwd: targetDir, // Extract to target directory
223
+ strip: 3, // Remove feunard-alepha-<hash>/apps/starter prefix
224
+ filter: (path) => {
225
+ // Only extract files from apps/starter/
226
+ const parts = path.split("/");
227
+ return (
228
+ parts.length >= 3 && parts[1] === "apps" && parts[2] === "starter"
229
+ );
230
+ },
231
+ }),
232
+ );
233
+ }
234
+
235
+ // ===================================================================================================================
236
+ // Biome Configuration
237
+ // ===================================================================================================================
238
+
239
+ /**
240
+ * Get the path to Biome configuration file.
241
+ *
242
+ * Looks for an existing biome.json in the project root, or creates one if it doesn't exist.
243
+ *
244
+ * @param maybePath - Optional custom path to biome config
245
+ * @returns Absolute path to the biome.json config file
246
+ */
247
+ public async getBiomeConfigPath(maybePath?: string): Promise<string> {
248
+ const root = process.cwd();
249
+ if (maybePath) {
250
+ try {
251
+ const path = join(root, maybePath);
252
+ await access(path);
253
+ return path;
254
+ } catch {}
255
+ }
256
+
257
+ try {
258
+ const path = join(root, "biome.json");
259
+ await access(path);
260
+ return path;
261
+ } catch {
262
+ return await this.runner.writeConfigFile("biome.json", biomeJson);
263
+ }
264
+ }
265
+
266
+ // ===================================================================================================================
267
+ // Vite Configuration
268
+ // ===================================================================================================================
269
+
270
+ /**
271
+ * Get the path to Vite configuration file.
272
+ *
273
+ * Looks for an existing vite.config.ts in the project root, or creates one if it doesn't exist.
274
+ *
275
+ * @param root - The root directory of the project (defaults to process.cwd())
276
+ * @param serverEntry - Optional path to the server entry file to include in the config
277
+ * @returns Absolute path to the vite.config.ts file
278
+ */
279
+ public async getViteConfigPath(
280
+ root: string,
281
+ serverEntry?: string,
282
+ ): Promise<string> {
283
+ try {
284
+ const viteConfigPath = join(root, "vite.config.ts");
285
+ await access(viteConfigPath);
286
+ return viteConfigPath;
287
+ } catch {
288
+ return this.runner.writeConfigFile(
289
+ "vite.config.ts",
290
+ viteConfigTs(serverEntry),
291
+ );
292
+ }
293
+ }
294
+
295
+ // ===================================================================================================================
296
+ // Drizzle ORM & Kit Utilities
297
+ // ===================================================================================================================
298
+
299
+ /**
300
+ * Load Alepha instance from a server entry file.
301
+ *
302
+ * Dynamically imports the server entry file and extracts the Alepha instance.
303
+ * Skips the automatic start process.
304
+ *
305
+ * @param rootDir - The root directory of the project
306
+ * @param explicitEntry - Optional explicit path to the entry file
307
+ * @returns Object containing the Alepha instance and the entry file path
308
+ * @throws {AlephaError} If the Alepha instance cannot be found
309
+ */
310
+ public async loadAlephaFromServerEntryFile(
311
+ rootDir?: string,
312
+ explicitEntry?: string,
313
+ ): Promise<{
314
+ alepha: Alepha;
315
+ entry: string;
316
+ }> {
317
+ process.env.ALEPHA_SKIP_START = "true";
318
+
319
+ const entry = await boot.getServerEntry(rootDir, explicitEntry);
320
+ const mod = await tsImport(entry, {
321
+ parentURL: import.meta.url,
322
+ });
323
+
324
+ this.log.debug(`Load entry: ${entry}`);
325
+
326
+ // check if alepha is correctly exported
327
+ if (mod.default instanceof Alepha) {
328
+ return {
329
+ alepha: mod.default,
330
+ entry,
331
+ };
332
+ }
333
+
334
+ // else, try with global variable
335
+ const g: any = global;
336
+ if (g.__alepha) {
337
+ return {
338
+ alepha: g.__alepha,
339
+ entry,
340
+ };
341
+ }
342
+
343
+ throw new AlephaError(
344
+ `Could not find Alepha instance in entry file: ${entry}`,
345
+ );
346
+ }
347
+
348
+ /**
349
+ * Get DrizzleKitProvider from an Alepha instance.
350
+ *
351
+ * Searches the Alepha registry for the DrizzleKitProvider instance.
352
+ *
353
+ * @param alepha - The Alepha instance to search
354
+ * @returns The DrizzleKitProvider instance
355
+ */
356
+ public getKitFromAlepha(alepha: Alepha): any {
357
+ // biome-ignore lint/complexity/useLiteralKeys: private key
358
+ return alepha["registry"]
359
+ .values()
360
+ .find((it: any) => it.instance.constructor.name === "DrizzleKitProvider")
361
+ ?.instance;
362
+ }
363
+
364
+ /**
365
+ * Generate JavaScript code for Drizzle entities export.
366
+ *
367
+ * Creates a temporary entities.js file that imports from the entry file
368
+ * and exports database models for Drizzle Kit to process.
369
+ *
370
+ * @param entry - Path to the server entry file
371
+ * @param provider - Name of the database provider
372
+ * @param models - Array of model names to export
373
+ * @returns JavaScript code as a string
374
+ */
375
+ public generateEntitiesJs(
376
+ entry: string,
377
+ provider: string,
378
+ models: string[] = [],
379
+ ): string {
380
+ return `
381
+ import "${entry}";
382
+ import { DrizzleKitProvider, Repository } from "@alepha/orm";
383
+
384
+ const alepha = globalThis.__alepha;
385
+ const kit = alepha.inject(DrizzleKitProvider);
386
+ const provider = alepha.services(Repository).find((it) => it.provider.name === "${provider}").provider;
387
+ const models = kit.getModels(provider);
388
+
389
+ ${models.map((it: string) => `export const ${it} = models["${it}"];`).join("\n")}
390
+
391
+ `.trim();
392
+ }
393
+
394
+ /**
395
+ * Prepare Drizzle configuration files for a database provider.
396
+ *
397
+ * Creates temporary entities.js and drizzle.config.js files needed
398
+ * for Drizzle Kit commands to run properly.
399
+ *
400
+ * @param options - Configuration options including kit, provider info, and paths
401
+ * @returns Path to the generated drizzle.config.js file
402
+ */
403
+ public async prepareDrizzleConfig(options: {
404
+ kit: any;
405
+ provider: any;
406
+ providerName: string;
407
+ providerUrl: string;
408
+ dialect: string;
409
+ entry: string;
410
+ rootDir: string;
411
+ }): Promise<string> {
412
+ const models = Object.keys(options.kit.getModels(options.provider));
413
+ const entitiesJs = this.generateEntitiesJs(
414
+ options.entry,
415
+ options.providerName,
416
+ models,
417
+ );
418
+
419
+ const entitiesJsPath = await this.runner.writeConfigFile(
420
+ "entities.js",
421
+ entitiesJs,
422
+ options.rootDir,
423
+ );
424
+
425
+ const config: Record<string, any> = {
426
+ schema: entitiesJsPath,
427
+ out: `./migrations/${options.providerName}`,
428
+ dialect: options.dialect,
429
+ dbCredentials: {
430
+ url: options.providerUrl,
431
+ },
432
+ };
433
+
434
+ if (options.providerName === "pglite") {
435
+ config.driver = "pglite";
436
+ }
437
+
438
+ const drizzleConfigJs = `export default ${JSON.stringify(config, null, 2)}`;
439
+
440
+ return await this.runner.writeConfigFile(
441
+ "drizzle.config.js",
442
+ drizzleConfigJs,
443
+ options.rootDir,
444
+ );
445
+ }
446
+
447
+ /**
448
+ * Run a drizzle-kit command for all database providers in an Alepha instance.
449
+ *
450
+ * Iterates through all repository providers, prepares Drizzle config for each,
451
+ * and executes the specified drizzle-kit command.
452
+ *
453
+ * @param options - Configuration including command to run, flags, and logging
454
+ */
455
+ public async runDrizzleKitCommand(options: {
456
+ root: string;
457
+ args?: string;
458
+ command: string;
459
+ logMessage: (providerName: string, dialect: string) => string;
460
+ }): Promise<void> {
461
+ const rootDir = options.root;
462
+ this.log.debug(`Using project root: ${rootDir}`);
463
+
464
+ const { alepha, entry } = await this.loadAlephaFromServerEntryFile(
465
+ rootDir,
466
+ options.args,
467
+ );
468
+
469
+ const kit = this.getKitFromAlepha(alepha);
470
+ const repositoryProvider =
471
+ alepha.inject<RepositoryProvider>("RepositoryProvider");
472
+ const accepted = new Set<string>([]);
473
+
474
+ for (const descriptor of repositoryProvider.getRepositories()) {
475
+ const provider = descriptor.provider;
476
+ const providerName = provider.name;
477
+ const dialect = provider.dialect;
478
+
479
+ if (accepted.has(providerName)) {
480
+ continue;
481
+ }
482
+ accepted.add(providerName);
483
+
484
+ this.log.info("");
485
+ this.log.info(options.logMessage(providerName, dialect));
486
+
487
+ const drizzleConfigJsPath = await this.prepareDrizzleConfig({
488
+ kit,
489
+ provider,
490
+ providerName,
491
+ providerUrl: provider.url,
492
+ dialect,
493
+ entry,
494
+ rootDir,
495
+ });
496
+
497
+ await this.runner.exec(
498
+ `drizzle-kit ${options.command} --config=${drizzleConfigJsPath}`,
499
+ );
500
+ }
501
+ }
502
+ }
503
+
504
+ export interface DependencyModes {
505
+ api?: boolean;
506
+ react?: boolean;
507
+ orm?: boolean;
508
+ }