@getstrata/cli 1.1.1 → 1.1.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/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # @getstrata/cli changelog
2
2
 
3
+ ## 1.1.3
4
+
5
+ - Eager with() arrays, OpenAPI mkdir, public-read/auth docs
6
+
7
+ ## 1.1.2
8
+
9
+ - Product CLI helpers, file migrations, and job discovery.
10
+
11
+ ## Unreleased
12
+
13
+ - Export `queueWorker`, `queueFailed`, `scaffold`, `openapi`, and `schedule` subpaths. Generated apps register `queue:work` through `runQueueWorkerCommand({ boot, close })` (no secrets guard in the helper), plus failed-job commands, `make:*`, `openapi:*`, and `schedule:run`. Monorepo `queue:failed` / `retry` / `flush-failed` go through `createQueueFailedCommands()` so they type as `StrataCommand`. `boot` may return a value (`bootstrapApp()` returns the app).
14
+ - `make:job` emits `static jobName`. `make:request` imports `@getstrata/core/http`. `make:module --with-web` writes `views/` when that directory exists.
15
+ - Peer dependencies on `@getstrata/core` and `@getstrata/bootstrap`.
16
+
3
17
  ## 1.1.1
4
18
 
5
19
  Fix generated app boot
package/README.md CHANGED
@@ -19,6 +19,8 @@ strata help
19
19
 
20
20
  `strata start` does not migrate when `APP_ENV=production`. Run `strata migrate` as an explicit deploy step.
21
21
 
22
+ App commands from `src/cli/register.ts` merge with this list. Generated apps add `queue:work`, failed-job commands, `make:*`, `openapi:*`, and `schedule:run`.
23
+
22
24
  ## Running it
23
25
 
24
26
  This package installs into your app, not globally, so `strata` lands in `node_modules/.bin` and is not on your `PATH`. Use the scripts a generated app already ships:
@@ -49,9 +51,9 @@ From the current working directory, `strata` loads `strata.config.ts` if present
49
51
  | `migrate` | `src/db/migrate.ts` |
50
52
  | `fresh` | `src/db/fresh.ts` |
51
53
 
52
- `src/cli/register.ts` can export `commands` or `registerCommands()` to add your own commands. Generated apps do not ship one.
54
+ `src/cli/register.ts` can export `commands` or `registerCommands()` to add your own commands. Generated apps ship `queue:work`, which boots the app (`bootstrapApp` / `createApp`) rather than the monorepo provider stack, plus `make:*`, failed-job commands, `openapi:*`, and `schedule:run`.
53
55
 
