@podosoft/podokit 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.
Files changed (56) hide show
  1. package/dist/create.d.ts +26 -0
  2. package/dist/create.js +44 -0
  3. package/dist/index.d.ts +13 -0
  4. package/dist/index.js +99 -0
  5. package/dist/prompt.d.ts +21 -0
  6. package/dist/prompt.js +38 -0
  7. package/dist/templates/base/README.md +19 -0
  8. package/dist/templates/base/apps/api/package.json +10 -0
  9. package/dist/templates/base/apps/api/src/main.ts +5 -0
  10. package/dist/templates/base/apps/web/package.json +10 -0
  11. package/dist/templates/base/apps/web/src/app.css +1 -0
  12. package/dist/templates/base/dot-env.example +15 -0
  13. package/dist/templates/base/dot-gitignore +9 -0
  14. package/dist/templates/base/package.json +17 -0
  15. package/dist/templates/fullstack-nest-svelte/README.md +29 -0
  16. package/dist/templates/fullstack-nest-svelte/apps/api/Dockerfile +22 -0
  17. package/dist/templates/fullstack-nest-svelte/apps/api/nest-cli.json +5 -0
  18. package/dist/templates/fullstack-nest-svelte/apps/api/package.json +32 -0
  19. package/dist/templates/fullstack-nest-svelte/apps/api/src/app.module.ts +7 -0
  20. package/dist/templates/fullstack-nest-svelte/apps/api/src/common/all-exceptions.filter.ts +43 -0
  21. package/dist/templates/fullstack-nest-svelte/apps/api/src/common/app-exception.ts +12 -0
  22. package/dist/templates/fullstack-nest-svelte/apps/api/src/config/env.validation.ts +18 -0
  23. package/dist/templates/fullstack-nest-svelte/apps/api/src/health/health.controller.ts +13 -0
  24. package/dist/templates/fullstack-nest-svelte/apps/api/src/health/health.module.ts +7 -0
  25. package/dist/templates/fullstack-nest-svelte/apps/api/src/main.ts +21 -0
  26. package/dist/templates/fullstack-nest-svelte/apps/api/test/health.e2e-spec.ts +23 -0
  27. package/dist/templates/fullstack-nest-svelte/apps/api/tsconfig.json +21 -0
  28. package/dist/templates/fullstack-nest-svelte/apps/web/Dockerfile +22 -0
  29. package/dist/templates/fullstack-nest-svelte/apps/web/components.json +15 -0
  30. package/dist/templates/fullstack-nest-svelte/apps/web/package.json +28 -0
  31. package/dist/templates/fullstack-nest-svelte/apps/web/src/app.css +17 -0
  32. package/dist/templates/fullstack-nest-svelte/apps/web/src/app.d.ts +11 -0
  33. package/dist/templates/fullstack-nest-svelte/apps/web/src/app.html +11 -0
  34. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/i18n/README.md +7 -0
  35. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/i18n/en.ts +10 -0
  36. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/i18n/ko.ts +8 -0
  37. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/server/backend-proxy.ts +16 -0
  38. package/dist/templates/fullstack-nest-svelte/apps/web/src/routes/+layout.svelte +9 -0
  39. package/dist/templates/fullstack-nest-svelte/apps/web/src/routes/+page.svelte +26 -0
  40. package/dist/templates/fullstack-nest-svelte/apps/web/src/routes/api/health/+server.ts +12 -0
  41. package/dist/templates/fullstack-nest-svelte/apps/web/static/.gitkeep +0 -0
  42. package/dist/templates/fullstack-nest-svelte/apps/web/svelte.config.js +15 -0
  43. package/dist/templates/fullstack-nest-svelte/apps/web/tsconfig.json +9 -0
  44. package/dist/templates/fullstack-nest-svelte/apps/web/vite.config.ts +7 -0
  45. package/dist/templates/fullstack-nest-svelte/dot-env.example +16 -0
  46. package/dist/templates/fullstack-nest-svelte/dot-gitignore +9 -0
  47. package/dist/templates/fullstack-nest-svelte/infra/docker/docker-compose.yml +29 -0
  48. package/dist/templates/fullstack-nest-svelte/infra/k3s/api-deployment.yaml +24 -0
  49. package/dist/templates/fullstack-nest-svelte/infra/k3s/configmap.yaml +10 -0
  50. package/dist/templates/fullstack-nest-svelte/infra/k3s/ingress.yaml +18 -0
  51. package/dist/templates/fullstack-nest-svelte/infra/k3s/namespace.yaml +4 -0
  52. package/dist/templates/fullstack-nest-svelte/infra/k3s/secret.example.yaml +10 -0
  53. package/dist/templates/fullstack-nest-svelte/infra/k3s/services.yaml +21 -0
  54. package/dist/templates/fullstack-nest-svelte/infra/k3s/web-deployment.yaml +20 -0
  55. package/dist/templates/fullstack-nest-svelte/package.json +13 -0
  56. package/package.json +31 -0
