@tatara/cli 0.1.3 → 0.3.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.
- package/dist/bin.mjs +590 -14
- package/package.json +1 -1
- package/templates/features/admin/files/src/apps/admin/server.ts +3 -2
- package/templates/features/admin/files/src/modules/example/routes/admin-greeting.routes.ts +1 -1
- package/templates/features/auth/files/src/core/auth/middleware.ts +1 -1
- package/templates/features/auth/files/src/core/auth/routes/me.routes.ts +1 -1
- package/templates/features/auth-keycloak/files/src/core/auth/commands/keycloak-token.ts +1 -1
- package/templates/features/authz/files/src/core/authz/commands/grant-role.ts +1 -1
- package/templates/features/authz/files/src/core/authz/commands/sync-permissions.ts +1 -1
- package/templates/features/authz/files/src/core/authz/middleware.ts +1 -1
- package/templates/features/authz/files/src/core/authz/routes/authorization.routes.ts +1 -1
- package/templates/features/authz/files/src/core/authz/services/grants.ts +1 -1
- package/templates/features/authz/files/src/core/authz/services/members.ts +1 -1
- package/templates/features/authz/files/src/core/authz/services/roles.ts +1 -1
- package/templates/features/base/files/AGENTS.md +1 -0
- package/templates/features/base/files/src/apps/api/server.ts +3 -2
- package/templates/features/base/files/src/apps/cli/index.ts +4 -4
- package/templates/features/base/files/src/core/http/app.ts +28 -41
- package/templates/features/base/files/src/core/http/routes.ts +7 -11
- package/templates/features/base/files/src/core/logging/logger.ts +4 -12
- package/templates/features/base/files/src/modules/example/routes/greeting.routes.ts +1 -1
- package/templates/features/db/files/src/core/db/commands/migrate.ts +1 -1
- package/templates/features/db/files/src/core/db/commands/reset.ts +1 -1
- package/templates/features/db/files/src/core/db/commands/seed.ts +1 -1
- package/templates/features/db/files/src/core/db/tests/reset.test.ts +1 -1
- package/templates/features/drizzle/files+mysql/src/core/db/client.ts +2 -2
- package/templates/features/drizzle/files+postgres/src/core/db/client.ts +2 -2
- package/templates/features/email/files/src/core/email/commands/send-test.ts +1 -1
- package/templates/features/jobs/files/src/apps/cron/index.ts +2 -2
- package/templates/features/jobs/files/src/apps/worker/index.ts +2 -2
- package/templates/features/jobs/files/src/core/jobs/commands/enqueue.ts +1 -1
- package/templates/features/jobs/files/src/core/jobs/queue.ts +1 -1
- package/templates/features/jobs/files/src/core/jobs/redis.ts +1 -1
- package/templates/features/prisma/files+mysql/src/core/db/client.ts +2 -2
- package/templates/features/prisma/files+postgres/src/core/db/client.ts +2 -2
- package/templates/features/storage/files/src/core/storage/commands/check.ts +1 -1
- package/templates/generators/command/files/src/modules/__moduleKebab__/commands/__nameKebab__.ts +1 -1
- package/templates/generators/module/files/src/modules/__moduleKebab__/routes/__moduleKebab__.routes.ts +1 -1
- package/templates/generators/resource/files/src/modules/__moduleKebab__/routes/__nameKebab__.routes.ts +1 -1
- package/templates/generators/resource/files+drizzle/src/modules/__moduleKebab__/services/__nameKebab__.service.ts +2 -2
- package/templates/generators/resource/files+prisma/src/modules/__moduleKebab__/services/__nameKebab__.service.ts +2 -2
- package/templates/generators/route/files/src/modules/__moduleKebab__/routes/__nameKebab__.routes.ts +1 -1
- package/templates/versions.json +3 -1
- package/templates/features/base/files/src/core/commands/define.ts +0 -33
- package/templates/features/base/files/src/core/commands/runner.ts +0 -54
- package/templates/features/base/files/src/core/commands/tests/flags.test.ts +0 -16
- package/templates/features/base/files/src/core/http/errors.ts +0 -79
- package/templates/features/base/files/src/core/http/health.ts +0 -25
- package/templates/features/base/files/src/core/http/middleware/error-handler.ts +0 -36
- package/templates/features/base/files/src/core/http/middleware/request-context.ts +0 -24
- package/templates/features/base/files/src/core/http/middleware/validation.ts +0 -17
- package/templates/features/base/files/src/core/http/response.ts +0 -72
- package/templates/features/base/files/src/core/http/server.ts +0 -15
- package/templates/features/base/files/src/core/process/lifecycle.ts +0 -34
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from "vitest";
|
|
2
|
-
import { z } from "zod";
|
|
3
|
-
import { flag } from "#core/commands/define";
|
|
4
|
-
import { parseFlags } from "#core/commands/runner";
|
|
5
|
-
|
|
6
|
-
const args = z.object({ force: flag() });
|
|
7
|
-
const parse = (argv: string[]) => args.parse(parseFlags(argv)).force;
|
|
8
|
-
|
|
9
|
-
describe("flag()", () => {
|
|
10
|
-
it("is true for a bare flag", () => expect(parse(["--force"])).toBe(true));
|
|
11
|
-
it("reads --flag false as false", () => expect(parse(["--force", "false"])).toBe(false));
|
|
12
|
-
it("reads --flag=0 as false", () => expect(parse(["--force=0"])).toBe(false));
|
|
13
|
-
it("reads --flag true as true", () => expect(parse(["--force", "true"])).toBe(true));
|
|
14
|
-
it("defaults to false", () => expect(parse([])).toBe(false));
|
|
15
|
-
it("rejects anything else", () => expect(() => parse(["--force", "maybe"])).toThrow());
|
|
16
|
-
});
|
|
@@ -1,79 +0,0 @@
|
|
|
1
|
-
import type { ContentfulStatusCode } from "hono/utils/http-status";
|
|
2
|
-
|
|
3
|
-
export class AppError extends Error {
|
|
4
|
-
readonly status: ContentfulStatusCode;
|
|
5
|
-
readonly code: string;
|
|
6
|
-
readonly details?: unknown;
|
|
7
|
-
/** True when the message is generic, so the response may replace it with `errors.<code>` in the caller's language. */
|
|
8
|
-
readonly translatable: boolean;
|
|
9
|
-
|
|
10
|
-
constructor(
|
|
11
|
-
status: ContentfulStatusCode,
|
|
12
|
-
code: string,
|
|
13
|
-
message: string,
|
|
14
|
-
details?: unknown,
|
|
15
|
-
translatable = false,
|
|
16
|
-
) {
|
|
17
|
-
super(message);
|
|
18
|
-
this.name = new.target.name;
|
|
19
|
-
this.status = status;
|
|
20
|
-
this.code = code;
|
|
21
|
-
this.details = details;
|
|
22
|
-
this.translatable = translatable;
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
export class ValidationError extends AppError {
|
|
27
|
-
constructor(message?: string, details?: unknown) {
|
|
28
|
-
super(400, "VALIDATION_ERROR", message ?? "Invalid request", details, message === undefined);
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
export class BusinessRuleError extends AppError {
|
|
33
|
-
constructor(code: string, message: string, details?: unknown) {
|
|
34
|
-
// codes are specific to the rule, so a translation for the code is never less precise
|
|
35
|
-
super(400, code, message, details, true);
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
export class UnauthorizedError extends AppError {
|
|
40
|
-
constructor(message?: string) {
|
|
41
|
-
super(
|
|
42
|
-
401,
|
|
43
|
-
"UNAUTHORIZED",
|
|
44
|
-
message ?? "Authentication required",
|
|
45
|
-
undefined,
|
|
46
|
-
message === undefined,
|
|
47
|
-
);
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
export class ForbiddenError extends AppError {
|
|
52
|
-
constructor(message?: string) {
|
|
53
|
-
super(
|
|
54
|
-
403,
|
|
55
|
-
"FORBIDDEN",
|
|
56
|
-
message ?? "You do not have permission to do this",
|
|
57
|
-
undefined,
|
|
58
|
-
message === undefined,
|
|
59
|
-
);
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
export class NotFoundError extends AppError {
|
|
64
|
-
constructor(resource?: string) {
|
|
65
|
-
super(
|
|
66
|
-
404,
|
|
67
|
-
"NOT_FOUND",
|
|
68
|
-
`${resource ?? "Resource"} not found`,
|
|
69
|
-
undefined,
|
|
70
|
-
resource === undefined,
|
|
71
|
-
);
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
export class ConflictError extends AppError {
|
|
76
|
-
constructor(message?: string, details?: unknown) {
|
|
77
|
-
super(409, "CONFLICT", message ?? "Resource already exists", details, message === undefined);
|
|
78
|
-
}
|
|
79
|
-
}
|
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
type Check = () => Promise<void>;
|
|
2
|
-
|
|
3
|
-
const checks = new Map<string, Check>();
|
|
4
|
-
|
|
5
|
-
/** Registers a dependency checked by GET /ready (database, redis, …). */
|
|
6
|
-
export function registerReadinessCheck(name: string, check: Check): void {
|
|
7
|
-
checks.set(name, check);
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
export async function runReadinessChecks() {
|
|
11
|
-
const results = await Promise.all(
|
|
12
|
-
[...checks].map(async ([name, check]) => {
|
|
13
|
-
try {
|
|
14
|
-
await check();
|
|
15
|
-
return [name, "ok"] as const;
|
|
16
|
-
} catch {
|
|
17
|
-
return [name, "failed"] as const;
|
|
18
|
-
}
|
|
19
|
-
}),
|
|
20
|
-
);
|
|
21
|
-
return {
|
|
22
|
-
ready: results.every(([, status]) => status === "ok"),
|
|
23
|
-
checks: Object.fromEntries(results),
|
|
24
|
-
};
|
|
25
|
-
}
|
|
@@ -1,36 +0,0 @@
|
|
|
1
|
-
import type { Context } from "hono";
|
|
2
|
-
import { HTTPException } from "hono/http-exception";
|
|
3
|
-
import type { AppEnv } from "#core/http/context";
|
|
4
|
-
import { AppError } from "#core/http/errors";
|
|
5
|
-
// tatara:slot imports
|
|
6
|
-
|
|
7
|
-
export function errorHandler(err: Error, c: Context<AppEnv>) {
|
|
8
|
-
if (err instanceof AppError) {
|
|
9
|
-
let message = err.message;
|
|
10
|
-
// tatara:slot error-message
|
|
11
|
-
return c.json(
|
|
12
|
-
{
|
|
13
|
-
success: false as const,
|
|
14
|
-
error: message,
|
|
15
|
-
code: err.code,
|
|
16
|
-
...(err.details === undefined ? {} : { details: err.details }),
|
|
17
|
-
},
|
|
18
|
-
err.status,
|
|
19
|
-
);
|
|
20
|
-
}
|
|
21
|
-
if (err instanceof HTTPException) {
|
|
22
|
-
return c.json(
|
|
23
|
-
{ success: false as const, error: err.message || "Request failed", code: "HTTP_ERROR" },
|
|
24
|
-
err.status,
|
|
25
|
-
);
|
|
26
|
-
}
|
|
27
|
-
c.get("logger").error({ err }, "unhandled error");
|
|
28
|
-
return c.json(
|
|
29
|
-
{ success: false as const, error: "Something went wrong", code: "INTERNAL_ERROR" },
|
|
30
|
-
500,
|
|
31
|
-
);
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
export function notFoundHandler(c: Context<AppEnv>) {
|
|
35
|
-
return c.json({ success: false as const, error: "Route not found", code: "NOT_FOUND" }, 404);
|
|
36
|
-
}
|
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
import { createMiddleware } from "hono/factory";
|
|
2
|
-
import type { AppEnv } from "#core/http/context";
|
|
3
|
-
import { logger } from "#core/logging/logger";
|
|
4
|
-
|
|
5
|
-
const REQUEST_ID = /^[\w-]{8,128}$/;
|
|
6
|
-
|
|
7
|
-
/** Sets requestId, a request-scoped logger and defaults; logs one line per request. */
|
|
8
|
-
export const requestContext = createMiddleware<AppEnv>(async (c, next) => {
|
|
9
|
-
const incoming = c.req.header("x-request-id");
|
|
10
|
-
const requestId = incoming && REQUEST_ID.test(incoming) ? incoming : crypto.randomUUID();
|
|
11
|
-
const log = logger.child({ requestId });
|
|
12
|
-
c.set("requestId", requestId);
|
|
13
|
-
c.set("logger", log);
|
|
14
|
-
c.set("principal", null);
|
|
15
|
-
c.set("language", "en");
|
|
16
|
-
c.header("x-request-id", requestId);
|
|
17
|
-
const start = performance.now();
|
|
18
|
-
await next();
|
|
19
|
-
const ms = Math.round(performance.now() - start);
|
|
20
|
-
const status = c.res.status;
|
|
21
|
-
const entry = { method: c.req.method, path: c.req.path, status, ms };
|
|
22
|
-
if (status >= 500) log.error(entry, "request failed");
|
|
23
|
-
else log.info(entry, "request");
|
|
24
|
-
});
|
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
import type { Hook } from "@hono/zod-openapi";
|
|
2
|
-
import type { AppEnv } from "#core/http/context";
|
|
3
|
-
import { ValidationError } from "#core/http/errors";
|
|
4
|
-
|
|
5
|
-
// biome-ignore lint/suspicious/noExplicitAny: the hook is shared by every route schema
|
|
6
|
-
export const validationHook: Hook<any, AppEnv, any, any> = (result) => {
|
|
7
|
-
if (!result.success) {
|
|
8
|
-
throw new ValidationError(
|
|
9
|
-
undefined,
|
|
10
|
-
result.error.issues.map((issue) => ({
|
|
11
|
-
path: issue.path.join("."),
|
|
12
|
-
message: issue.message,
|
|
13
|
-
code: issue.code,
|
|
14
|
-
})),
|
|
15
|
-
);
|
|
16
|
-
}
|
|
17
|
-
};
|
|
@@ -1,72 +0,0 @@
|
|
|
1
|
-
import { z } from "@hono/zod-openapi";
|
|
2
|
-
|
|
3
|
-
export const errorSchema = z
|
|
4
|
-
.object({
|
|
5
|
-
success: z.literal(false),
|
|
6
|
-
error: z.string(),
|
|
7
|
-
code: z.string(),
|
|
8
|
-
details: z.unknown().optional(),
|
|
9
|
-
})
|
|
10
|
-
.openapi("Error");
|
|
11
|
-
|
|
12
|
-
export const paginationMetaSchema = z
|
|
13
|
-
.object({
|
|
14
|
-
page: z.number().int(),
|
|
15
|
-
pageSize: z.number().int(),
|
|
16
|
-
total: z.number().int(),
|
|
17
|
-
totalPages: z.number().int(),
|
|
18
|
-
})
|
|
19
|
-
.openapi("PaginationMeta");
|
|
20
|
-
|
|
21
|
-
export const paginationQuerySchema = z.object({
|
|
22
|
-
page: z.coerce.number().int().min(1).default(1),
|
|
23
|
-
pageSize: z.coerce.number().int().min(1).max(100).default(20),
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
export type PaginationQuery = z.infer<typeof paginationQuerySchema>;
|
|
27
|
-
|
|
28
|
-
export function successSchema<T extends z.ZodType>(data: T) {
|
|
29
|
-
return z.object({ success: z.literal(true), data });
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
export function paginatedSchema<T extends z.ZodType>(item: T) {
|
|
33
|
-
return z.object({ success: z.literal(true), data: z.array(item), meta: paginationMetaSchema });
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export function success<T>(data: T) {
|
|
37
|
-
return { success: true as const, data };
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
export function paginated<T>(data: T[], page: number, pageSize: number, total: number) {
|
|
41
|
-
return {
|
|
42
|
-
success: true as const,
|
|
43
|
-
data,
|
|
44
|
-
meta: { page, pageSize, total, totalPages: Math.max(1, Math.ceil(total / pageSize)) },
|
|
45
|
-
};
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
export function jsonContent<T extends z.ZodType>(schema: T, description: string) {
|
|
49
|
-
return { content: { "application/json": { schema } }, description };
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/** A required JSON request body. Without `required`, a request with no body skips validation. */
|
|
53
|
-
export function jsonBody<T extends z.ZodType>(schema: T, description: string) {
|
|
54
|
-
return { ...jsonContent(schema, description), required: true };
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
const ERROR_DESCRIPTIONS = {
|
|
58
|
-
400: "Invalid request",
|
|
59
|
-
401: "Authentication required",
|
|
60
|
-
403: "Forbidden",
|
|
61
|
-
404: "Not found",
|
|
62
|
-
409: "Conflict",
|
|
63
|
-
} as const;
|
|
64
|
-
|
|
65
|
-
type ErrorStatus = keyof typeof ERROR_DESCRIPTIONS;
|
|
66
|
-
|
|
67
|
-
/** OpenAPI error responses for the given statuses, all using the shared error envelope. */
|
|
68
|
-
export function errorResponses<const S extends ErrorStatus[]>(...statuses: S) {
|
|
69
|
-
return Object.fromEntries(
|
|
70
|
-
statuses.map((status) => [status, jsonContent(errorSchema, ERROR_DESCRIPTIONS[status])]),
|
|
71
|
-
) as { [K in S[number]]: ReturnType<typeof jsonContent<typeof errorSchema>> };
|
|
72
|
-
}
|
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
import { serve } from "@hono/node-server";
|
|
2
|
-
import type { OpenAPIHono } from "@hono/zod-openapi";
|
|
3
|
-
import type { AppEnv } from "#core/http/context";
|
|
4
|
-
import { logger } from "#core/logging/logger";
|
|
5
|
-
import { handleSignals, onShutdown } from "#core/process/lifecycle";
|
|
6
|
-
|
|
7
|
-
/** Starts an HTTP app; on shutdown stops accepting connections and drains in-flight requests. */
|
|
8
|
-
export function startServer(app: OpenAPIHono<AppEnv>, port: number, name: string) {
|
|
9
|
-
const server = serve({ fetch: app.fetch, port }, (info) => {
|
|
10
|
-
logger.info({ port: info.port, app: name }, `${name} listening on http://localhost:${info.port}`);
|
|
11
|
-
});
|
|
12
|
-
onShutdown(`${name}-http`, () => new Promise<void>((resolve) => server.close(() => resolve())));
|
|
13
|
-
handleSignals();
|
|
14
|
-
return server;
|
|
15
|
-
}
|
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
import { logger } from "#core/logging/logger";
|
|
2
|
-
|
|
3
|
-
type Hook = () => Promise<void> | void;
|
|
4
|
-
|
|
5
|
-
const hooks: { name: string; run: Hook }[] = [];
|
|
6
|
-
let shuttingDown = false;
|
|
7
|
-
|
|
8
|
-
/** Registers cleanup that runs on SIGTERM/SIGINT or `shutdown()`, last registered first. */
|
|
9
|
-
export function onShutdown(name: string, run: Hook): void {
|
|
10
|
-
hooks.push({ name, run });
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
export async function shutdown(code = 0): Promise<never> {
|
|
14
|
-
if (!shuttingDown) {
|
|
15
|
-
shuttingDown = true;
|
|
16
|
-
for (const hook of [...hooks].reverse()) {
|
|
17
|
-
try {
|
|
18
|
-
await hook.run();
|
|
19
|
-
} catch (error) {
|
|
20
|
-
logger.error({ err: error, hook: hook.name }, "shutdown hook failed");
|
|
21
|
-
}
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
process.exit(code);
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export function handleSignals(): void {
|
|
28
|
-
for (const signal of ["SIGTERM", "SIGINT"] as const) {
|
|
29
|
-
process.once(signal, () => {
|
|
30
|
-
logger.info({ signal }, "shutting down");
|
|
31
|
-
void shutdown(0);
|
|
32
|
-
});
|
|
33
|
-
}
|
|
34
|
-
}
|