54
- The published `strata` binary does **not** include monorepo codegen (`make:module`, `make:migration`, `make:job`, `openapi:*`, `queue:work`, `schedule:run`, …). Those commands ship with the [Strata framework repo](https://github.com/EyK-26/strata) CLI (`bun run cli …` when developing the framework). Product apps can copy `src/cli/register.ts` from that repo or implement their own registrars. Scaffold commands resolve paths from the app working directory (`src/modules`, `src/db/migrations`, `src/jobs`). See [docs/BUILDING-APPS.md](https://github.com/EyK-26/strata/blob/main/docs/BUILDING-APPS.md#extending-the-cli).
56
+ Helpers live on package subpaths: `@getstrata/cli/queueWorker`, `@getstrata/cli/queueFailed`, `@getstrata/cli/scaffold`, `@getstrata/cli/openapi`, and `@getstrata/cli/schedule`. `queue:work` takes `{ boot, close }` and does not call `assertProductionSecrets()` (that belongs in app boot). Scaffold commands resolve paths from the app working directory (`src/modules`, `src/db/migrations`, `src/jobs`, `views/` or `resources/views`). See [docs/BUILDING-APPS.md](https://github.com/EyK-26/strata/blob/main/docs/BUILDING-APPS.md#extending-the-cli).
55
57
 
56
58
  A migrate entry may export `close()`. The CLI calls it after `migrate()` and `fresh()` so pooled drivers such as `mysql2` release the event loop instead of hanging the command.
57
59
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getstrata/cli",
3
- "version": "1.1.1",
3
+ "version": "1.1.3",
4
4
  "description": "Strata CLI: framework commands for Bun apps (dev, start, migrate, run)",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -16,6 +16,26 @@
16
16
  ".": {
17
17
  "import": "./src/index.ts",
18
18
  "default": "./src/index.ts"
19
+ },
20
+ "./queueWorker": {
21
+ "import": "./src/queueWorker.ts",
22
+ "default": "./src/queueWorker.ts"
23
+ },
24
+ "./queueFailed": {
25
+ "import": "./src/queueFailed.ts",
26
+ "default": "./src/queueFailed.ts"
27
+ },
28
+ "./scaffold": {
29
+ "import": "./src/scaffold/index.ts",
30
+ "default": "./src/scaffold/index.ts"
31
+ },
32
+ "./openapi": {
33
+ "import": "./src/openapi.ts",
34
+ "default": "./src/openapi.ts"
35
+ },
36
+ "./schedule": {
37
+ "import": "./src/schedule.ts",
38
+ "default": "./src/schedule.ts"
19
39
  }
20
40
  },
21
41
  "files": [
@@ -25,6 +45,10 @@
25
45
  "CHANGELOG.md"
26
46
  ],
27
47
  "scripts": {},
48
+ "peerDependencies": {
49
+ "@getstrata/bootstrap": "^1.1.1",
50
+ "@getstrata/core": "^1.1.1"
51
+ },
28
52
  "publishConfig": {
29
53
  "access": "public"
30
54
  },
package/src/openapi.ts ADDED
@@ -0,0 +1,89 @@
1
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
+ import { registerOpenApiRouteMap } from "@getstrata/bootstrap/buildModuleRoutes";
4
+ import { routeRegistry } from "@getstrata/bootstrap/routeRegistry";
5
+ import { generateOpenApiSpec, renderOpenApiDocument } from "@getstrata/core/openapi/generator";
6
+ import { validateOpenApiSpec } from "@getstrata/core/openapi/validate";
7
+
8
+ type AppBootstrap = () => Promise<{ routes: Record<string, unknown> }>;
9
+
10
+ function withoutSpaCatchAll(routes: Record<string, unknown>): Record<string, unknown> {
11
+ const next = { ...routes };
12
+ delete next["/*"];
13
+ return next;
14
+ }
15
+
16
+ async function registerAppOpenApiRoutes(bootstrap: AppBootstrap): Promise<void> {
17
+ const { routes } = await bootstrap();
18
+ routeRegistry.clear();
19
+ registerOpenApiRouteMap(withoutSpaCatchAll(routes), ["global", "api"]);
20
+ }
21
+
22
+ function createOpenApiGenerateCommand(bootstrap: AppBootstrap) {
23
+ return async function openapiGenerateCommand(): Promise<void> {
24
+ await registerAppOpenApiRoutes(bootstrap);
25
+
26
+ const spec = generateOpenApiSpec(routeRegistry.list());
27
+ const jsonPath = join(process.cwd(), "docs/openapi.json");
28
+ await mkdir(dirname(jsonPath), { recursive: true });
29
+ await writeFile(jsonPath, renderOpenApiDocument(spec), "utf8");
30
+
31
+ console.log(`OpenAPI spec written to ${jsonPath} (${routeRegistry.list().length} routes).`);
32
+ };
33
+ }
34
+
35
+ function createOpenApiValidateCommand(bootstrap: AppBootstrap) {
36
+ return async function openapiValidateCommand(): Promise<void> {
37
+ await registerAppOpenApiRoutes(bootstrap);
38
+
39
+ const spec = generateOpenApiSpec(routeRegistry.list());
40
+ const errors = validateOpenApiSpec(spec);
41
+
42
+ if (errors.length > 0) {
43
+ console.error("OpenAPI validation failed:");
44
+ for (const error of errors) {
45
+ console.error(`- ${error}`);
46
+ }
47
+ process.exit(1);
48
+ }
49
+
50
+ console.log(`OpenAPI spec valid (${routeRegistry.list().length} routes).`);
51
+ };
52
+ }
53
+
54
+ function createOpenApiCheckCommand(bootstrap: AppBootstrap) {
55
+ return async function openapiCheckCommand(): Promise<void> {
56
+ await registerAppOpenApiRoutes(bootstrap);
57
+
58
+ const spec = generateOpenApiSpec(routeRegistry.list());
59
+ const errors = validateOpenApiSpec(spec);
60
+
61
+ if (errors.length > 0) {
62
+ console.error("OpenAPI validation failed:");
63
+ for (const error of errors) {
64
+ console.error(`- ${error}`);
65
+ }
66
+ process.exit(1);
67
+ }
68
+
69
+ const generated = renderOpenApiDocument(spec);
70
+ const jsonPath = join(process.cwd(), "docs/openapi.json");
71
+ const committed = await readFile(jsonPath, "utf8");
72
+
73
+ if (committed !== generated) {
74
+ console.error("OpenAPI spec drift detected.");
75
+ console.error("Run `strata openapi:generate` and commit docs/openapi.json.");
76
+ process.exit(1);
77
+ }
78
+
79
+ console.log(`OpenAPI spec matches committed file (${routeRegistry.list().length} routes).`);
80
+ };
81
+ }
82
+
83
+ export type { AppBootstrap };
84
+ export {
85
+ createOpenApiCheckCommand,
86
+ createOpenApiGenerateCommand,
87
+ createOpenApiValidateCommand,
88
+ registerAppOpenApiRoutes,
89
+ };
@@ -0,0 +1,74 @@
1
+ import { createFailedJobService } from "@getstrata/core/queue/createAppQueue";
2
+ import { jobRegistry } from "@getstrata/core/queue/jobRegistry";
3
+ import { runQueueJob } from "@getstrata/core/queue/jobRunner";
4
+ import type { StrataCommand } from "./types.ts";
5
+
6
+ type QueueFailedBoot = () => unknown | Promise<unknown>;
7
+
8
+ async function queueFailedCommand(boot?: QueueFailedBoot): Promise<void> {
9
+ await boot?.();
10
+ const failedJobs = createFailedJobService();
11
+ const jobs = await failedJobs.listRecent();
12
+
13
+ if (jobs.length === 0) {
14
+ console.log("No failed jobs.");
15
+ return;
16
+ }
17
+
18
+ for (const job of jobs) {
19
+ console.log(`#${job.id} ${job.job_name} failed at ${job.failed_at.toISOString()}`);
20
+ }
21
+ }
22
+
23
+ async function queueRetryCommand(id?: string, boot?: QueueFailedBoot): Promise<void> {
24
+ if (!id) {
25
+ throw new Error("queue:retry requires a failed job id.");
26
+ }
27
+
28
+ await boot?.();
29
+ const failedJobs = createFailedJobService();
30
+ const failedJob = await failedJobs.retry(Number.parseInt(id, 10));
31
+
32
+ const job = jobRegistry.create(failedJob.job_name);
33
+
34
+ if (!job) {
35
+ throw new Error(`Unknown job "${failedJob.job_name}".`);
36
+ }
37
+
38
+ await runQueueJob(
39
+ {
40
+ name: failedJob.job_name,
41
+ payload: failedJob.payload,
42
+ attempts: 0,
43
+ },
44
+ failedJobs,
45
+ );
46
+
47
+ console.log(`Retried failed job #${id}.`);
48
+ }
49
+
50
+ async function queueFlushFailedCommand(boot?: QueueFailedBoot): Promise<void> {
51
+ await boot?.();
52
+ const deleted = await createFailedJobService().flush();
53
+ console.log(`Removed ${deleted} failed job(s).`);
54
+ }
55
+
56
+ function createQueueFailedCommands(boot?: QueueFailedBoot): {
57
+ queueFailedCommand: StrataCommand;
58
+ queueRetryCommand: StrataCommand;
59
+ queueFlushFailedCommand: StrataCommand;
60
+ } {
61
+ return {
62
+ queueFailedCommand: async () => queueFailedCommand(boot),
63
+ queueRetryCommand: async (id?: string) => queueRetryCommand(id, boot),
64
+ queueFlushFailedCommand: async () => queueFlushFailedCommand(boot),
65
+ };
66
+ }
67
+
68
+ export type { QueueFailedBoot };
69
+ export {
70
+ createQueueFailedCommands,
71
+ queueFailedCommand,
72
+ queueFlushFailedCommand,
73
+ queueRetryCommand,
74
+ };
@@ -0,0 +1,45 @@
1
+ import {
2
+ installGracefulShutdownSignals,
3
+ registerShutdownHandler,
4
+ } from "@getstrata/core/lifecycle/gracefulShutdown";
5
+ import { createFailedJobService, createQueueWorker } from "@getstrata/core/queue/createAppQueue";
6
+
7
+ type QueueWorkerBoot = () => unknown | Promise<unknown>;
8
+ type QueueWorkerClose = () => unknown | Promise<unknown>;
9
+
10
+ async function runQueueWorkerCommand(options: {
11
+ boot: QueueWorkerBoot;
12
+ close?: QueueWorkerClose;
13
+ failedJobs?: ReturnType<typeof createFailedJobService>;
14
+ }): Promise<void> {
15
+ const redisUrl = process.env.REDIS_URL;
16
+
17
+ if (!redisUrl) {
18
+ throw new Error("queue:work requires REDIS_URL to be set.");
19
+ }
20
+
21
+ await options.boot();
22
+
23
+ const failedJobs = options.failedJobs ?? createFailedJobService();
24
+
25
+ console.log("[queue:work] Listening for jobs on Redis...");
26
+ const worker = createQueueWorker(redisUrl, failedJobs);
27
+
28
+ registerShutdownHandler("queue-worker", async () => {
29
+ worker.requestStop();
30
+ });
31
+
32
+ if (options.close) {
33
+ registerShutdownHandler("database", async () => {
34
+ await options.close?.();
35
+ });
36
+ }
37
+
38
+ installGracefulShutdownSignals();
39
+
40
+ await worker.run();
41
+ console.log("[queue:work] Worker stopped.");
42
+ }
43
+
44
+ export type { QueueWorkerBoot, QueueWorkerClose };
45
+ export { runQueueWorkerCommand };
@@ -0,0 +1,47 @@
1
+ import type { StrataCommandMap } from "../types.ts";
2
+ import { makeFactoryCommand } from "./makeFactory.ts";
3
+ import { makeJobCommand } from "./makeJob.ts";
4
+ import { makeListenerCommand } from "./makeListener.ts";
5
+ import { makeMigrationCommand } from "./makeMigration.ts";
6
+ import { makeModuleCommand } from "./makeModule.ts";
7
+ import { makePolicyCommand } from "./makePolicy.ts";
8
+ import { makeRequestCommand } from "./makeRequest.ts";
9
+ import {
10
+ ensureDirectory,
11
+ migrationDirectory,
12
+ moduleDirectory,
13
+ timestampForFilename,
14
+ toCamelCase,
15
+ toKebabCase,
16
+ toPascalCase,
17
+ webViewsDirectory,
18
+ } from "./utils.ts";
19
+
20
+ const scaffoldCommands: StrataCommandMap = {
21
+ "make:module": async () => makeModuleCommand,
22
+ "make:policy": async () => makePolicyCommand,
23
+ "make:job": async () => makeJobCommand,
24
+ "make:listener": async () => makeListenerCommand,
25
+ "make:request": async () => makeRequestCommand,
26
+ "make:factory": async () => makeFactoryCommand,
27
+ "make:migration": async () => makeMigrationCommand,
28
+ };
29
+
30
+ export {
31
+ ensureDirectory,
32
+ makeFactoryCommand,
33
+ makeJobCommand,
34
+ makeListenerCommand,
35
+ makeMigrationCommand,
36
+ makeModuleCommand,
37
+ makePolicyCommand,
38
+ makeRequestCommand,
39
+ migrationDirectory,
40
+ moduleDirectory,
41
+ scaffoldCommands,
42
+ timestampForFilename,
43
+ toCamelCase,
44
+ toKebabCase,
45
+ toPascalCase,
46
+ webViewsDirectory,
47
+ };
@@ -0,0 +1,48 @@
1
+ import { join } from "node:path";
2
+ import { ensureDirectory, moduleDirectory, toCamelCase, toKebabCase, toPascalCase } from "./utils";
3
+
4
+ async function makeFactoryCommand(name?: string): Promise<void> {
5
+ if (!name) {
6
+ throw new Error("make:factory requires a model name.");
7
+ }
8
+
9
+ const moduleSlug = toKebabCase(name);
10
+ const modelName = toPascalCase(name);
11
+ const moduleIdentifier = toCamelCase(name);
12
+ const directory = moduleDirectory(moduleSlug);
13
+ const filePath = join(directory, "factory.ts");
14
+
15
+ await ensureDirectory(directory);
16
+
17
+ const exists = await Bun.file(filePath).exists();
18
+ if (exists) {
19
+ throw new Error(`Factory already exists: ${filePath}`);
20
+ }
21
+
22
+ const content = `import { Factory } from "@getstrata/core/database/factory";
23
+ import type { ${modelName}Record } from "./types";
24
+
25
+ class ${modelName}Factory extends Factory<${modelName}Record> {
26
+ protected definition(): ${modelName}Record {
27
+ const now = new Date();
28
+
29
+ return {
30
+ id: 0,
31
+ } satisfies Partial<${modelName}Record> as ${modelName}Record;
32
+ }
33
+
34
+ protected persist(values: Partial<${modelName}Record>): Promise<${modelName}Record> {
35
+ throw new Error("${modelName}Factory.persist() is not implemented.");
36
+ }
37
+ }
38
+
39
+ const ${moduleIdentifier}Factory = new ${modelName}Factory();
40
+
41
+ export { ${modelName}Factory, ${moduleIdentifier}Factory };
42
+ `;
43
+
44
+ await Bun.write(filePath, content);
45
+ console.log(`Created factory: ${filePath}`);
46
+ }
47
+
48
+ export { makeFactoryCommand };
@@ -0,0 +1,53 @@
1
+ import { access, mkdir } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { toKebabCase, toPascalCase } from "./utils";
4
+
5
+ async function makeJobCommand(name?: string): Promise<void> {
6
+ if (!name) {
7
+ throw new Error("make:job requires a job name.");
8
+ }
9
+
10
+ const jobSlug = toKebabCase(name);
11
+ const jobClass = `${toPascalCase(name)}Job`;
12
+ const payloadType = `${toPascalCase(name)}Payload`;
13
+ const directory = join(process.cwd(), "src", "jobs");
14
+ const jobPath = join(directory, `${jobSlug}Job.ts`);
15
+
16
+ await mkdir(directory, { recursive: true });
17
+
18
+ try {
19
+ await access(jobPath);
20
+ throw new Error(`Job already exists: ${jobPath}`);
21
+ } catch (error) {
22
+ if (error instanceof Error && error.message.startsWith("Job already exists:")) {
23
+ throw error;
24
+ }
25
+ }
26
+
27
+ const content = `import { Job } from "@getstrata/core/queue";
28
+
29
+ interface ${payloadType} {
30
+ }
31
+
32
+ class ${jobClass} extends Job<${payloadType}> {
33
+ static readonly jobName = "${jobSlug}";
34
+
35
+ override async handle(payload: ${payloadType}): Promise<void> {
36
+ void payload;
37
+ }
38
+ }
39
+
40
+ export default ${jobClass};
41
+ export type { ${payloadType} };
42
+ `;
43
+
44
+ await Bun.write(jobPath, content);
45
+
46
+ console.log(`Created job in: ${jobPath}`);
47
+ console.log(
48
+ `Dispatch it via queue.dispatch(new ${jobClass}(), payload) from your controller or listener.`,
49
+ );
50
+ console.log(`Workers discover it from src/jobs/ as "${jobSlug}".`);
51
+ }
52
+
53
+ export { makeJobCommand };
@@ -0,0 +1,45 @@
1
+ import { access, mkdir } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { toCamelCase, toKebabCase, toPascalCase } from "./utils";
4
+
5
+ async function makeListenerCommand(name?: string, eventName?: string): Promise<void> {
6
+ if (!name) {
7
+ throw new Error("make:listener requires a listener name.");
8
+ }
9
+
10
+ const listenerSlug = toKebabCase(name);
11
+ const registerFunction = `register${toPascalCase(name)}Listener`;
12
+ const directory = join(process.cwd(), "src", "listeners");
13
+ const listenerPath = join(directory, `${listenerSlug}.ts`);
14
+ const resolvedEventName = eventName ?? `${toCamelCase(name)}.created`;
15
+
16
+ await mkdir(directory, { recursive: true });
17
+
18
+ try {
19
+ await access(listenerPath);
20
+ throw new Error(`Listener already exists: ${listenerPath}`);
21
+ } catch (error) {
22
+ if (error instanceof Error && error.message.startsWith("Listener already exists:")) {
23
+ throw error;
24
+ }
25
+ }
26
+
27
+ const content = `import { eventBus } from "@getstrata/core/events";
28
+
29
+ function ${registerFunction}(): void {
30
+ eventBus.listen("${resolvedEventName}", async (payload) => {
31
+ void payload;
32
+ });
33
+ }
34
+
35
+ export default ${registerFunction};
36
+ `;
37
+
38
+ await Bun.write(listenerPath, content);
39
+
40
+ console.log(`Created listener in: ${listenerPath}`);
41
+ console.log(`Listening for event: ${resolvedEventName}`);
42
+ console.log("It will be auto-discovered from src/listeners/ on the next app boot.");
43
+ }
44
+
45
+ export { makeListenerCommand };
@@ -0,0 +1,58 @@
1
+ import { access } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { ensureDirectory, migrationDirectory, timestampForFilename, toKebabCase } from "./utils";
4
+
5
+ async function makeMigrationCommand(name?: string): Promise<void> {
6
+ if (!name) {
7
+ throw new Error("make:migration requires a name.");
8
+ }
9
+
10
+ const normalizedName = toKebabCase(name).replace(/-/g, "_");
11
+ if (!normalizedName) {
12
+ throw new Error("make:migration requires a valid migration name.");
13
+ }
14
+
15
+ const fileBaseName = `${timestampForFilename()}_${normalizedName}`;
16
+ const directory = migrationDirectory();
17
+ const filePath = join(directory, `${fileBaseName}.ts`);
18
+
19
+ await ensureDirectory(directory);
20
+
21
+ try {
22
+ await access(filePath);
23
+ throw new Error(`Migration already exists: ${filePath}`);
24
+ } catch (error) {
25
+ if (!(error instanceof Error) || !("code" in error) || error.code !== "ENOENT") {
26
+ if (error instanceof Error && error.message.startsWith("Migration already exists:")) {
27
+ throw error;
28
+ }
29
+ throw error;
30
+ }
31
+ }
32
+
33
+ const content = `import type { Migration } from "@getstrata/core/database/migrations/types";
34
+
35
+ const migration: Migration = {
36
+ name: "${fileBaseName}",
37
+ async up(db) {
38
+ await db.unsafe(\`
39
+ -- Write SQL for ${fileBaseName}
40
+ SELECT 1
41
+ \`);
42
+ },
43
+ async down(db) {
44
+ await db.unsafe(\`
45
+ -- Roll back ${fileBaseName}
46
+ SELECT 1
47
+ \`);
48
+ },
49
+ };
50
+
51
+ export default migration;
52
+ `;
53
+
54
+ await Bun.write(filePath, content);
55
+ console.log(`Created migration: ${filePath}`);
56
+ }
57
+
58
+ export { makeMigrationCommand };
@@ -0,0 +1,592 @@
1
+ import { access } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import {
4
+ ensureDirectory,
5
+ moduleDirectory,
6
+ toCamelCase,
7
+ toKebabCase,
8
+ toPascalCase,
9
+ webViewsDirectory,
10
+ } from "./utils";
11
+
12
+ async function makeModuleCommand(...args: string[]): Promise<void> {
13
+ const withWeb = args.includes("--with-web");
14
+ const name = args.find((arg) => !arg.startsWith("--"));
15
+
16
+ if (!name) {
17
+ throw new Error("make:module requires a name.");
18
+ }
19
+
20
+ const moduleSlug = toKebabCase(name);
21
+ const moduleName = toPascalCase(name);
22
+ const moduleIdentifier = toCamelCase(name);
23
+ const moduleVariable = `${moduleIdentifier}Module`;
24
+
25
+ if (!moduleName || !moduleIdentifier || !moduleSlug) {
26
+ throw new Error("make:module requires a valid module name.");
27
+ }
28
+
29
+ const directory = moduleDirectory(moduleSlug);
30
+
31
+ try {
32
+ await access(directory);
33
+ throw new Error(`Module already exists: ${directory}`);
34
+ } catch (error) {
35
+ if (!(error instanceof Error) || !("code" in error) || error.code !== "ENOENT") {
36
+ if (error instanceof Error && error.message.startsWith("Module already exists:")) {
37
+ throw error;
38
+ }
39
+ throw error;
40
+ }
41
+ }
42
+
43
+ await ensureDirectory(directory);
44
+
45
+ const pluralSlug = `${moduleSlug}s`;
46
+
47
+ const files = new Map<string, string>([
48
+ [
49
+ "types.ts",
50
+ `interface ${moduleName}Record {
51
+ id: number;
52
+ name: string;
53
+ }
54
+
55
+ export type { ${moduleName}Record };
56
+ `,
57
+ ],
58
+ [
59
+ "table.ts",
60
+ `import { defineTable } from "@getstrata/core/database/table";
61
+ import type { ${moduleName}Record } from "./types";
62
+
63
+ const ${moduleIdentifier}Table = defineTable<${moduleName}Record, "id">({
64
+ name: "${moduleSlug}",
65
+ primaryKey: "id",
66
+ columns: ["id", "name"],
67
+ defaultOrderBy: { column: "id", direction: "ASC" },
68
+ });
69
+
70
+ export { ${moduleIdentifier}Table };
71
+ `,
72
+ ],
73
+ [
74
+ "repository.ts",
75
+ `import { BaseRepository } from "@getstrata/core/database/baseRepository";
76
+ import { ${moduleIdentifier}Table } from "./table";
77
+ import type { ${moduleName}Record } from "./types";
78
+
79
+ class ${moduleName}Repository extends BaseRepository<${moduleName}Record, "id"> {
80
+ constructor() {
81
+ super(${moduleIdentifier}Table);
82
+ }
83
+ }
84
+
85
+ export default ${moduleName}Repository;
86
+ `,
87
+ ],
88
+ [
89
+ "service.ts",
90
+ `import ${moduleName}Repository from "./repository";
91
+ import type { ${moduleName}Record } from "./types";
92
+ import type { PaginatedResult } from "@getstrata/core/pagination";
93
+
94
+ class ${moduleName}Service {
95
+ constructor(private readonly repository: ${moduleName}Repository) {}
96
+
97
+ paginate(options: {
98
+ page: number;
99
+ perPage: number;
100
+ }): Promise<PaginatedResult<${moduleName}Record>> {
101
+ return this.repository.paginate(options);
102
+ }
103
+
104
+ findByIdOrThrow(id: number): Promise<${moduleName}Record> {
105
+ return this.repository.findByIdOrThrow(id);
106
+ }
107
+
108
+ create(input: { name: string }): Promise<${moduleName}Record> {
109
+ return this.repository.create(input);
110
+ }
111
+
112
+ update(id: number, input: { name?: string }): Promise<${moduleName}Record> {
113
+ return this.repository.update(id, input);
114
+ }
115
+
116
+ delete(id: number): Promise<void> {
117
+ return this.repository.delete(id);
118
+ }
119
+ }
120
+
121
+ export default ${moduleName}Service;
122
+ `,
123
+ ],
124
+ [
125
+ "policy.ts",
126
+ `import type { AuthUser } from "@getstrata/core/auth/authContext";
127
+ import { Policy } from "@getstrata/core/auth/policy";
128
+ import type { ${moduleName}Record } from "./types";
129
+
130
+ class ${moduleName}Policy extends Policy {
131
+ override create(_user: AuthUser | null): boolean {
132
+ return true;
133
+ }
134
+
135
+ override update(_user: AuthUser | null, _resource: ${moduleName}Record): boolean {
136
+ return true;
137
+ }
138
+
139
+ override delete(user: AuthUser | null, _resource: ${moduleName}Record): boolean {
140
+ return user?.role === "admin" || user?.role === "member";
141
+ }
142
+ }
143
+
144
+ export default ${moduleName}Policy;
145
+ `,
146
+ ],
147
+ [
148
+ "provider.ts",
149
+ `import type { ServiceProvider } from "@getstrata/core/contracts/di";
150
+ import { CORE_POLICY_GATE_TOKEN } from "@getstrata/bootstrap/config";
151
+ import ${moduleName}Repository from "./repository";
152
+ import ${moduleName}Service from "./service";
153
+ import ${moduleName}Policy from "./policy";
154
+
155
+ const ${moduleIdentifier}RepositoryToken = "${moduleSlug}.repository";
156
+ const ${moduleIdentifier}ServiceToken = "${moduleSlug}.service";
157
+ const ${moduleIdentifier}PolicyToken = "${moduleSlug}.policy";
158
+
159
+ const ${moduleIdentifier}Provider: ServiceProvider = {
160
+ name: "${moduleSlug}.provider",
161
+ register({ container }) {
162
+ container.singleton(${moduleIdentifier}RepositoryToken, () => new ${moduleName}Repository());
163
+ container.singleton(${moduleIdentifier}PolicyToken, () => new ${moduleName}Policy());
164
+ },
165
+ boot({ container }) {
166
+ container.singleton(${moduleIdentifier}ServiceToken, () => {
167
+ const repository = container.resolve<${moduleName}Repository>(${moduleIdentifier}RepositoryToken);
168
+ return new ${moduleName}Service(repository);
169
+ });
170
+
171
+ const gate = container.resolve<{ register: (resource: string, policy: unknown) => void }>(
172
+ CORE_POLICY_GATE_TOKEN,
173
+ );
174
+ gate.register("${moduleSlug}", container.resolve(${moduleIdentifier}PolicyToken));
175
+ },
176
+ };
177
+
178
+ export default ${moduleIdentifier}Provider;
179
+ export {
180
+ ${moduleIdentifier}PolicyToken,
181
+ ${moduleIdentifier}RepositoryToken,
182
+ ${moduleIdentifier}ServiceToken,
183
+ };
184
+ `,
185
+ ],
186
+ [
187
+ "requests.ts",
188
+ `import { parseJsonBody, parsePositiveIntParam } from "@getstrata/core/http/validation";
189
+ import { parsePaginationQuery } from "@getstrata/core/http/pagination";
190
+ import {
191
+ maxLength,
192
+ minLength,
193
+ required,
194
+ stringRule,
195
+ validateObject,
196
+ } from "@getstrata/core/validation/rules";
197
+
198
+ type ${moduleName}IdParams = { id: string };
199
+
200
+ interface ${moduleName}ListQueryDto {
201
+ page: number;
202
+ perPage: number;
203
+ }
204
+
205
+ interface Create${moduleName}BodyDto {
206
+ name: string;
207
+ }
208
+
209
+ interface Update${moduleName}BodyDto {
210
+ name?: string;
211
+ }
212
+
213
+ function parse${moduleName}IdParams(params: ${moduleName}IdParams): { id: number } {
214
+ return {
215
+ id: parsePositiveIntParam(params.id, "${moduleSlug} id"),
216
+ };
217
+ }
218
+
219
+ function parse${moduleName}ListQuery(request?: Request): ${moduleName}ListQueryDto {
220
+ return parsePaginationQuery(request);
221
+ }
222
+
223
+ async function parseCreate${moduleName}Body(
224
+ request: Request,
225
+ ): Promise<Create${moduleName}BodyDto> {
226
+ return await parseJsonBody(request, (payload) => {
227
+ const body = validateObject(payload, {
228
+ name: [required(), stringRule(), minLength(1), maxLength(120)],
229
+ });
230
+
231
+ return {
232
+ name: body.name as string,
233
+ };
234
+ });
235
+ }
236
+
237
+ async function parseUpdate${moduleName}Body(
238
+ request: Request,
239
+ ): Promise<Update${moduleName}BodyDto> {
240
+ return await parseJsonBody(request, (payload) => {
241
+ const body = validateObject(payload, {
242
+ name: [stringRule(), minLength(1), maxLength(120)],
243
+ });
244
+
245
+ return {
246
+ ...(body.name === undefined ? {} : { name: body.name as string }),
247
+ };
248
+ });
249
+ }
250
+
251
+ export {
252
+ parseCreate${moduleName}Body,
253
+ parse${moduleName}IdParams,
254
+ parse${moduleName}ListQuery,
255
+ parseUpdate${moduleName}Body,
256
+ };
257
+ export type {
258
+ Create${moduleName}BodyDto,
259
+ ${moduleName}IdParams,
260
+ ${moduleName}ListQueryDto,
261
+ Update${moduleName}BodyDto,
262
+ };
263
+ `,
264
+ ],
265
+ [
266
+ "resources.ts",
267
+ `import { toPaginatedResourceCollection, toResourceCollection } from "@getstrata/core/http/resources";
268
+ import type { PaginationMeta } from "@getstrata/core/pagination";
269
+ import type { ${moduleName}Record } from "./types";
270
+
271
+ interface ${moduleName}Resource {
272
+ id: number;
273
+ name: string;
274
+ }
275
+
276
+ function to${moduleName}Resource(record: ${moduleName}Record): ${moduleName}Resource {
277
+ return {
278
+ id: record.id,
279
+ name: record.name,
280
+ };
281
+ }
282
+
283
+ function to${moduleName}ResourceCollection(
284
+ records: readonly ${moduleName}Record[],
285
+ ): ${moduleName}Resource[] {
286
+ return toResourceCollection(records, to${moduleName}Resource);
287
+ }
288
+
289
+ function to${moduleName}PaginatedResourceCollection(
290
+ records: readonly ${moduleName}Record[],
291
+ meta: PaginationMeta,
292
+ ) {
293
+ return toPaginatedResourceCollection(records, meta, to${moduleName}Resource);
294
+ }
295
+
296
+ export {
297
+ to${moduleName}PaginatedResourceCollection,
298
+ to${moduleName}Resource,
299
+ to${moduleName}ResourceCollection,
300
+ };
301
+ export type { ${moduleName}Resource };
302
+ `,
303
+ ],
304
+ [
305
+ "controller.ts",
306
+ `import type { AppDependencies, CachedJson } from "@getstrata/core/contracts/di";
307
+ import { resolveService } from "@getstrata/core/contracts/di";
308
+ import { bindRouteModel } from "@getstrata/core/http/routeModelBinding";
309
+ import { buildRequestCacheKey } from "@getstrata/core/http/validation";
310
+ import { securedBindRouteModel } from "@getstrata/core/http/securedRouteModelBinding";
311
+ import type { RouteRequest } from "@getstrata/core/http/route";
312
+ import { createdResponse, jsonResponse, noContentResponse, withErrorHandling } from "@getstrata/core/http/response";
313
+ import ${moduleName}Service from "./service";
314
+ import { ${moduleIdentifier}ServiceToken } from "./provider";
315
+ import {
316
+ parseCreate${moduleName}Body,
317
+ parse${moduleName}ListQuery,
318
+ parseUpdate${moduleName}Body,
319
+ type ${moduleName}IdParams,
320
+ } from "./requests";
321
+ import {
322
+ to${moduleName}PaginatedResourceCollection,
323
+ to${moduleName}Resource,
324
+ } from "./resources";
325
+
326
+ const ${moduleIdentifier.toUpperCase()}_CACHE_TAG = "${pluralSlug}";
327
+
328
+ class ${moduleName}Controller {
329
+ constructor(
330
+ private readonly dependencies: AppDependencies,
331
+ private readonly cachedJson: CachedJson,
332
+ ) {}
333
+
334
+ private get service(): ${moduleName}Service {
335
+ return resolveService(this.dependencies, ${moduleIdentifier}ServiceToken);
336
+ }
337
+
338
+ readonly index = withErrorHandling(async (request?: Request) => {
339
+ const query = parse${moduleName}ListQuery(request);
340
+ const cacheKey = buildRequestCacheKey("/${pluralSlug}", request);
341
+
342
+ return await this.cachedJson(
343
+ cacheKey,
344
+ async () => {
345
+ const result = await this.service.paginate(query);
346
+ return to${moduleName}PaginatedResourceCollection(result.data, result.meta);
347
+ },
348
+ [${moduleIdentifier.toUpperCase()}_CACHE_TAG],
349
+ request,
350
+ );
351
+ });
352
+
353
+ readonly show = withErrorHandling(
354
+ bindRouteModel(
355
+ "id",
356
+ (id) => this.service.findByIdOrThrow(id),
357
+ async (_request, record) => {
358
+ return jsonResponse(to${moduleName}Resource(record));
359
+ },
360
+ ),
361
+ );
362
+
363
+ readonly store = withErrorHandling(async (request: Request) => {
364
+ const body = await parseCreate${moduleName}Body(request);
365
+ const record = await this.service.create(body);
366
+ return createdResponse(to${moduleName}Resource(record));
367
+ });
368
+
369
+ readonly update = withErrorHandling(
370
+ securedBindRouteModel(
371
+ "id",
372
+ (id) => this.service.findByIdOrThrow(id),
373
+ { resource: "${moduleSlug}", action: "update" },
374
+ async (req: RouteRequest<${moduleName}IdParams>, record) => {
375
+ const body = await parseUpdate${moduleName}Body(req);
376
+ const updated = await this.service.update(record.id, body);
377
+ return jsonResponse(to${moduleName}Resource(updated));
378
+ },
379
+ ),
380
+ );
381
+
382
+ readonly destroy = withErrorHandling(
383
+ securedBindRouteModel(
384
+ "id",
385
+ (id) => this.service.findByIdOrThrow(id),
386
+ { resource: "${moduleSlug}", action: "delete" },
387
+ async (_request, record) => {
388
+ await this.service.delete(record.id);
389
+ return noContentResponse();
390
+ },
391
+ ),
392
+ );
393
+ }
394
+
395
+ export default ${moduleName}Controller;
396
+ `,
397
+ ],
398
+ [
399
+ "routes.ts",
400
+ `import type { HttpKernel } from "@getstrata/bootstrap/httpKernel";
401
+ import type { AppDependencies, CachedJson } from "@getstrata/core/contracts/di";
402
+ import type { RouteHandler } from "@getstrata/core/http/middleware";
403
+ import ${moduleName}Controller from "./controller";
404
+
405
+ function create${moduleName}Routes(
406
+ dependencies: AppDependencies,
407
+ cachedJson: CachedJson,
408
+ kernel: HttpKernel,
409
+ ) {
410
+ const controller = new ${moduleName}Controller(dependencies, cachedJson);
411
+
412
+ return {
413
+ "/${pluralSlug}": {
414
+ GET: controller.index,
415
+ POST: kernel.wrapAbility(
416
+ "${pluralSlug}:create",
417
+ controller.store as unknown as RouteHandler,
418
+ ),
419
+ },
420
+ "/${pluralSlug}/:id": {
421
+ GET: controller.show,
422
+ PATCH: kernel.wrapAbility(
423
+ "${pluralSlug}:update",
424
+ controller.update as unknown as RouteHandler,
425
+ ),
426
+ DELETE: kernel.wrapAbility(
427
+ "${pluralSlug}:delete",
428
+ controller.destroy as unknown as RouteHandler,
429
+ ),
430
+ },
431
+ };
432
+ }
433
+
434
+ export { create${moduleName}Routes };
435
+ `,
436
+ ],
437
+ [
438
+ "index.ts",
439
+ `import { type AppModule } from "@getstrata/bootstrap/contracts";
440
+ import ${moduleName}Controller from "./controller";
441
+ import ${moduleIdentifier}Provider, {
442
+ ${moduleIdentifier}RepositoryToken,
443
+ ${moduleIdentifier}ServiceToken,
444
+ } from "./provider";
445
+ import { create${moduleName}Routes } from "./routes";${
446
+ withWeb
447
+ ? `
448
+ import { create${moduleName}WebRoutes } from "./webRoutes";`
449
+ : ""
450
+ }
451
+ import { ${moduleIdentifier}Table } from "./table";
452
+
453
+ const ${moduleVariable}: AppModule = {
454
+ name: "${moduleSlug}",
455
+ order: 100,
456
+ tableName: ${moduleIdentifier}Table.name,
457
+ cacheTags: ["${pluralSlug}"],
458
+ providers: [${moduleIdentifier}Provider],
459
+ routes({ dependencies, cachedJson, kernel }) {
460
+ return create${moduleName}Routes(dependencies, cachedJson, kernel);
461
+ },${
462
+ withWeb
463
+ ? `
464
+ webRoutes({ dependencies, kernel }) {
465
+ return create${moduleName}WebRoutes(dependencies, kernel);
466
+ },`
467
+ : ""
468
+ }
469
+ };
470
+
471
+ export default ${moduleVariable};
472
+ export {
473
+ ${moduleIdentifier}Provider,
474
+ ${moduleIdentifier}RepositoryToken,
475
+ ${moduleIdentifier}ServiceToken,
476
+ };
477
+ export { ${moduleName}Controller };
478
+ export { parse${moduleName}IdParams, parse${moduleName}ListQuery } from "./requests";
479
+ export {
480
+ to${moduleName}PaginatedResourceCollection,
481
+ to${moduleName}Resource,
482
+ to${moduleName}ResourceCollection,
483
+ } from "./resources";
484
+ export { create${moduleName}Routes } from "./routes";${
485
+ withWeb
486
+ ? `
487
+ export { create${moduleName}WebRoutes } from "./webRoutes";`
488
+ : ""
489
+ }
490
+ export { default as ${moduleName}Repository } from "./repository";
491
+ export { default as ${moduleName}Service } from "./service";
492
+ export { ${moduleIdentifier}Table } from "./table";
493
+ export type { ${moduleName}Record } from "./types";
494
+ `,
495
+ ],
496
+ ]);
497
+
498
+ for (const [fileName, content] of files) {
499
+ await Bun.write(join(directory, fileName), content);
500
+ }
501
+
502
+ let createdViewsDirectory: string | undefined;
503
+
504
+ if (withWeb) {
505
+ const viewsDirectory = webViewsDirectory(pluralSlug);
506
+ createdViewsDirectory = viewsDirectory;
507
+ await ensureDirectory(viewsDirectory);
508
+ await Bun.write(
509
+ join(viewsDirectory, "index.eta"),
510
+ `<section class="page-header">
511
+ <h1>${moduleName}s</h1>
512
+ </section>
513
+
514
+ <p class="hint">Generated web view for ${pluralSlug}. Wire up ${moduleName}WebController next.</p>
515
+ `,
516
+ );
517
+
518
+ await Bun.write(
519
+ join(directory, "webController.ts"),
520
+ `import type { AppDependencies } from "@getstrata/core/contracts/di";
521
+ import { resolveService } from "@getstrata/core/contracts/di";
522
+ import { CORE_VIEW_TOKEN } from "@getstrata/bootstrap/providers/view";
523
+ import { withErrorHandling } from "@getstrata/core/http/response";
524
+ import type { ViewEngine } from "@getstrata/core/view";
525
+ import { htmlResponse } from "@getstrata/core/view";
526
+ import { ${moduleIdentifier}ServiceToken } from "./provider";
527
+ import { parse${moduleName}ListQuery } from "./requests";
528
+ import type ${moduleName}Service from "./service";
529
+
530
+ class ${moduleName}WebController {
531
+ constructor(private readonly dependencies: AppDependencies) {}
532
+
533
+ private get service(): ${moduleName}Service {
534
+ return resolveService(this.dependencies, ${moduleIdentifier}ServiceToken);
535
+ }
536
+
537
+ private get view(): ViewEngine {
538
+ return resolveService(this.dependencies, CORE_VIEW_TOKEN);
539
+ }
540
+
541
+ readonly index = withErrorHandling(async (request?: Request) => {
542
+ const query = parse${moduleName}ListQuery(request);
543
+ const result = await this.service.paginate(query);
544
+
545
+ return htmlResponse(
546
+ await this.view.render("${pluralSlug}/index", {
547
+ title: "${moduleName}s",
548
+ records: result.data,
549
+ meta: result.meta,
550
+ }),
551
+ );
552
+ });
553
+ }
554
+
555
+ export default ${moduleName}WebController;
556
+ `,
557
+ );
558
+
559
+ await Bun.write(
560
+ join(directory, "webRoutes.ts"),
561
+ `import type { AppDependencies } from "@getstrata/core/contracts/di";
562
+ import type { HttpKernel } from "@getstrata/bootstrap/httpKernel";
563
+ import type { RouteHandler } from "@getstrata/core/http/middleware";
564
+ import ${moduleName}WebController from "./webController";
565
+
566
+ function create${moduleName}WebRoutes(dependencies: AppDependencies, kernel: HttpKernel) {
567
+ const controller = new ${moduleName}WebController(dependencies);
568
+
569
+ return {
570
+ "/${pluralSlug}": {
571
+ GET: kernel.wrapWebPublicRead(controller.index as unknown as RouteHandler),
572
+ },
573
+ };
574
+ }
575
+
576
+ export { create${moduleName}WebRoutes };
577
+ `,
578
+ );
579
+ }
580
+
581
+ console.log(`Created module scaffold in: ${directory}`);
582
+ if (createdViewsDirectory) {
583
+ const relativeViews = createdViewsDirectory.startsWith(process.cwd())
584
+ ? createdViewsDirectory.slice(process.cwd().length + 1)
585
+ : createdViewsDirectory;
586
+ console.log(`Created web view scaffold in: ${relativeViews}/`);
587
+ }
588
+ console.log(`Module will be auto-discovered from the app modules directory (${moduleSlug}/)`);
589
+ console.log(`Next: strata make:migration create_${moduleSlug} && strata migrate`);
590
+ }
591
+
592
+ export { makeModuleCommand };
@@ -0,0 +1,66 @@
1
+ import { access } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { moduleDirectory, toCamelCase, toKebabCase, toPascalCase } from "./utils";
4
+
5
+ async function makePolicyCommand(moduleName?: string): Promise<void> {
6
+ if (!moduleName) {
7
+ throw new Error("make:policy requires a module name.");
8
+ }
9
+
10
+ const moduleSlug = toKebabCase(moduleName);
11
+ const resourceName = toPascalCase(moduleName);
12
+ const directory = moduleDirectory(moduleSlug);
13
+ const policyPath = join(directory, "policy.ts");
14
+
15
+ try {
16
+ await access(directory);
17
+ } catch {
18
+ throw new Error(`Module not found: ${directory}`);
19
+ }
20
+
21
+ try {
22
+ await access(policyPath);
23
+ throw new Error(`Policy already exists: ${policyPath}`);
24
+ } catch (error) {
25
+ if (error instanceof Error && error.message.startsWith("Policy already exists:")) {
26
+ throw error;
27
+ }
28
+ }
29
+
30
+ const recordType = `${resourceName}Record`;
31
+ const policyClass = `${resourceName}Policy`;
32
+ const policyVariable = `${toCamelCase(moduleName)}Policy`;
33
+
34
+ const content = `import { Policy } from "@getstrata/core/auth/policy";
35
+ import type { ${recordType} } from "./types";
36
+
37
+ class ${policyClass} extends Policy {
38
+ override view(_user: unknown, _resource: ${recordType}): boolean {
39
+ return true;
40
+ }
41
+
42
+ override create(_user: unknown): boolean {
43
+ return true;
44
+ }
45
+
46
+ override update(_user: unknown, _resource: ${recordType}): boolean {
47
+ return true;
48
+ }
49
+
50
+ override delete(_user: unknown, _resource: ${recordType}): boolean {
51
+ return true;
52
+ }
53
+ }
54
+
55
+ export default ${policyClass};
56
+ `;
57
+
58
+ await Bun.write(policyPath, content);
59
+
60
+ console.log(`Created policy in: ${policyPath}`);
61
+ console.log(
62
+ `Register it in src/modules/${moduleSlug}/provider.ts boot() via gate.register("${moduleSlug}", container.resolve(${policyVariable})).`,
63
+ );
64
+ }
65
+
66
+ export { makePolicyCommand };
@@ -0,0 +1,70 @@
1
+ import { join } from "node:path";
2
+ import { ensureDirectory, moduleDirectory, toKebabCase, toPascalCase } from "./utils";
3
+
4
+ async function makeRequestCommand(moduleName?: string): Promise<void> {
5
+ if (!moduleName) {
6
+ throw new Error("make:request requires a module name.");
7
+ }
8
+
9
+ const moduleSlug = toKebabCase(moduleName);
10
+ const moduleNamePascal = toPascalCase(moduleName);
11
+ const filePath = join(moduleDirectory(moduleSlug), "requests.ts");
12
+
13
+ await ensureDirectory(moduleDirectory(moduleSlug));
14
+
15
+ const exists = await Bun.file(filePath).exists();
16
+ if (exists) {
17
+ throw new Error(`Request file already exists: ${filePath}`);
18
+ }
19
+
20
+ const content = `import { FormRequest } from "@getstrata/core/http";
21
+ import { parsePositiveIntParam } from "@getstrata/core/http/validation";
22
+ import {
23
+ maxLength,
24
+ minLength,
25
+ required,
26
+ stringRule,
27
+ validateObject,
28
+ } from "@getstrata/core/validation/rules";
29
+
30
+ type ${moduleNamePascal}IdParams = { id: string };
31
+
32
+ interface Create${moduleNamePascal}BodyDto {
33
+ name: string;
34
+ }
35
+
36
+ class Create${moduleNamePascal}Request extends FormRequest<Create${moduleNamePascal}BodyDto> {
37
+ protected parse(payload: unknown): Create${moduleNamePascal}BodyDto {
38
+ const validated = validateObject(payload, {
39
+ name: [required(), stringRule(), minLength(1), maxLength(120)],
40
+ });
41
+
42
+ return {
43
+ name: validated.name as string,
44
+ };
45
+ }
46
+ }
47
+
48
+ const create${moduleNamePascal}Request = new Create${moduleNamePascal}Request();
49
+
50
+ function parse${moduleNamePascal}IdParams(params: ${moduleNamePascal}IdParams): { id: number } {
51
+ return {
52
+ id: parsePositiveIntParam(params.id, "${moduleSlug} id"),
53
+ };
54
+ }
55
+
56
+ async function parseCreate${moduleNamePascal}Body(
57
+ request: Request,
58
+ ): Promise<Create${moduleNamePascal}BodyDto> {
59
+ return await create${moduleNamePascal}Request.validate(request);
60
+ }
61
+
62
+ export { parseCreate${moduleNamePascal}Body, parse${moduleNamePascal}IdParams };
63
+ export type { Create${moduleNamePascal}BodyDto, ${moduleNamePascal}IdParams };
64
+ `;
65
+
66
+ await Bun.write(filePath, content);
67
+ console.log(`Created request helpers: ${filePath}`);
68
+ }
69
+
70
+ export { makeRequestCommand };
@@ -0,0 +1,71 @@
1
+ import { existsSync } from "node:fs";
2
+ import { mkdir } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+
5
+ function toKebabCase(value: string): string {
6
+ return value
7
+ .trim()
8
+ .replace(/([a-z0-9])([A-Z])/g, "$1-$2")
9
+ .replace(/[\s_]+/g, "-")
10
+ .replace(/[^a-zA-Z0-9-]/g, "-")
11
+ .replace(/-+/g, "-")
12
+ .replace(/^-|-$/g, "")
13
+ .toLowerCase();
14
+ }
15
+
16
+ function toPascalCase(value: string): string {
17
+ return toKebabCase(value)
18
+ .split("-")
19
+ .filter(Boolean)
20
+ .map((part) => part[0]?.toUpperCase() + part.slice(1))
21
+ .join("");
22
+ }
23
+
24
+ function toCamelCase(value: string): string {
25
+ const pascalCase = toPascalCase(value);
26
+ return pascalCase[0]?.toLowerCase() + pascalCase.slice(1);
27
+ }
28
+
29
+ function timestampForFilename(date: Date = new Date()): string {
30
+ const parts = [
31
+ date.getUTCFullYear(),
32
+ String(date.getUTCMonth() + 1).padStart(2, "0"),
33
+ String(date.getUTCDate()).padStart(2, "0"),
34
+ String(date.getUTCHours()).padStart(2, "0"),
35
+ String(date.getUTCMinutes()).padStart(2, "0"),
36
+ String(date.getUTCSeconds()).padStart(2, "0"),
37
+ ];
38
+
39
+ return parts.join("");
40
+ }
41
+
42
+ async function ensureDirectory(path: string): Promise<void> {
43
+ await mkdir(path, { recursive: true });
44
+ }
45
+
46
+ function moduleDirectory(name: string): string {
47
+ return join(process.cwd(), "src", "modules", toKebabCase(name));
48
+ }
49
+
50
+ function migrationDirectory(): string {
51
+ return join(process.cwd(), "src", "db", "migrations");
52
+ }
53
+
54
+ function webViewsDirectory(pluralSlug: string): string {
55
+ const productViews = join(process.cwd(), "views");
56
+ if (existsSync(productViews)) {
57
+ return join(productViews, pluralSlug);
58
+ }
59
+ return join(process.cwd(), "resources", "views", pluralSlug);
60
+ }
61
+
62
+ export {
63
+ ensureDirectory,
64
+ migrationDirectory,
65
+ moduleDirectory,
66
+ timestampForFilename,
67
+ toCamelCase,
68
+ toKebabCase,
69
+ toPascalCase,
70
+ webViewsDirectory,
71
+ };
@@ -0,0 +1,25 @@
1
+ import { appSchedule, runDueScheduledTasks } from "@getstrata/core/scheduler/schedule";
2
+
3
+ type LoadAppSchedule = () => void | Promise<void>;
4
+
5
+ function createScheduleRunCommand(loadSchedule: LoadAppSchedule) {
6
+ return async function scheduleRunCommand(): Promise<void> {
7
+ await loadSchedule();
8
+
9
+ const due = appSchedule.dueTasks();
10
+
11
+ if (due.length === 0) {
12
+ console.log("No scheduled tasks due.");
13
+ return;
14
+ }
15
+
16
+ for (const task of due) {
17
+ console.log(`Running scheduled task: ${task.name}`);
18
+ }
19
+
20
+ await runDueScheduledTasks(appSchedule);
21
+ };
22
+ }
23
+
24
+ export type { LoadAppSchedule };
25
+ export { createScheduleRunCommand };