@@ -0,0 +1,26 @@
1
+ export type PackageManager = "npm" | "pnpm" | "yarn";
2
+ export declare const DEFAULT_TEMPLATE = "fullstack-nest-svelte";
3
+ export interface CreateOptions {
4
+ /** Project name; also the default directory name. */
5
+ name: string;
6
+ /** Directory that holds the template sets (each in its own subfolder). */
7
+ templatesDir: string;
8
+ /** Template subfolder to use. Defaults to `fullstack-nest-svelte`. */
9
+ template?: string;
10
+ /** Where to create the project. Defaults to `<cwd>/<name>`. */
11
+ targetDir?: string;
12
+ /** Package manager recorded in the generated project. Defaults to `npm`. */
13
+ packageManager?: PackageManager;
14
+ }
15
+ export interface CreateResult {
16
+ projectDir: string;
17
+ packageManager: PackageManager;
18
+ template: string;
19
+ }
20
+ /** Validate a project name: no path separators, npm-friendly characters. */
21
+ export declare function assertValidName(name: string): void;
22
+ /**
23
+ * Scaffold a new project from the `base` template. Pure enough to test:
24
+ * given a name and a templates directory, it writes files and returns where.
25
+ */
26
+ export declare function create(options: CreateOptions): CreateResult;
package/dist/create.js ADDED
@@ -0,0 +1,44 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.DEFAULT_TEMPLATE = void 0;
4
+ exports.assertValidName = assertValidName;
5
+ exports.create = create;
6
+ const node_fs_1 = require("node:fs");
7
+ const node_path_1 = require("node:path");
8
+ const podokit_template_engine_1 = require("@podosoft/podokit-template-engine");
9
+ exports.DEFAULT_TEMPLATE = "fullstack-nest-svelte";
10
+ const NAME_PATTERN = /^[a-z0-9](?:[a-z0-9-._]*[a-z0-9])?$/i;
11
+ /** Validate a project name: no path separators, npm-friendly characters. */
12
+ function assertValidName(name) {
13
+ if (!name || !NAME_PATTERN.test(name)) {
14
+ throw new Error(`Invalid project name "${name}". Use letters, digits, "-", "_", "." and no path separators.`);
15
+ }
16
+ }
17
+ function isEmptyDir(dir) {
18
+ return !(0, node_fs_1.existsSync)(dir) || (0, node_fs_1.readdirSync)(dir).length === 0;
19
+ }
20
+ /**
21
+ * Scaffold a new project from the `base` template. Pure enough to test:
22
+ * given a name and a templates directory, it writes files and returns where.
23
+ */
24
+ function create(options) {
25
+ const { name, templatesDir } = options;
26
+ assertValidName(name);
27
+ const template = options.template ?? exports.DEFAULT_TEMPLATE;
28
+ const packageManager = options.packageManager ?? "npm";
29
+ const projectDir = options.targetDir
30
+ ? (0, node_path_1.isAbsolute)(options.targetDir)
31
+ ? options.targetDir
32
+ : (0, node_path_1.resolve)(process.cwd(), options.targetDir)
33
+ : (0, node_path_1.resolve)(process.cwd(), name);
34
+ if (!isEmptyDir(projectDir)) {
35
+ throw new Error(`Target directory is not empty: ${projectDir}`);
36
+ }
37
+ const templateDir = (0, node_path_1.join)(templatesDir, template);
38
+ if (!(0, node_fs_1.existsSync)(templateDir)) {
39
+ throw new Error(`Template "${template}" not found at ${templateDir}`);
40
+ }
41
+ const vars = { projectName: name, packageManager };
42
+ (0, podokit_template_engine_1.copyTemplate)(templateDir, projectDir, vars);
43
+ return { projectDir, packageManager, template };
44
+ }
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env node
2
+ import { type PackageManager } from "./create";
3
+ interface ParsedArgs {
4
+ command?: string;
5
+ name?: string;
6
+ template?: string;
7
+ dir?: string;
8
+ pm?: PackageManager;
9
+ yes: boolean;
10
+ help: boolean;
11
+ }
12
+ export declare function parseArgs(argv: string[]): ParsedArgs;
13
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,99 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.parseArgs = parseArgs;
5
+ const node_path_1 = require("node:path");
6
+ const promises_1 = require("node:readline/promises");
7
+ const create_1 = require("./create");
8
+ const prompt_1 = require("./prompt");
9
+ const HELP = `podo — PodoKit project generator
10
+
11
+ Usage:
12
+ podo create <name> [options]
13
+
14
+ Options:
15
+ --template <t> Template: fullstack-nest-svelte | base (default: fullstack-nest-svelte)
16
+ --dir <path> Target directory (default: ./<name>)
17
+ --pm <name> Package manager: npm | pnpm | yarn (default: npm)
18
+ -y, --yes Skip prompts and accept defaults
19
+ -h, --help Show this help
20
+
21
+ Example:
22
+ npx @podosoft/podokit create my-app
23
+ `;
24
+ function parseArgs(argv) {
25
+ const parsed = { help: false, yes: false };
26
+ const positionals = [];
27
+ for (let i = 0; i < argv.length; i += 1) {
28
+ const arg = argv[i];
29
+ if (arg === "-h" || arg === "--help") {
30
+ parsed.help = true;
31
+ }
32
+ else if (arg === "-y" || arg === "--yes") {
33
+ parsed.yes = true;
34
+ }
35
+ else if (arg === "--template") {
36
+ parsed.template = argv[++i];
37
+ }
38
+ else if (arg === "--dir") {
39
+ parsed.dir = argv[++i];
40
+ }
41
+ else if (arg === "--pm") {
42
+ parsed.pm = argv[++i];
43
+ }
44
+ else if (arg !== undefined && !arg.startsWith("-")) {
45
+ positionals.push(arg);
46
+ }
47
+ }
48
+ parsed.command = positionals[0];
49
+ parsed.name = positionals[1];
50
+ return parsed;
51
+ }
52
+ function fail(message) {
53
+ process.stderr.write(`error: ${message}\n`);
54
+ process.exit(1);
55
+ }
56
+ async function main(argv) {
57
+ const args = parseArgs(argv);
58
+ if (args.help || !args.command) {
59
+ process.stdout.write(HELP);
60
+ return;
61
+ }
62
+ if (args.command !== "create") {
63
+ fail(`Unknown command "${args.command}". Run "podo --help".`);
64
+ }
65
+ if (!args.name) {
66
+ fail('Missing project name. Usage: podo create <name>');
67
+ }
68
+ try {
69
+ (0, create_1.assertValidName)(args.name);
70
+ }
71
+ catch (err) {
72
+ fail(err.message);
73
+ }
74
+ const interactive = Boolean(process.stdin.isTTY) && !args.yes;
75
+ const rl = interactive ? (0, promises_1.createInterface)({ input: process.stdin, output: process.stdout }) : undefined;
76
+ const ask = async (question) => (rl ? (await rl.question(question)).trim() : "");
77
+ const templatesDir = (0, node_path_1.join)(__dirname, "templates");
78
+ try {
79
+ const resolved = await (0, prompt_1.resolveCreateOptions)({ template: args.template, pm: args.pm }, ask, interactive);
80
+ const result = (0, create_1.create)({
81
+ name: args.name,
82
+ templatesDir,
83
+ template: resolved.template,
84
+ targetDir: args.dir,
85
+ packageManager: resolved.packageManager,
86
+ });
87
+ const relPath = (0, node_path_1.relative)(process.cwd(), result.projectDir) || ".";
88
+ const rel = relPath.startsWith("..") ? result.projectDir : relPath;
89
+ const pm = result.packageManager;
90
+ process.stdout.write(`\nCreated ${args.name} (${result.template}) in ${rel}\n\nNext steps:\n cd ${rel}\n ${pm} install\n ${pm} run dev\n`);
91
+ }
92
+ catch (err) {
93
+ fail(err.message);
94
+ }
95
+ finally {
96
+ rl?.close();
97
+ }
98
+ }
99
+ void main(process.argv.slice(2));
@@ -0,0 +1,21 @@
1
+ import { type PackageManager } from "./create";
2
+ export declare const TEMPLATES: readonly ["fullstack-nest-svelte", "base"];
3
+ export declare const PACKAGE_MANAGERS: PackageManager[];
4
+ /** Asks a single question and resolves to the trimmed answer (empty if skipped). */
5
+ export type Ask = (question: string) => Promise<string>;
6
+ export interface RawCreateArgs {
7
+ template?: string;
8
+ pm?: PackageManager;
9
+ }
10
+ export interface ResolvedCreateOptions {
11
+ template: string;
12
+ packageManager: PackageManager;
13
+ }
14
+ /**
15
+ * Resolve the template and package manager for `create`.
16
+ *
17
+ * Precedence: an explicit flag wins; otherwise, when `interactive` is true the
18
+ * user is prompted (blank answer = default); otherwise the default is used.
19
+ * Kept free of I/O — the caller injects `ask` — so it is unit-testable.
20
+ */
21
+ export declare function resolveCreateOptions(args: RawCreateArgs, ask: Ask, interactive: boolean): Promise<ResolvedCreateOptions>;
package/dist/prompt.js ADDED
@@ -0,0 +1,38 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.PACKAGE_MANAGERS = exports.TEMPLATES = void 0;
4
+ exports.resolveCreateOptions = resolveCreateOptions;
5
+ const create_1 = require("./create");
6
+ exports.TEMPLATES = ["fullstack-nest-svelte", "base"];
7
+ exports.PACKAGE_MANAGERS = ["npm", "pnpm", "yarn"];
8
+ function isPackageManager(value) {
9
+ return exports.PACKAGE_MANAGERS.includes(value);
10
+ }
11
+ /**
12
+ * Resolve the template and package manager for `create`.
13
+ *
14
+ * Precedence: an explicit flag wins; otherwise, when `interactive` is true the
15
+ * user is prompted (blank answer = default); otherwise the default is used.
16
+ * Kept free of I/O — the caller injects `ask` — so it is unit-testable.
17
+ */
18
+ async function resolveCreateOptions(args, ask, interactive) {
19
+ let template = args.template;
20
+ if (!template && interactive) {
21
+ const answer = await ask(`Template (${exports.TEMPLATES.join(" / ")}) [${create_1.DEFAULT_TEMPLATE}]: `);
22
+ template = answer || undefined;
23
+ }
24
+ template = template ?? create_1.DEFAULT_TEMPLATE;
25
+ if (!exports.TEMPLATES.includes(template)) {
26
+ throw new Error(`Unknown template "${template}". Choose one of: ${exports.TEMPLATES.join(", ")}.`);
27
+ }
28
+ let pm = args.pm;
29
+ if (!pm && interactive) {
30
+ const answer = await ask(`Package manager (${exports.PACKAGE_MANAGERS.join(" / ")}) [npm]: `);
31
+ pm = answer || undefined;
32
+ }
33
+ pm = pm ?? "npm";
34
+ if (!isPackageManager(pm)) {
35
+ throw new Error(`Invalid package manager "${pm}". Choose one of: ${exports.PACKAGE_MANAGERS.join(", ")}.`);
36
+ }
37
+ return { template, packageManager: pm };
38
+ }
@@ -0,0 +1,19 @@
1
+ # {{projectName}}
2
+
3
+ Generated with [PodoKit](https://github.com/podosoft-dev/podokit).
4
+
5
+ A full-stack TypeScript workspace with a NestJS API (`apps/api`) and a
6
+ SvelteKit web app (`apps/web`).
7
+
8
+ ## Getting started
9
+
10
+ ```bash
11
+ {{packageManager}} install
12
+ {{packageManager}} run dev
13
+ ```
14
+
15
+ Copy the example environment file before running:
16
+
17
+ ```bash
18
+ cp .env.example .env
19
+ ```
@@ -0,0 +1,10 @@
1
+ {
2
+ "name": "{{projectName}}-api",
3
+ "version": "0.0.0",
4
+ "private": true,
5
+ "scripts": {
6
+ "dev": "echo \"api dev placeholder\" && exit 0",
7
+ "build": "echo \"api build placeholder\" && exit 0",
8
+ "test": "echo \"api test placeholder\" && exit 0"
9
+ }
10
+ }
@@ -0,0 +1,5 @@
1
+ // Entry point placeholder for the {{projectName}} API.
2
+ // The full NestJS starter is added by `templates/fullstack-nest-svelte`.
3
+ export function health(): { status: string } {
4
+ return { status: "ok" };
5
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ "name": "{{projectName}}-web",
3
+ "version": "0.0.0",
4
+ "private": true,
5
+ "scripts": {
6
+ "dev": "echo \"web dev placeholder\" && exit 0",
7
+ "build": "echo \"web build placeholder\" && exit 0",
8
+ "test": "echo \"web test placeholder\" && exit 0"
9
+ }
10
+ }
@@ -0,0 +1 @@
1
+ /* {{projectName}} web styles. TailwindCSS is wired up by the full template. */
@@ -0,0 +1,15 @@
1
+ # Copy to .env and adjust. Never commit real secrets.
2
+ POSTGRES_HOST=localhost
3
+ POSTGRES_PORT=5432
4
+ POSTGRES_USER=podokit
5
+ POSTGRES_PASSWORD=podokit
6
+ POSTGRES_DB=podokit
7
+
8
+ REDIS_HOST=localhost
9
+ REDIS_PORT=6379
10
+
11
+ APP_BASE_URL=http://localhost:5173
12
+ API_BASE_URL=http://localhost:3000
13
+ BACKEND_INTERNAL_URL=http://localhost:3000
14
+ CORS_ORIGIN=http://localhost:5173
15
+ PORT=3000
@@ -0,0 +1,9 @@
1
+ node_modules/
2
+ dist/
3
+ build/
4
+ .svelte-kit/
5
+ .env
6
+ .env.*
7
+ !.env.example
8
+ .DS_Store
9
+ *.log
@@ -0,0 +1,17 @@
1
+ {
2
+ "name": "{{projectName}}",
3
+ "version": "0.0.0",
4
+ "private": true,
5
+ "engines": {
6
+ "node": ">=20"
7
+ },
8
+ "workspaces": [
9
+ "apps/*"
10
+ ],
11
+ "scripts": {
12
+ "dev": "echo \"Run 'dev' in apps/api and apps/web\" && exit 0",
13
+ "build": "npm run build --workspaces --if-present",
14
+ "lint": "npm run lint --workspaces --if-present",
15
+ "test": "npm run test --workspaces --if-present"
16
+ }
17
+ }
@@ -0,0 +1,29 @@
1
+ # {{projectName}}
2
+
3
+ Full-stack TypeScript app generated with [PodoKit](https://github.com/podosoft-dev/podokit).
4
+
5
+ - `apps/api` — NestJS API (config validation, health checks, standard error envelope)
6
+ - `apps/web` — SvelteKit app (TailwindCSS v4, shadcn-svelte, typesafe-i18n) that talks to the API through a server-side proxy
7
+ - `infra/` — Docker Compose and k3s manifests
8
+
9
+ ## Getting started
10
+
11
+ ```bash
12
+ {{packageManager}} install
13
+ cp .env.example .env
14
+ {{packageManager}} run dev
15
+ ```
16
+
17
+ - API: http://localhost:3000 (health at `/health`)
18
+ - Web: http://localhost:5173
19
+
20
+ ## Local services
21
+
22
+ ```bash
23
+ docker compose -f infra/docker/docker-compose.yml up -d
24
+ ```
25
+
26
+ ## Deploy
27
+
28
+ Docker Compose manifests live in `infra/docker`; example k3s manifests in `infra/k3s`
29
+ (use `secret.example.yaml` as a template — never commit real secrets).
@@ -0,0 +1,22 @@
1
+ # syntax=docker/dockerfile:1
2
+ FROM node:20-alpine AS deps
3
+ WORKDIR /app
4
+ COPY package.json ./
5
+ RUN npm install --omit=dev=false --no-audit --no-fund
6
+
7
+ FROM node:20-alpine AS build
8
+ WORKDIR /app
9
+ COPY --from=deps /app/node_modules ./node_modules
10
+ COPY . .
11
+ RUN npm run build
12
+
13
+ FROM node:20-alpine AS runtime
14
+ WORKDIR /app
15
+ ENV NODE_ENV=production
16
+ RUN addgroup -S app && adduser -S app -G app
17
+ COPY --from=build /app/dist ./dist
18
+ COPY --from=build /app/node_modules ./node_modules
19
+ COPY package.json ./
20
+ USER app
21
+ EXPOSE 3000
22
+ CMD ["node", "dist/main"]
@@ -0,0 +1,5 @@
1
+ {
2
+ "$schema": "https://json.schemastore.org/nest-cli",
3
+ "collection": "@nestjs/schematics",
4
+ "sourceRoot": "src"
5
+ }
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "{{projectName}}-api",
3
+ "version": "0.0.0",
4
+ "private": true,
5
+ "scripts": {
6
+ "dev": "nest start --watch",
7
+ "build": "nest build",
8
+ "start": "node dist/main",
9
+ "lint": "tsc -p tsconfig.json --noEmit",
10
+ "test": "jest"
11
+ },
12
+ "dependencies": {
13
+ "@nestjs/common": "^10.4.0",
14
+ "@nestjs/core": "^10.4.0",
15
+ "@nestjs/platform-express": "^10.4.0",
16
+ "class-transformer": "^0.5.1",
17
+ "class-validator": "^0.14.1",
18
+ "reflect-metadata": "^0.2.2",
19
+ "rxjs": "^7.8.1"
20
+ },
21
+ "devDependencies": {
22
+ "@nestjs/cli": "^10.4.0",
23
+ "@nestjs/testing": "^10.4.0",
24
+ "@types/express": "^4.17.21",
25
+ "@types/node": "^20.17.0",
26
+ "@types/supertest": "^6.0.2",
27
+ "jest": "^29.7.0",
28
+ "supertest": "^7.0.0",
29
+ "ts-jest": "^29.2.5",
30
+ "typescript": "^5.6.3"
31
+ }
32
+ }
@@ -0,0 +1,7 @@
1
+ import { Module } from "@nestjs/common";
2
+ import { HealthModule } from "./health/health.module";
3
+
4
+ @Module({
5
+ imports: [HealthModule],
6
+ })
7
+ export class AppModule {}
@@ -0,0 +1,43 @@
1
+ import { ArgumentsHost, Catch, ExceptionFilter, HttpException, HttpStatus } from "@nestjs/common";
2
+ import type { Request, Response } from "express";
3
+ import { AppException } from "./app-exception";
4
+
5
+ interface ErrorBody {
6
+ success: false;
7
+ error: {
8
+ code: string;
9
+ message: string;
10
+ statusCode: number;
11
+ path: string;
12
+ timestamp: string;
13
+ };
14
+ }
15
+
16
+ @Catch()
17
+ export class AllExceptionsFilter implements ExceptionFilter {
18
+ catch(exception: unknown, host: ArgumentsHost): void {
19
+ const ctx = host.switchToHttp();
20
+ const response = ctx.getResponse<Response>();
21
+ const request = ctx.getRequest<Request>();
22
+
23
+ let statusCode = HttpStatus.INTERNAL_SERVER_ERROR;
24
+ let code = "INTERNAL_ERROR";
25
+ let message = "Internal server error";
26
+
27
+ if (exception instanceof AppException) {
28
+ statusCode = exception.statusCode;
29
+ code = exception.code;
30
+ message = exception.message;
31
+ } else if (exception instanceof HttpException) {
32
+ statusCode = exception.getStatus();
33
+ code = "HTTP_ERROR";
34
+ message = exception.message;
35
+ }
36
+
37
+ const body: ErrorBody = {
38
+ success: false,
39
+ error: { code, message, statusCode, path: request.url, timestamp: new Date().toISOString() },
40
+ };
41
+ response.status(statusCode).json(body);
42
+ }
43
+ }
@@ -0,0 +1,12 @@
1
+ // Stable, language-independent error codes. The frontend branches on `code`,
2
+ // not on the human-readable message.
3
+ export class AppException extends Error {
4
+ constructor(
5
+ readonly code: string,
6
+ message: string,
7
+ readonly statusCode = 400,
8
+ ) {
9
+ super(message);
10
+ this.name = "AppException";
11
+ }
12
+ }
@@ -0,0 +1,18 @@
1
+ // Minimal typed environment validation. Extend as the app grows.
2
+ export interface AppEnv {
3
+ nodeEnv: string;
4
+ port: number;
5
+ corsOrigin: string | undefined;
6
+ }
7
+
8
+ export function validateEnv(env: NodeJS.ProcessEnv = process.env): AppEnv {
9
+ const port = Number(env.PORT ?? 3000);
10
+ if (Number.isNaN(port)) {
11
+ throw new Error(`Invalid PORT: ${env.PORT}`);
12
+ }
13
+ return {
14
+ nodeEnv: env.NODE_ENV ?? "development",
15
+ port,
16
+ corsOrigin: env.CORS_ORIGIN,
17
+ };
18
+ }
@@ -0,0 +1,13 @@
1
+ import { Controller, Get } from "@nestjs/common";
2
+
3
+ @Controller("health")
4
+ export class HealthController {
5
+ @Get()
6
+ liveness(): { status: string; uptime: number; timestamp: string } {
7
+ return {
8
+ status: "ok",
9
+ uptime: process.uptime(),
10
+ timestamp: new Date().toISOString(),
11
+ };
12
+ }
13
+ }
@@ -0,0 +1,7 @@
1
+ import { Module } from "@nestjs/common";
2
+ import { HealthController } from "./health.controller";
3
+
4
+ @Module({
5
+ controllers: [HealthController],
6
+ })
7
+ export class HealthModule {}
@@ -0,0 +1,21 @@
1
+ import { NestFactory } from "@nestjs/core";
2
+ import { ValidationPipe } from "@nestjs/common";
3
+ import { AppModule } from "./app.module";
4
+ import { AllExceptionsFilter } from "./common/all-exceptions.filter";
5
+
6
+ async function bootstrap(): Promise<void> {
7
+ const app = await NestFactory.create(AppModule);
8
+
9
+ app.useGlobalPipes(
10
+ new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }),
11
+ );
12
+ app.useGlobalFilters(new AllExceptionsFilter());
13
+
14
+ const corsOrigin = process.env.CORS_ORIGIN?.split(",").map((o) => o.trim());
15
+ app.enableCors({ origin: corsOrigin ?? true, credentials: true });
16
+
17
+ const port = Number(process.env.PORT ?? 3000);
18
+ await app.listen(port);
19
+ }
20
+
21
+ void bootstrap();
@@ -0,0 +1,23 @@
1
+ import { Test } from "@nestjs/testing";
2
+ import type { INestApplication } from "@nestjs/common";
3
+ import request from "supertest";
4
+ import { HealthModule } from "../src/health/health.module";
5
+
6
+ describe("Health (e2e)", () => {
7
+ let app: INestApplication;
8
+
9
+ beforeAll(async () => {
10
+ const moduleRef = await Test.createTestingModule({ imports: [HealthModule] }).compile();
11
+ app = moduleRef.createNestApplication();
12
+ await app.init();
13
+ });
14
+
15
+ afterAll(async () => {
16
+ await app.close();
17
+ });
18
+
19
+ it("GET /health returns ok", async () => {
20
+ const res = await request(app.getHttpServer()).get("/health").expect(200);
21
+ expect(res.body.status).toBe("ok");
22
+ });
23
+ });
@@ -0,0 +1,21 @@
1
+ {
2
+ "compilerOptions": {
3
+ "module": "CommonJS",
4
+ "target": "ES2022",
5
+ "moduleResolution": "Node",
6
+ "outDir": "dist",
7
+ "rootDir": "src",
8
+ "declaration": false,
9
+ "emitDecoratorMetadata": true,
10
+ "experimentalDecorators": true,
11
+ "strict": true,
12
+ "noUnusedLocals": true,
13
+ "noUnusedParameters": true,
14
+ "noImplicitReturns": true,
15
+ "esModuleInterop": true,
16
+ "skipLibCheck": true,
17
+ "forceConsistentCasingInFileNames": true
18
+ },
19
+ "include": ["src/**/*.ts"],
20
+ "exclude": ["node_modules", "dist", "test"]
21
+ }
@@ -0,0 +1,22 @@
1
+ # syntax=docker/dockerfile:1
2
+ FROM node:20-alpine AS deps
3
+ WORKDIR /app
4
+ COPY package.json ./
5
+ RUN npm install --no-audit --no-fund
6
+
7
+ FROM node:20-alpine AS build
8
+ WORKDIR /app
9
+ COPY --from=deps /app/node_modules ./node_modules
10
+ COPY . .
11
+ RUN npm run build
12
+
13
+ FROM node:20-alpine AS runtime
14
+ WORKDIR /app
15
+ ENV NODE_ENV=production
16
+ RUN addgroup -S app && adduser -S app -G app
17
+ COPY --from=build /app/build ./build
18
+ COPY --from=build /app/node_modules ./node_modules
19
+ COPY package.json ./
20
+ USER app
21
+ EXPOSE 3000
22
+ CMD ["node", "build"]
@@ -0,0 +1,15 @@
1
+ {
2
+ "$schema": "https://shadcn-svelte.com/schema.json",
3
+ "style": "new-york",
4
+ "tailwind": {
5
+ "css": "src/app.css",
6
+ "baseColor": "zinc"
7
+ },
8
+ "aliases": {
9
+ "components": "$lib/components",
10
+ "ui": "$lib/components/ui",
11
+ "utils": "$lib/utils"
12
+ },
13
+ "typescript": true,
14
+ "registry": "https://shadcn-svelte.com/registry"
15
+ }
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "{{projectName}}-web",
3
+ "version": "0.0.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite dev --port 5173",
8
+ "build": "vite build",
9
+ "preview": "vite preview",
10
+ "lint": "svelte-check --tsconfig ./tsconfig.json",
11
+ "test": "echo \"add Playwright tests here\" && exit 0"
12
+ },
13
+ "dependencies": {
14
+ "mode-watcher": "^0.5.0"
15
+ },
16
+ "devDependencies": {
17
+ "@sveltejs/adapter-node": "^5.2.0",
18
+ "@sveltejs/kit": "^2.8.0",
19
+ "@sveltejs/vite-plugin-svelte": "^4.0.0",
20
+ "@tailwindcss/vite": "^4.0.0",
21
+ "svelte": "^5.1.0",
22
+ "svelte-check": "^4.0.0",
23
+ "tailwindcss": "^4.0.0",
24
+ "typesafe-i18n": "^5.26.2",
25
+ "typescript": "^5.6.3",
26
+ "vite": "^5.4.0"
27
+ }
28
+ }
@@ -0,0 +1,17 @@
1
+ @import "tailwindcss";
2
+
3
+ /* Semantic tokens. shadcn-svelte components read these CSS variables. */
4
+ :root {
5
+ --background: oklch(1 0 0);
6
+ --foreground: oklch(0.15 0 0);
7
+ }
8
+
9
+ :root.dark {
10
+ --background: oklch(0.15 0 0);
11
+ --foreground: oklch(0.98 0 0);
12
+ }
13
+
14
+ body {
15
+ background-color: var(--background);
16
+ color: var(--foreground);
17
+ }
@@ -0,0 +1,11 @@
1
+ // See https://svelte.dev/docs/kit/types#app
2
+ declare global {
3
+ namespace App {
4
+ // interface Error {}
5
+ // interface Locals {}
6
+ // interface PageData {}
7
+ // interface Platform {}
8
+ }
9
+ }
10
+
11
+ export {};
@@ -0,0 +1,11 @@
1
+ <!doctype html>
2
+ <html lang="en" class="h-full">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ %sveltekit.head%
7
+ </head>
8
+ <body data-sveltekit-preload-data="hover" class="h-full">
9
+ <div style="display: contents">%sveltekit.body%</div>
10
+ </body>
11
+ </html>
@@ -0,0 +1,7 @@
1
+ # i18n
2
+
3
+ Locales use [typesafe-i18n](https://github.com/ivanhofer/typesafe-i18n).
4
+
5
+ - `en.ts` is the base locale; `ko.ts` mirrors its shape.
6
+ - Run `npx typesafe-i18n` to generate the typed runtime and Svelte store,
7
+ then use `$LL.appTitle()` in components. Add keys to every locale.
@@ -0,0 +1,10 @@
1
+ import type { BaseTranslation } from "typesafe-i18n";
2
+
3
+ // Base locale. Run `npx typesafe-i18n` to generate the typed runtime,
4
+ // then access strings as `$LL.appTitle()` in components.
5
+ const en = {
6
+ appTitle: "{{projectName}}",
7
+ checkHealth: "Check API health",
8
+ } satisfies BaseTranslation;
9
+
10
+ export default en;
@@ -0,0 +1,8 @@
1
+ import type { Translation } from "typesafe-i18n";
2
+
3
+ const ko = {
4
+ appTitle: "{{projectName}}",
5
+ checkHealth: "API 상태 확인",
6
+ } satisfies Translation;
7
+
8
+ export default ko;
@@ -0,0 +1,16 @@
1
+ // Server-side proxy boundary. The browser never talks to the API directly:
2
+ // only these allowlisted headers are forwarded to BACKEND_INTERNAL_URL.
3
+ const FORWARDED_HEADERS = ["authorization", "cookie", "content-type"];
4
+
5
+ export function backendBaseUrl(): string {
6
+ return process.env.BACKEND_INTERNAL_URL ?? "http://localhost:3000";
7
+ }
8
+
9
+ export function backendProxyHeaders(request: Request): Headers {
10
+ const headers = new Headers();
11
+ for (const name of FORWARDED_HEADERS) {
12
+ const value = request.headers.get(name);
13
+ if (value) headers.set(name, value);
14
+ }
15
+ return headers;
16
+ }
@@ -0,0 +1,9 @@
1
+ <script lang="ts">
2
+ import "../app.css";
3
+ import { ModeWatcher } from "mode-watcher";
4
+
5
+ let { children } = $props();
6
+ </script>
7
+
8
+ <ModeWatcher />
9
+ {@render children()}
@@ -0,0 +1,26 @@
1
+ <script lang="ts">
2
+ type Health = { status: string } | { error: string };
3
+
4
+ let health = $state<Health | null>(null);
5
+
6
+ async function check(): Promise<void> {
7
+ const res = await fetch("/api/health");
8
+ health = res.ok ? await res.json() : { error: `HTTP ${res.status}` };
9
+ }
10
+ </script>
11
+
12
+ <main class="mx-auto flex min-h-full max-w-2xl flex-col gap-6 p-8">
13
+ <h1 class="text-3xl font-bold">{{projectName}}</h1>
14
+ <p class="text-sm opacity-70">Full-stack starter generated with PodoKit.</p>
15
+
16
+ <button
17
+ class="w-fit rounded-md border border-current/20 px-4 py-2 text-sm font-medium hover:opacity-80"
18
+ onclick={check}
19
+ >
20
+ Check API health
21
+ </button>
22
+
23
+ {#if health}
24
+ <pre class="rounded-md bg-current/5 p-4 text-sm">{JSON.stringify(health, null, 2)}</pre>
25
+ {/if}
26
+ </main>
@@ -0,0 +1,12 @@
1
+ import type { RequestHandler } from "@sveltejs/kit";
2
+ import { backendBaseUrl, backendProxyHeaders } from "$lib/server/backend-proxy";
3
+
4
+ export const GET: RequestHandler = async ({ request }) => {
5
+ const upstream = await fetch(`${backendBaseUrl()}/health`, {
6
+ headers: backendProxyHeaders(request),
7
+ });
8
+ return new Response(await upstream.text(), {
9
+ status: upstream.status,
10
+ headers: { "content-type": "application/json" },
11
+ });
12
+ };
@@ -0,0 +1,15 @@
1
+ import adapter from "@sveltejs/adapter-node";
2
+ import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
3
+
4
+ /** @type {import('@sveltejs/kit').Config} */
5
+ const config = {
6
+ preprocess: vitePreprocess(),
7
+ kit: {
8
+ adapter: adapter(),
9
+ alias: {
10
+ $i18n: "src/lib/i18n",
11
+ },
12
+ },
13
+ };
14
+
15
+ export default config;
@@ -0,0 +1,9 @@
1
+ {
2
+ "extends": "./.svelte-kit/tsconfig.json",
3
+ "compilerOptions": {
4
+ "strict": true,
5
+ "moduleResolution": "bundler",
6
+ "esModuleInterop": true,
7
+ "skipLibCheck": true
8
+ }
9
+ }
@@ -0,0 +1,7 @@
1
+ import { sveltekit } from "@sveltejs/kit/vite";
2
+ import tailwindcss from "@tailwindcss/vite";
3
+ import { defineConfig } from "vite";
4
+
5
+ export default defineConfig({
6
+ plugins: [tailwindcss(), sveltekit()],
7
+ });
@@ -0,0 +1,16 @@
1
+ # Copy to .env and adjust. Never commit real secrets.
2
+ NODE_ENV=development
3
+ PORT=3000
4
+
5
+ POSTGRES_HOST=localhost
6
+ POSTGRES_PORT=5432
7
+ POSTGRES_USER=podokit
8
+ POSTGRES_PASSWORD=podokit
9
+ POSTGRES_DB=podokit
10
+
11
+ REDIS_HOST=localhost
12
+ REDIS_PORT=6379
13
+
14
+ # Web -> API boundary (server-side only; not exposed to the browser)
15
+ BACKEND_INTERNAL_URL=http://localhost:3000
16
+ CORS_ORIGIN=http://localhost:5173
@@ -0,0 +1,9 @@
1
+ node_modules/
2
+ dist/
3
+ build/
4
+ .svelte-kit/
5
+ .env
6
+ .env.*
7
+ !.env.example
8
+ .DS_Store
9
+ *.log
@@ -0,0 +1,29 @@
1
+ services:
2
+ postgres:
3
+ image: postgres:16-alpine
4
+ environment:
5
+ POSTGRES_USER: ${POSTGRES_USER:-podokit}
6
+ POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-podokit}
7
+ POSTGRES_DB: ${POSTGRES_DB:-podokit}
8
+ ports:
9
+ - "${POSTGRES_PORT:-5432}:5432"
10
+ volumes:
11
+ - pgdata:/var/lib/postgresql/data
12
+ healthcheck:
13
+ test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-podokit}"]
14
+ interval: 5s
15
+ timeout: 5s
16
+ retries: 5
17
+
18
+ redis:
19
+ image: redis:7-alpine
20
+ ports:
21
+ - "${REDIS_PORT:-6379}:6379"
22
+ healthcheck:
23
+ test: ["CMD", "redis-cli", "ping"]
24
+ interval: 5s
25
+ timeout: 5s
26
+ retries: 5
27
+
28
+ volumes:
29
+ pgdata:
@@ -0,0 +1,24 @@
1
+ apiVersion: apps/v1
2
+ kind: Deployment
3
+ metadata:
4
+ name: api
5
+ namespace: podokit
6
+ spec:
7
+ replicas: 1
8
+ selector:
9
+ matchLabels: { app: api }
10
+ template:
11
+ metadata:
12
+ labels: { app: api }
13
+ spec:
14
+ containers:
15
+ - name: api
16
+ image: ghcr.io/example/podokit-api:latest
17
+ ports:
18
+ - containerPort: 3000
19
+ envFrom:
20
+ - configMapRef: { name: app-config }
21
+ - secretRef: { name: app-secrets }
22
+ readinessProbe:
23
+ httpGet: { path: /health, port: 3000 }
24
+ initialDelaySeconds: 5
@@ -0,0 +1,10 @@
1
+ apiVersion: v1
2
+ kind: ConfigMap
3
+ metadata:
4
+ name: app-config
5
+ namespace: podokit
6
+ data:
7
+ NODE_ENV: "production"
8
+ PORT: "3000"
9
+ CORS_ORIGIN: "https://example.com"
10
+ BACKEND_INTERNAL_URL: "http://api:3000"
@@ -0,0 +1,18 @@
1
+ apiVersion: networking.k8s.io/v1
2
+ kind: Ingress
3
+ metadata:
4
+ name: app
5
+ namespace: podokit
6
+ spec:
7
+ rules:
8
+ - host: example.com
9
+ http:
10
+ paths:
11
+ - path: /api
12
+ pathType: Prefix
13
+ backend:
14
+ service: { name: api, port: { number: 3000 } }
15
+ - path: /
16
+ pathType: Prefix
17
+ backend:
18
+ service: { name: web, port: { number: 3000 } }
@@ -0,0 +1,4 @@
1
+ apiVersion: v1
2
+ kind: Namespace
3
+ metadata:
4
+ name: podokit
@@ -0,0 +1,10 @@
1
+ # Example only. Never commit real secrets. Create the real Secret out-of-band:
2
+ # kubectl -n podokit create secret generic app-secrets --from-literal=POSTGRES_PASSWORD=...
3
+ apiVersion: v1
4
+ kind: Secret
5
+ metadata:
6
+ name: app-secrets
7
+ namespace: podokit
8
+ type: Opaque
9
+ stringData:
10
+ POSTGRES_PASSWORD: "change-me"
@@ -0,0 +1,21 @@
1
+ apiVersion: v1
2
+ kind: Service
3
+ metadata:
4
+ name: api
5
+ namespace: podokit
6
+ spec:
7
+ selector: { app: api }
8
+ ports:
9
+ - port: 3000
10
+ targetPort: 3000
11
+ ---
12
+ apiVersion: v1
13
+ kind: Service
14
+ metadata:
15
+ name: web
16
+ namespace: podokit
17
+ spec:
18
+ selector: { app: web }
19
+ ports:
20
+ - port: 3000
21
+ targetPort: 3000
@@ -0,0 +1,20 @@
1
+ apiVersion: apps/v1
2
+ kind: Deployment
3
+ metadata:
4
+ name: web
5
+ namespace: podokit
6
+ spec:
7
+ replicas: 1
8
+ selector:
9
+ matchLabels: { app: web }
10
+ template:
11
+ metadata:
12
+ labels: { app: web }
13
+ spec:
14
+ containers:
15
+ - name: web
16
+ image: ghcr.io/example/podokit-web:latest
17
+ ports:
18
+ - containerPort: 3000
19
+ envFrom:
20
+ - configMapRef: { name: app-config }
@@ -0,0 +1,13 @@
1
+ {
2
+ "name": "{{projectName}}",
3
+ "version": "0.0.0",
4
+ "private": true,
5
+ "engines": { "node": ">=20" },
6
+ "workspaces": ["apps/*"],
7
+ "scripts": {
8
+ "dev": "npm run dev --workspaces --if-present",
9
+ "build": "npm run build --workspaces --if-present",
10
+ "lint": "npm run lint --workspaces --if-present",
11
+ "test": "npm run test --workspaces --if-present"
12
+ }
13
+ }
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "@podosoft/podokit",
3
+ "version": "0.1.0",
4
+ "description": "An opinionated but extensible starter toolkit and CLI for full-stack TypeScript apps with NestJS, SvelteKit, TailwindCSS, shadcn-svelte, Docker, and k3s.",
5
+ "license": "Apache-2.0",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/podosoft-dev/podokit.git",
9
+ "directory": "packages/cli"
10
+ },
11
+ "keywords": ["nestjs", "sveltekit", "starter", "cli", "scaffold", "tailwindcss", "shadcn"],
12
+ "bin": {
13
+ "podo": "dist/index.js"
14
+ },
15
+ "files": ["dist"],
16
+ "engines": {
17
+ "node": ">=20"
18
+ },
19
+ "publishConfig": {
20
+ "access": "public",
21
+ "provenance": true
22
+ },
23
+ "scripts": {
24
+ "build": "tsc -p tsconfig.json && node scripts/copy-templates.mjs",
25
+ "lint": "tsc -p tsconfig.json --noEmit",
26
+ "test": "vitest run"
27
+ },
28
+ "dependencies": {
29
+ "@podosoft/podokit-template-engine": "^0.1.0"
30
+ }
31
+ }