create-easy-stack 1.0.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/bin/create.mjs +244 -0
- package/infra/email/files/src/core/config/email.env.ts +73 -0
- package/infra/email/files/src/core/infrastructure/email/email-dispatcher.port.ts +13 -0
- package/infra/email/files/src/core/infrastructure/email/email-layout.spec.ts +83 -0
- package/infra/email/files/src/core/infrastructure/email/email-layout.ts +88 -0
- package/infra/email/files/src/core/infrastructure/email/email-sender.factory.spec.ts +87 -0
- package/infra/email/files/src/core/infrastructure/email/email-sender.factory.ts +35 -0
- package/infra/email/files/src/core/infrastructure/email/email-sender.port.ts +19 -0
- package/infra/email/files/src/core/infrastructure/email/email.job.spec.ts +101 -0
- package/infra/email/files/src/core/infrastructure/email/email.job.ts +64 -0
- package/infra/email/files/src/core/infrastructure/email/email.module.spec.ts +60 -0
- package/infra/email/files/src/core/infrastructure/email/email.module.ts +34 -0
- package/infra/email/files/src/core/infrastructure/email/log-email-sender.spec.ts +34 -0
- package/infra/email/files/src/core/infrastructure/email/log-email-sender.ts +17 -0
- package/infra/email/files/src/core/infrastructure/email/smtp-email-sender.spec.ts +99 -0
- package/infra/email/files/src/core/infrastructure/email/smtp-email-sender.ts +63 -0
- package/infra/email/infra.json +58 -0
- package/infra/prisma-postgres/docker.service.yml +12 -0
- package/infra/prisma-postgres/files/prisma/schema.prisma +10 -0
- package/infra/prisma-postgres/files/prisma.config.mjs +10 -0
- package/infra/prisma-postgres/files/src/core/config/database.env.ts +13 -0
- package/infra/prisma-postgres/files/src/core/infrastructure/database/database.module.ts +9 -0
- package/infra/prisma-postgres/files/src/core/infrastructure/database/prisma.service.ts +26 -0
- package/infra/prisma-postgres/infra.json +60 -0
- package/infra/rabbitmq/docker.service.yml +16 -0
- package/infra/rabbitmq/files/src/core/config/messaging.env.ts +29 -0
- package/infra/rabbitmq/files/src/core/infrastructure/messaging/jobs/inline-job-queue.spec.ts +60 -0
- package/infra/rabbitmq/files/src/core/infrastructure/messaging/jobs/inline-job-queue.ts +33 -0
- package/infra/rabbitmq/files/src/core/infrastructure/messaging/jobs/job-failure.spec.ts +23 -0
- package/infra/rabbitmq/files/src/core/infrastructure/messaging/jobs/job-failure.ts +19 -0
- package/infra/rabbitmq/files/src/core/infrastructure/messaging/jobs/job-payload-cipher.spec.ts +80 -0
- package/infra/rabbitmq/files/src/core/infrastructure/messaging/jobs/job-payload-cipher.ts +66 -0
- package/infra/rabbitmq/files/src/core/infrastructure/messaging/jobs/job-queue.boundary.spec.ts +34 -0
- package/infra/rabbitmq/files/src/core/infrastructure/messaging/jobs/job-queue.factory.ts +21 -0
- package/infra/rabbitmq/files/src/core/infrastructure/messaging/jobs/job-queue.module.spec.ts +75 -0
- package/infra/rabbitmq/files/src/core/infrastructure/messaging/jobs/job-queue.module.ts +30 -0
- package/infra/rabbitmq/files/src/core/infrastructure/messaging/jobs/job-queue.port.ts +59 -0
- package/infra/rabbitmq/files/src/core/infrastructure/messaging/jobs/rabbitmq-job-queue.spec.ts +510 -0
- package/infra/rabbitmq/files/src/core/infrastructure/messaging/jobs/rabbitmq-job-queue.ts +353 -0
- package/infra/rabbitmq/files/src/core/infrastructure/messaging/rabbitmq/rabbitmq.module.ts +9 -0
- package/infra/rabbitmq/files/src/core/infrastructure/messaging/rabbitmq/rabbitmq.service.spec.ts +289 -0
- package/infra/rabbitmq/files/src/core/infrastructure/messaging/rabbitmq/rabbitmq.service.ts +207 -0
- package/infra/rabbitmq/infra.json +55 -0
- package/infra/redis/docker.service.yml +10 -0
- package/infra/redis/files/src/core/http/decorators/rate-limit.decorator.ts +26 -0
- package/infra/redis/files/src/core/http/interceptors/rate-limit.interceptor.spec.ts +180 -0
- package/infra/redis/files/src/core/http/interceptors/rate-limit.interceptor.ts +105 -0
- package/infra/redis/files/src/core/infrastructure/redis/cache.constants.ts +67 -0
- package/infra/redis/files/src/core/infrastructure/redis/idempotency/idempotency.module.ts +9 -0
- package/infra/redis/files/src/core/infrastructure/redis/idempotency/idempotency.service.spec.ts +146 -0
- package/infra/redis/files/src/core/infrastructure/redis/idempotency/idempotency.service.ts +131 -0
- package/infra/redis/files/src/core/infrastructure/redis/lock/lock.module.ts +9 -0
- package/infra/redis/files/src/core/infrastructure/redis/lock/lock.service.integration-spec.ts +111 -0
- package/infra/redis/files/src/core/infrastructure/redis/lock/lock.service.ts +82 -0
- package/infra/redis/files/src/core/infrastructure/redis/rate-limit/rate-limit.module.ts +15 -0
- package/infra/redis/files/src/core/infrastructure/redis/rate-limit/rate-limit.service.spec.ts +45 -0
- package/infra/redis/files/src/core/infrastructure/redis/rate-limit/rate-limit.service.ts +38 -0
- package/infra/redis/files/src/core/infrastructure/redis/redis.module.ts +9 -0
- package/infra/redis/files/src/core/infrastructure/redis/redis.service.ts +196 -0
- package/infra/redis/infra.json +53 -0
- package/infra/storage-cloudinary/files/src/core/config/storage.env.ts +43 -0
- package/infra/storage-cloudinary/files/src/core/infrastructure/storage/cloudinary-image-storage.spec.ts +275 -0
- package/infra/storage-cloudinary/files/src/core/infrastructure/storage/cloudinary-image-storage.ts +158 -0
- package/infra/storage-cloudinary/files/src/core/infrastructure/storage/image-storage.factory.ts +30 -0
- package/infra/storage-cloudinary/files/src/core/infrastructure/storage/image-storage.module.ts +17 -0
- package/infra/storage-cloudinary/files/src/core/infrastructure/storage/image-storage.port.ts +38 -0
- package/infra/storage-cloudinary/files/src/core/infrastructure/storage/image-type.spec.ts +48 -0
- package/infra/storage-cloudinary/files/src/core/infrastructure/storage/image-type.ts +25 -0
- package/infra/storage-cloudinary/files/src/core/infrastructure/storage/unconfigured-image-storage.ts +19 -0
- package/infra/storage-cloudinary/infra.json +73 -0
- package/lib/fs-utils.mjs +115 -0
- package/lib/registry.mjs +50 -0
- package/lib/scaffold.mjs +157 -0
- package/package.json +37 -0
- package/templates/nest-ddd/files/.env.example +5 -0
- package/templates/nest-ddd/files/.husky/commit-msg +1 -0
- package/templates/nest-ddd/files/.husky/pre-commit +1 -0
- package/templates/nest-ddd/files/.oxlintrc.json +10 -0
- package/templates/nest-ddd/files/.prettierrc +4 -0
- package/templates/nest-ddd/files/_gitignore +40 -0
- package/templates/nest-ddd/files/commitlint.config.js +3 -0
- package/templates/nest-ddd/files/nest-cli.json +8 -0
- package/templates/nest-ddd/files/package.json +66 -0
- package/templates/nest-ddd/files/plop-templates/command/command.ts.hbs +5 -0
- package/templates/nest-ddd/files/plop-templates/command/handler.ts.hbs +30 -0
- package/templates/nest-ddd/files/plop-templates/module/application/commands/create-{{kebab}}.command.ts.hbs +5 -0
- package/templates/nest-ddd/files/plop-templates/module/application/commands/create-{{kebab}}.handler.spec.ts.hbs +35 -0
- package/templates/nest-ddd/files/plop-templates/module/application/commands/create-{{kebab}}.handler.ts.hbs +25 -0
- package/templates/nest-ddd/files/plop-templates/module/application/index.ts.hbs +5 -0
- package/templates/nest-ddd/files/plop-templates/module/application/queries/get-{{kebab}}-by-id.handler.ts.hbs +29 -0
- package/templates/nest-ddd/files/plop-templates/module/application/queries/get-{{kebab}}-by-id.query.ts.hbs +5 -0
- package/templates/nest-ddd/files/plop-templates/module/domain/entities/{{kebab}}.entity.ts.hbs +37 -0
- package/templates/nest-ddd/files/plop-templates/module/domain/events/{{kebab}}-created.event.ts.hbs +10 -0
- package/templates/nest-ddd/files/plop-templates/module/domain/exceptions/{{kebab}}.exception.ts.hbs +11 -0
- package/templates/nest-ddd/files/plop-templates/module/domain/index.ts.hbs +4 -0
- package/templates/nest-ddd/files/plop-templates/module/domain/repositories/{{kebab}}.repository.ts.hbs +7 -0
- package/templates/nest-ddd/files/plop-templates/module/presentation/controllers/{{kebab}}.controller.ts.hbs +53 -0
- package/templates/nest-ddd/files/plop-templates/module/presentation/dtos/create-{{kebab}}.dto.ts.hbs +10 -0
- package/templates/nest-ddd/files/plop-templates/module/presentation/dtos/{{kebab}}-response.dto.ts.hbs +15 -0
- package/templates/nest-ddd/files/plop-templates/module/presentation/index.ts.hbs +4 -0
- package/templates/nest-ddd/files/plop-templates/module/presentation/mappers/{{kebab}}.mapper.ts.hbs +13 -0
- package/templates/nest-ddd/files/plop-templates/module/{{kebab}}.module.ts.hbs +40 -0
- package/templates/nest-ddd/files/plop-templates/persistence-memory/infrastructure/database/repositories/in-memory-{{kebab}}.repository.ts.hbs +17 -0
- package/templates/nest-ddd/files/plop-templates/persistence-memory/infrastructure/index.ts.hbs +1 -0
- package/templates/nest-ddd/files/plop-templates/persistence-prisma/infrastructure/database/mappers/{{kebab}}.mapper.ts.hbs +29 -0
- package/templates/nest-ddd/files/plop-templates/persistence-prisma/infrastructure/database/repositories/prisma-{{kebab}}.repository.ts.hbs +28 -0
- package/templates/nest-ddd/files/plop-templates/persistence-prisma/infrastructure/index.ts.hbs +2 -0
- package/templates/nest-ddd/files/plop-templates/query/handler.ts.hbs +28 -0
- package/templates/nest-ddd/files/plop-templates/query/query.ts.hbs +5 -0
- package/templates/nest-ddd/files/plop-templates/snippets/error-codes.hbs +14 -0
- package/templates/nest-ddd/files/plop-templates/snippets/prisma-model.hbs +7 -0
- package/templates/nest-ddd/files/plopfile.mjs +186 -0
- package/templates/nest-ddd/files/src/app.controller.ts +13 -0
- package/templates/nest-ddd/files/src/app.module.ts +26 -0
- package/templates/nest-ddd/files/src/core/config/app.config.ts +9 -0
- package/templates/nest-ddd/files/src/core/config/env.validation.ts +30 -0
- package/templates/nest-ddd/files/src/core/domain/base/aggregate-root.base.ts +18 -0
- package/templates/nest-ddd/files/src/core/domain/base/domain-event.base.ts +7 -0
- package/templates/nest-ddd/files/src/core/domain/base/entity.base.ts +36 -0
- package/templates/nest-ddd/files/src/core/domain/base/value-object.base.ts +17 -0
- package/templates/nest-ddd/files/src/core/domain/exceptions/domain.exception.ts +16 -0
- package/templates/nest-ddd/files/src/core/domain/exceptions/error-codes.ts +28 -0
- package/templates/nest-ddd/files/src/core/domain/types/pagination.ts +9 -0
- package/templates/nest-ddd/files/src/core/http/decorators/client-info.decorator.ts +24 -0
- package/templates/nest-ddd/files/src/core/http/decorators/current-user.decorator.ts +19 -0
- package/templates/nest-ddd/files/src/core/http/dtos/paginated-response.dto.ts +29 -0
- package/templates/nest-ddd/files/src/core/http/dtos/pagination-query.dto.ts +17 -0
- package/templates/nest-ddd/files/src/core/http/filters/all-exceptions.filter.spec.ts +163 -0
- package/templates/nest-ddd/files/src/core/http/filters/all-exceptions.filter.ts +97 -0
- package/templates/nest-ddd/files/src/core/http/guards/max-body-size.guard.spec.ts +47 -0
- package/templates/nest-ddd/files/src/core/http/guards/max-body-size.guard.ts +31 -0
- package/templates/nest-ddd/files/src/core/http/interceptors/logging.interceptor.ts +37 -0
- package/templates/nest-ddd/files/src/core/http/interceptors/response.interceptor.ts +53 -0
- package/templates/nest-ddd/files/src/core/http/logging/app.logger.ts +34 -0
- package/templates/nest-ddd/files/src/core/index.ts +33 -0
- package/templates/nest-ddd/files/src/core/utils/correlation-id.util.spec.ts +29 -0
- package/templates/nest-ddd/files/src/core/utils/correlation-id.util.ts +15 -0
- package/templates/nest-ddd/files/src/core/utils/hash.util.spec.ts +61 -0
- package/templates/nest-ddd/files/src/core/utils/hash.util.ts +47 -0
- package/templates/nest-ddd/files/src/core/utils/snowflake.util.spec.ts +40 -0
- package/templates/nest-ddd/files/src/core/utils/snowflake.util.ts +99 -0
- package/templates/nest-ddd/files/src/core/utils/string.util.spec.ts +20 -0
- package/templates/nest-ddd/files/src/core/utils/string.util.ts +21 -0
- package/templates/nest-ddd/files/src/core/utils/time.util.spec.ts +25 -0
- package/templates/nest-ddd/files/src/core/utils/time.util.ts +25 -0
- package/templates/nest-ddd/files/src/core/utils/ttl.util.ts +22 -0
- package/templates/nest-ddd/files/src/main.ts +61 -0
- package/templates/nest-ddd/files/test/setup.ts +6 -0
- package/templates/nest-ddd/files/tsconfig.build.json +9 -0
- package/templates/nest-ddd/files/tsconfig.json +28 -0
- package/templates/nest-ddd/files/vitest.config.e2e.ts +15 -0
- package/templates/nest-ddd/files/vitest.config.ts +16 -0
- package/templates/nest-ddd/template.json +8 -0
- package/templates/node-express/files/.env.example +28 -0
- package/templates/node-express/files/docker-compose.yml +52 -0
- package/templates/node-express/files/package-lock.json +11880 -0
- package/templates/node-express/files/package.json +43 -0
- package/templates/node-express/files/server.js +13 -0
- package/templates/node-express/files/src/app.js +72 -0
- package/templates/node-express/files/src/auth/authUtils.js +116 -0
- package/templates/node-express/files/src/auth/checkAuth.js +61 -0
- package/templates/node-express/files/src/bucket_pattern/comment.model.js +27 -0
- package/templates/node-express/files/src/bucket_pattern/comment.service.js +50 -0
- package/templates/node-express/files/src/config/config.aws-s3.js +28 -0
- package/templates/node-express/files/src/config/config.cloudinary.js +13 -0
- package/templates/node-express/files/src/config/config.mongodb.js +25 -0
- package/templates/node-express/files/src/config/config.multer.js +25 -0
- package/templates/node-express/files/src/config/config.nodemailer.js +16 -0
- package/templates/node-express/files/src/controllers/access.controller.js +49 -0
- package/templates/node-express/files/src/controllers/cart.controller.js +38 -0
- package/templates/node-express/files/src/controllers/checkout.controller.js +15 -0
- package/templates/node-express/files/src/controllers/comment.controller.js +29 -0
- package/templates/node-express/files/src/controllers/discount.controller.js +68 -0
- package/templates/node-express/files/src/controllers/email.controller.js +16 -0
- package/templates/node-express/files/src/controllers/inventory.controller.js +15 -0
- package/templates/node-express/files/src/controllers/notification.controller.js +15 -0
- package/templates/node-express/files/src/controllers/product.controller.js +175 -0
- package/templates/node-express/files/src/controllers/profile.controller.js +65 -0
- package/templates/node-express/files/src/controllers/rbac.controller.js +41 -0
- package/templates/node-express/files/src/controllers/template.controller.js +20 -0
- package/templates/node-express/files/src/controllers/upload.controller.js +56 -0
- package/templates/node-express/files/src/controllers/user.controller.js +36 -0
- package/templates/node-express/files/src/core/error.response.js +84 -0
- package/templates/node-express/files/src/core/success.response.js +44 -0
- package/templates/node-express/files/src/database/init.mongodb.level0.js +16 -0
- package/templates/node-express/files/src/database/init.mongodb.level1.js +36 -0
- package/templates/node-express/files/src/database/init.redis.js +119 -0
- package/templates/node-express/files/src/helpers/asyncHandler.js +7 -0
- package/templates/node-express/files/src/helpers/check.connect.js +29 -0
- package/templates/node-express/files/src/keys/private_key.pem +28 -0
- package/templates/node-express/files/src/keys/public_key.pem +9 -0
- package/templates/node-express/files/src/logger/discord.log.js +26 -0
- package/templates/node-express/files/src/logger/discord.log.v2.js +56 -0
- package/templates/node-express/files/src/logger/mylogger.log.js +130 -0
- package/templates/node-express/files/src/logger/winston.log.js +27 -0
- package/templates/node-express/files/src/middlewares/index.js +21 -0
- package/templates/node-express/files/src/middlewares/rbac.js +37 -0
- package/templates/node-express/files/src/middlewares/role.middleware.js +12 -0
- package/templates/node-express/files/src/models/KeyToken.model.js +37 -0
- package/templates/node-express/files/src/models/apiKey.model.js +31 -0
- package/templates/node-express/files/src/models/cart.model.js +47 -0
- package/templates/node-express/files/src/models/comment.model.js +43 -0
- package/templates/node-express/files/src/models/discount.model.js +92 -0
- package/templates/node-express/files/src/models/inventory.model.js +42 -0
- package/templates/node-express/files/src/models/notification.model.js +52 -0
- package/templates/node-express/files/src/models/order.model.js +68 -0
- package/templates/node-express/files/src/models/otp.model.js +32 -0
- package/templates/node-express/files/src/models/product.model.js +144 -0
- package/templates/node-express/files/src/models/repository/cart.repo.js +71 -0
- package/templates/node-express/files/src/models/repository/discount.repo.js +63 -0
- package/templates/node-express/files/src/models/repository/inventory.repo.js +45 -0
- package/templates/node-express/files/src/models/repository/product.repo.js +145 -0
- package/templates/node-express/files/src/models/repository/shop.repo.js +18 -0
- package/templates/node-express/files/src/models/repository/spu.repo.js +18 -0
- package/templates/node-express/files/src/models/repository/user.repo.js +20 -0
- package/templates/node-express/files/src/models/resource.model.js +32 -0
- package/templates/node-express/files/src/models/role.model.js +57 -0
- package/templates/node-express/files/src/models/shop.model.js +43 -0
- package/templates/node-express/files/src/models/sku.model.js +81 -0
- package/templates/node-express/files/src/models/spu.model.js +101 -0
- package/templates/node-express/files/src/models/template.model.js +25 -0
- package/templates/node-express/files/src/models/user.model.js +82 -0
- package/templates/node-express/files/src/postman/access.post.http +22 -0
- package/templates/node-express/files/src/routes/access/index.route.js +21 -0
- package/templates/node-express/files/src/routes/cart/index.route.js +20 -0
- package/templates/node-express/files/src/routes/checkout/index.route.js +10 -0
- package/templates/node-express/files/src/routes/comment/index.route.js +16 -0
- package/templates/node-express/files/src/routes/discount/index.route.js +26 -0
- package/templates/node-express/files/src/routes/index.route.js +27 -0
- package/templates/node-express/files/src/routes/inventory/index.route.js +11 -0
- package/templates/node-express/files/src/routes/notification/index.route.js +14 -0
- package/templates/node-express/files/src/routes/product/index.route.js +38 -0
- package/templates/node-express/files/src/routes/profile/index.route.js +15 -0
- package/templates/node-express/files/src/routes/rbac/index.route.js +21 -0
- package/templates/node-express/files/src/routes/template/index.route.js +11 -0
- package/templates/node-express/files/src/routes/upload/index.route.js +28 -0
- package/templates/node-express/files/src/routes/user/index.route.js +13 -0
- package/templates/node-express/files/src/services/access.service.js +257 -0
- package/templates/node-express/files/src/services/apiKey.service.js +15 -0
- package/templates/node-express/files/src/services/cart.service.js +115 -0
- package/templates/node-express/files/src/services/checkout.service.js +218 -0
- package/templates/node-express/files/src/services/comment.service.js +185 -0
- package/templates/node-express/files/src/services/discount.service.js +282 -0
- package/templates/node-express/files/src/services/email.service.js +71 -0
- package/templates/node-express/files/src/services/inventory.service.js +35 -0
- package/templates/node-express/files/src/services/keyToken.service.js +77 -0
- package/templates/node-express/files/src/services/notification.service.js +58 -0
- package/templates/node-express/files/src/services/otp.service.js +36 -0
- package/templates/node-express/files/src/services/product.service.js +228 -0
- package/templates/node-express/files/src/services/rbac.service.js +143 -0
- package/templates/node-express/files/src/services/redis.service.js +57 -0
- package/templates/node-express/files/src/services/redisPubSub.service.js +72 -0
- package/templates/node-express/files/src/services/shop.service.js +21 -0
- package/templates/node-express/files/src/services/sku.service.js +52 -0
- package/templates/node-express/files/src/services/spu.service.js +89 -0
- package/templates/node-express/files/src/services/template.service.js +41 -0
- package/templates/node-express/files/src/services/upload.s3.service.js +55 -0
- package/templates/node-express/files/src/services/upload.service.js +74 -0
- package/templates/node-express/files/src/services/user.service.js +92 -0
- package/templates/node-express/files/src/temp/1734420604996-ci-cd.png +0 -0
- package/templates/node-express/files/src/temp/1734421222213-cover-fb.jpg +0 -0
- package/templates/node-express/files/src/temp/1734421222214-cover-fb-01.jpg +0 -0
- package/templates/node-express/files/src/temp/1734421293437-cover-fb-01.jpg +0 -0
- package/templates/node-express/files/src/temp/1734421293437-cover-fb.jpg +0 -0
- package/templates/node-express/files/src/tests/inventory.test.js +21 -0
- package/templates/node-express/files/src/tests/message_queue/kafka/consumer.js +23 -0
- package/templates/node-express/files/src/tests/message_queue/kafka/producer.js +21 -0
- package/templates/node-express/files/src/tests/message_queue/rabbitMQ/consumer.js +28 -0
- package/templates/node-express/files/src/tests/message_queue/rabbitMQ/ordered.consumer.js +26 -0
- package/templates/node-express/files/src/tests/message_queue/rabbitMQ/ordered.producer.js +31 -0
- package/templates/node-express/files/src/tests/message_queue/rabbitMQ/producer.js +26 -0
- package/templates/node-express/files/src/tests/message_queue/rabbitMQ/producerDLX.js +44 -0
- package/templates/node-express/files/src/tests/mysql/connect.js +59 -0
- package/templates/node-express/files/src/tests/product.test.js +14 -0
- package/templates/node-express/files/src/utils/index.js +78 -0
- package/templates/node-express/files/src/utils/reasonPhrases.js +344 -0
- package/templates/node-express/files/src/utils/statusCodes.js +344 -0
- package/templates/node-express/files/src/utils/template.html.js +38 -0
- package/templates/node-express/template.json +11 -0
- package/templates/react-fsd/files/index.html +13 -0
- package/templates/react-fsd/files/package.json +15 -0
- package/templates/react-fsd/files/public/favicon.svg +1 -0
- package/templates/react-fsd/files/public/icons.svg +24 -0
- package/templates/react-fsd/files/src/assets/hero.png +0 -0
- package/templates/react-fsd/files/src/assets/typescript.svg +1 -0
- package/templates/react-fsd/files/src/assets/vite.svg +1 -0
- package/templates/react-fsd/files/src/counter.ts +9 -0
- package/templates/react-fsd/files/src/main.ts +60 -0
- package/templates/react-fsd/files/src/style.css +296 -0
- package/templates/react-fsd/files/tsconfig.json +24 -0
- package/templates/react-fsd/template.json +11 -0
- package/templates/react-monolithic/files/index.html +13 -0
- package/templates/react-monolithic/files/package.json +15 -0
- package/templates/react-monolithic/files/public/favicon.svg +1 -0
- package/templates/react-monolithic/files/public/icons.svg +24 -0
- package/templates/react-monolithic/files/src/assets/hero.png +0 -0
- package/templates/react-monolithic/files/src/assets/typescript.svg +1 -0
- package/templates/react-monolithic/files/src/assets/vite.svg +1 -0
- package/templates/react-monolithic/files/src/counter.ts +9 -0
- package/templates/react-monolithic/files/src/main.ts +60 -0
- package/templates/react-monolithic/files/src/style.css +296 -0
- package/templates/react-monolithic/files/tsconfig.json +24 -0
- package/templates/react-monolithic/template.json +11 -0
- package/templates/vue-fsd/files/index.html +13 -0
- package/templates/vue-fsd/files/package.json +15 -0
- package/templates/vue-fsd/files/public/favicon.svg +1 -0
- package/templates/vue-fsd/files/public/icons.svg +24 -0
- package/templates/vue-fsd/files/src/assets/hero.png +0 -0
- package/templates/vue-fsd/files/src/assets/typescript.svg +1 -0
- package/templates/vue-fsd/files/src/assets/vite.svg +1 -0
- package/templates/vue-fsd/files/src/counter.ts +9 -0
- package/templates/vue-fsd/files/src/main.ts +60 -0
- package/templates/vue-fsd/files/src/style.css +296 -0
- package/templates/vue-fsd/files/tsconfig.json +24 -0
- package/templates/vue-fsd/template.json +11 -0
- package/templates/vue-monolithic/files/index.html +13 -0
- package/templates/vue-monolithic/files/package.json +15 -0
- package/templates/vue-monolithic/files/public/favicon.svg +1 -0
- package/templates/vue-monolithic/files/public/icons.svg +24 -0
- package/templates/vue-monolithic/files/src/assets/hero.png +0 -0
- package/templates/vue-monolithic/files/src/assets/typescript.svg +1 -0
- package/templates/vue-monolithic/files/src/assets/vite.svg +1 -0
- package/templates/vue-monolithic/files/src/counter.ts +9 -0
- package/templates/vue-monolithic/files/src/main.ts +60 -0
- package/templates/vue-monolithic/files/src/style.css +296 -0
- package/templates/vue-monolithic/files/tsconfig.json +24 -0
- package/templates/vue-monolithic/template.json +11 -0
package/infra/storage-cloudinary/files/src/core/infrastructure/storage/cloudinary-image-storage.ts
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { Logger } from '@nestjs/common';
|
|
2
|
+
import { v2 as cloudinary } from 'cloudinary';
|
|
3
|
+
import { DomainException } from '../../domain/exceptions/domain.exception.js';
|
|
4
|
+
import { StorageErrorCodes } from '../../domain/exceptions/error-codes.js';
|
|
5
|
+
import type {
|
|
6
|
+
IImageStorage,
|
|
7
|
+
ImageFit,
|
|
8
|
+
ImageType,
|
|
9
|
+
ImageUpload,
|
|
10
|
+
} from './image-storage.port.js';
|
|
11
|
+
|
|
12
|
+
export interface CloudinarySettings {
|
|
13
|
+
cloudName: string;
|
|
14
|
+
apiKey: string;
|
|
15
|
+
apiSecret: string;
|
|
16
|
+
/** Everything of this app lives under this folder of the Cloudinary account. */
|
|
17
|
+
folder: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The two calls to Cloudinary that are needed; a stand-in replaces them in tests. */
|
|
21
|
+
export interface CloudinaryClient {
|
|
22
|
+
upload(
|
|
23
|
+
image: Buffer,
|
|
24
|
+
options: Record<string, unknown>,
|
|
25
|
+
): Promise<{ secure_url?: string }>;
|
|
26
|
+
destroy(publicId: string, options: Record<string, unknown>): Promise<unknown>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const UPLOAD_TIMEOUT_MS = 15_000;
|
|
30
|
+
// Cloudinary answers 400 for a file it cannot read as an image
|
|
31
|
+
const CLOUDINARY_BAD_IMAGE = 400;
|
|
32
|
+
// Names separated by single slashes; keeps a caller from reaching outside its folder
|
|
33
|
+
const PATH_PATTERN = /^[A-Za-z0-9_-]+(\/[A-Za-z0-9_-]+)*$/;
|
|
34
|
+
|
|
35
|
+
export const DEFAULT_IMAGE_FOLDER = '{{PROJECT_NAME}}';
|
|
36
|
+
|
|
37
|
+
/** The real Cloudinary, using the official SDK; nothing is written to disk. */
|
|
38
|
+
export function createCloudinaryClient(
|
|
39
|
+
settings: CloudinarySettings,
|
|
40
|
+
): CloudinaryClient {
|
|
41
|
+
cloudinary.config({
|
|
42
|
+
cloud_name: settings.cloudName,
|
|
43
|
+
api_key: settings.apiKey,
|
|
44
|
+
api_secret: settings.apiSecret,
|
|
45
|
+
secure: true,
|
|
46
|
+
});
|
|
47
|
+
return {
|
|
48
|
+
upload: (image, options) =>
|
|
49
|
+
new Promise((resolve, reject) => {
|
|
50
|
+
cloudinary.uploader
|
|
51
|
+
.upload_stream(options, (error, result) =>
|
|
52
|
+
error || !result
|
|
53
|
+
? reject(error ?? new Error('Cloudinary gave no result'))
|
|
54
|
+
: resolve(result),
|
|
55
|
+
)
|
|
56
|
+
.end(image);
|
|
57
|
+
}),
|
|
58
|
+
destroy: (publicId, options) =>
|
|
59
|
+
cloudinary.uploader.destroy(publicId, options),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export class CloudinaryImageStorage implements IImageStorage {
|
|
64
|
+
private readonly logger = new Logger(CloudinaryImageStorage.name);
|
|
65
|
+
private readonly addressStart: string;
|
|
66
|
+
|
|
67
|
+
constructor(
|
|
68
|
+
private readonly settings: CloudinarySettings,
|
|
69
|
+
private readonly client: CloudinaryClient = createCloudinaryClient(
|
|
70
|
+
settings,
|
|
71
|
+
),
|
|
72
|
+
) {
|
|
73
|
+
this.addressStart = `https://res.cloudinary.com/${settings.cloudName}/`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async upload(request: ImageUpload): Promise<string> {
|
|
77
|
+
const publicId = this.publicId(request.path);
|
|
78
|
+
let result: { secure_url?: string };
|
|
79
|
+
try {
|
|
80
|
+
result = await this.client.upload(request.image, {
|
|
81
|
+
public_id: publicId,
|
|
82
|
+
resource_type: 'image',
|
|
83
|
+
// Uploading to the same path again replaces the picture instead of piling up files
|
|
84
|
+
overwrite: true,
|
|
85
|
+
invalidate: true,
|
|
86
|
+
unique_filename: false,
|
|
87
|
+
use_filename: false,
|
|
88
|
+
// Cloudinary checks the file again on its side
|
|
89
|
+
allowed_formats: [formatName(request.type)],
|
|
90
|
+
transformation: [transformationFor(request.fit)],
|
|
91
|
+
timeout: UPLOAD_TIMEOUT_MS,
|
|
92
|
+
});
|
|
93
|
+
} catch (error) {
|
|
94
|
+
if (
|
|
95
|
+
(error as { http_code?: number })?.http_code === CLOUDINARY_BAD_IMAGE
|
|
96
|
+
) {
|
|
97
|
+
// Usually the file, but a bad option on our side looks the same: leave a trace
|
|
98
|
+
this.logger.warn(
|
|
99
|
+
`Cloudinary refused an image as unreadable: ${reasonOf(error)}`,
|
|
100
|
+
);
|
|
101
|
+
throw new DomainException(StorageErrorCodes.UNREADABLE_IMAGE);
|
|
102
|
+
}
|
|
103
|
+
// Only what went wrong, never the file or the keys
|
|
104
|
+
this.logger.error(`Uploading an image failed: ${reasonOf(error)}`);
|
|
105
|
+
throw new DomainException(StorageErrorCodes.UNAVAILABLE);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const address = result.secure_url;
|
|
109
|
+
if (typeof address !== 'string' || !address.startsWith(this.addressStart)) {
|
|
110
|
+
this.logger.error(
|
|
111
|
+
'Cloudinary answered with an address outside the configured cloud',
|
|
112
|
+
);
|
|
113
|
+
throw new DomainException(StorageErrorCodes.UNAVAILABLE);
|
|
114
|
+
}
|
|
115
|
+
return address;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async delete(path: string): Promise<void> {
|
|
119
|
+
const publicId = this.publicId(path);
|
|
120
|
+
try {
|
|
121
|
+
await this.client.destroy(publicId, {
|
|
122
|
+
invalidate: true,
|
|
123
|
+
timeout: UPLOAD_TIMEOUT_MS,
|
|
124
|
+
});
|
|
125
|
+
} catch (error) {
|
|
126
|
+
this.logger.error(`Removing an image failed: ${reasonOf(error)}`);
|
|
127
|
+
throw new DomainException(StorageErrorCodes.UNAVAILABLE);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
private publicId(path: string): string {
|
|
132
|
+
if (!PATH_PATTERN.test(path)) {
|
|
133
|
+
// A bug in the calling code, not something a user can cause
|
|
134
|
+
throw new Error(`Invalid image path "${path}"`);
|
|
135
|
+
}
|
|
136
|
+
return `${this.settings.folder}/${path}`;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function formatName(type: ImageType): string {
|
|
141
|
+
return type === 'jpeg' ? 'jpg' : type;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function transformationFor(fit: ImageFit): Record<string, unknown> {
|
|
145
|
+
return {
|
|
146
|
+
width: fit.width,
|
|
147
|
+
height: fit.height,
|
|
148
|
+
crop: fit.crop,
|
|
149
|
+
...(fit.crop === 'fill' && fit.focus === 'face' && { gravity: 'face' }),
|
|
150
|
+
quality: 'auto',
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function reasonOf(error: unknown): string {
|
|
155
|
+
if (error instanceof Error) return error.message;
|
|
156
|
+
const message = (error as { message?: unknown } | null)?.message;
|
|
157
|
+
return typeof message === 'string' ? message : 'unknown error';
|
|
158
|
+
}
|
package/infra/storage-cloudinary/files/src/core/infrastructure/storage/image-storage.factory.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { IImageStorage } from './image-storage.port.js';
|
|
2
|
+
import {
|
|
3
|
+
CloudinaryImageStorage,
|
|
4
|
+
DEFAULT_IMAGE_FOLDER,
|
|
5
|
+
type CloudinarySettings,
|
|
6
|
+
} from './cloudinary-image-storage.js';
|
|
7
|
+
import { UnconfiguredImageStorage } from './unconfigured-image-storage.js';
|
|
8
|
+
|
|
9
|
+
/** The Cloudinary settings, or null when none are given (`validateEnv` makes sure it is all or none). */
|
|
10
|
+
export function cloudinarySettingsFromEnv(
|
|
11
|
+
env: NodeJS.ProcessEnv,
|
|
12
|
+
): CloudinarySettings | null {
|
|
13
|
+
const cloudName = env.CLOUDINARY_CLOUD_NAME?.trim();
|
|
14
|
+
const apiKey = env.CLOUDINARY_API_KEY?.trim();
|
|
15
|
+
const apiSecret = env.CLOUDINARY_API_SECRET?.trim();
|
|
16
|
+
if (!cloudName || !apiKey || !apiSecret) return null;
|
|
17
|
+
return {
|
|
18
|
+
cloudName,
|
|
19
|
+
apiKey,
|
|
20
|
+
apiSecret,
|
|
21
|
+
folder: env.CLOUDINARY_FOLDER?.trim() || DEFAULT_IMAGE_FOLDER,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function createImageStorage(env: NodeJS.ProcessEnv): IImageStorage {
|
|
26
|
+
const settings = cloudinarySettingsFromEnv(env);
|
|
27
|
+
return settings
|
|
28
|
+
? new CloudinaryImageStorage(settings)
|
|
29
|
+
: new UnconfiguredImageStorage();
|
|
30
|
+
}
|
package/infra/storage-cloudinary/files/src/core/infrastructure/storage/image-storage.module.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { Global, Module } from '@nestjs/common';
|
|
2
|
+
import { IMAGE_STORAGE } from './image-storage.port.js';
|
|
3
|
+
import { createImageStorage } from './image-storage.factory.js';
|
|
4
|
+
|
|
5
|
+
// Global: any feature (avatars now, product photos later) injects IMAGE_STORAGE without
|
|
6
|
+
// importing a feature module. Cloudinary when its settings are present, otherwise uploads answer 503.
|
|
7
|
+
@Global()
|
|
8
|
+
@Module({
|
|
9
|
+
providers: [
|
|
10
|
+
{
|
|
11
|
+
provide: IMAGE_STORAGE,
|
|
12
|
+
useFactory: () => createImageStorage(process.env),
|
|
13
|
+
},
|
|
14
|
+
],
|
|
15
|
+
exports: [IMAGE_STORAGE],
|
|
16
|
+
})
|
|
17
|
+
export class ImageStorageModule {}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/** The kinds of image that are accepted, decided from the first bytes of the file. */
|
|
2
|
+
export type ImageType = 'jpeg' | 'png' | 'webp';
|
|
3
|
+
|
|
4
|
+
/** How a picture is cut to size when it is stored. */
|
|
5
|
+
export interface ImageFit {
|
|
6
|
+
width: number;
|
|
7
|
+
height: number;
|
|
8
|
+
/** `fill`: exactly this size, cutting the edges. `limit`: shrink to fit inside, never enlarge. */
|
|
9
|
+
crop: 'fill' | 'limit';
|
|
10
|
+
/** With `fill`, what to keep in the middle: a detected face (else the centre), or the centre. */
|
|
11
|
+
focus?: 'face' | 'center';
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface ImageUpload {
|
|
15
|
+
/**
|
|
16
|
+
* Where the picture lives, as names separated by "/" (letters, digits, "_" and "-"), e.g.
|
|
17
|
+
* `avatars/123`. Uploading to a path that has a picture replaces it, so the caller decides
|
|
18
|
+
* whether a thing has one picture (a fixed path) or many (a path per picture).
|
|
19
|
+
*/
|
|
20
|
+
path: string;
|
|
21
|
+
image: Buffer;
|
|
22
|
+
type: ImageType;
|
|
23
|
+
fit: ImageFit;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const IMAGE_STORAGE = 'IImageStorage';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Where pictures are kept (avatars now; product photos and the like later). The features know
|
|
30
|
+
* what to store and where, never which service does it.
|
|
31
|
+
*/
|
|
32
|
+
export interface IImageStorage {
|
|
33
|
+
/** Stores the picture and returns the https address it can be shown from. */
|
|
34
|
+
upload(request: ImageUpload): Promise<string>;
|
|
35
|
+
|
|
36
|
+
/** Removes the picture at this path. Succeeds when there is none. */
|
|
37
|
+
delete(path: string): Promise<void>;
|
|
38
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { detectImageType } from './image-type.js';
|
|
3
|
+
|
|
4
|
+
const bytes = (...values: number[]) => Buffer.from(values);
|
|
5
|
+
const ascii = (text: string) => Buffer.from(text, 'latin1');
|
|
6
|
+
|
|
7
|
+
describe('detectImageType', () => {
|
|
8
|
+
it('recognises JPEG, PNG and WebP by their first bytes', () => {
|
|
9
|
+
expect(detectImageType(bytes(0xff, 0xd8, 0xff, 0xe0, 0, 0x10))).toBe(
|
|
10
|
+
'jpeg',
|
|
11
|
+
);
|
|
12
|
+
expect(
|
|
13
|
+
detectImageType(
|
|
14
|
+
bytes(0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0),
|
|
15
|
+
),
|
|
16
|
+
).toBe('png');
|
|
17
|
+
expect(
|
|
18
|
+
detectImageType(
|
|
19
|
+
Buffer.concat([ascii('RIFF'), bytes(10, 0, 0, 0), ascii('WEBPVP8 ')]),
|
|
20
|
+
),
|
|
21
|
+
).toBe('webp');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it.each([
|
|
25
|
+
['text renamed to .png', ascii('hello, I am not a picture')],
|
|
26
|
+
[
|
|
27
|
+
'SVG (it can carry scripts)',
|
|
28
|
+
ascii('<svg xmlns="http://www.w3.org/2000/svg"></svg>'),
|
|
29
|
+
],
|
|
30
|
+
['GIF', ascii('GIF89a......')],
|
|
31
|
+
['PDF', ascii('%PDF-1.7 ...')],
|
|
32
|
+
['a Windows program', ascii('MZ......')],
|
|
33
|
+
['an HTML page', ascii('<html><script>alert(1)</script>')],
|
|
34
|
+
[
|
|
35
|
+
'RIFF that is not WebP (a WAV sound)',
|
|
36
|
+
Buffer.concat([ascii('RIFF'), bytes(10, 0, 0, 0), ascii('WAVEfmt ')]),
|
|
37
|
+
],
|
|
38
|
+
['a PNG cut inside its signature', bytes(0x89, 0x50, 0x4e, 0x47)],
|
|
39
|
+
['two bytes of a JPEG', bytes(0xff, 0xd8)],
|
|
40
|
+
['nothing', Buffer.alloc(0)],
|
|
41
|
+
])('refuses %s', (_name, content) => {
|
|
42
|
+
expect(detectImageType(content)).toBeNull();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('does not read past the end of a short RIFF header', () => {
|
|
46
|
+
expect(detectImageType(ascii('RIFF1234WEB'))).toBeNull();
|
|
47
|
+
});
|
|
48
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { ImageType } from './image-storage.port.js';
|
|
2
|
+
|
|
3
|
+
const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
|
|
4
|
+
const JPEG_SIGNATURE = [0xff, 0xd8, 0xff];
|
|
5
|
+
|
|
6
|
+
const startsWith = (bytes: Buffer, signature: number[], offset = 0) =>
|
|
7
|
+
bytes.length >= offset + signature.length &&
|
|
8
|
+
signature.every((value, index) => bytes[offset + index] === value);
|
|
9
|
+
|
|
10
|
+
const asciiAt = (bytes: Buffer, offset: number, text: string) =>
|
|
11
|
+
bytes.length >= offset + text.length &&
|
|
12
|
+
bytes.toString('latin1', offset, offset + text.length) === text;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* What kind of image these bytes are, from the first bytes of the file, or null when it is none
|
|
16
|
+
* of the accepted kinds. The file name and the Content-Type are written by whoever sends the
|
|
17
|
+
* file, so they prove nothing. SVG is left out on purpose: it can carry scripts.
|
|
18
|
+
*/
|
|
19
|
+
export function detectImageType(bytes: Buffer): ImageType | null {
|
|
20
|
+
if (startsWith(bytes, JPEG_SIGNATURE)) return 'jpeg';
|
|
21
|
+
if (startsWith(bytes, PNG_SIGNATURE)) return 'png';
|
|
22
|
+
// WebP is "RIFF", four bytes of size, then "WEBP"
|
|
23
|
+
if (asciiAt(bytes, 0, 'RIFF') && asciiAt(bytes, 8, 'WEBP')) return 'webp';
|
|
24
|
+
return null;
|
|
25
|
+
}
|
package/infra/storage-cloudinary/files/src/core/infrastructure/storage/unconfigured-image-storage.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { DomainException } from '../../domain/exceptions/domain.exception.js';
|
|
2
|
+
import { StorageErrorCodes } from '../../domain/exceptions/error-codes.js';
|
|
3
|
+
import type { IImageStorage, ImageUpload } from './image-storage.port.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Used when no image service is configured: uploading answers 503 and says why; everything else
|
|
7
|
+
* keeps working, and removing a picture has nothing to remove from.
|
|
8
|
+
*/
|
|
9
|
+
export class UnconfiguredImageStorage implements IImageStorage {
|
|
10
|
+
upload(_request: ImageUpload): Promise<string> {
|
|
11
|
+
return Promise.reject(
|
|
12
|
+
new DomainException(StorageErrorCodes.NOT_CONFIGURED),
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
delete(_path: string): Promise<void> {
|
|
17
|
+
return Promise.resolve();
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "storage-cloudinary",
|
|
3
|
+
"label": "Image storage (Cloudinary, optional at runtime)",
|
|
4
|
+
"templates": ["nest-ddd"],
|
|
5
|
+
"dependencies": {
|
|
6
|
+
"cloudinary": "^2.11.0"
|
|
7
|
+
},
|
|
8
|
+
"env": [
|
|
9
|
+
"# Cloudinary — optional: leave all empty to disable uploads (all three or none)",
|
|
10
|
+
"CLOUDINARY_CLOUD_NAME=",
|
|
11
|
+
"CLOUDINARY_API_KEY=",
|
|
12
|
+
"CLOUDINARY_API_SECRET=",
|
|
13
|
+
"CLOUDINARY_FOLDER={{PROJECT_NAME}}/images"
|
|
14
|
+
],
|
|
15
|
+
"inject": [
|
|
16
|
+
{
|
|
17
|
+
"file": "src/core/domain/exceptions/error-codes.ts",
|
|
18
|
+
"marker": "// @infra:error-codes",
|
|
19
|
+
"lines": [
|
|
20
|
+
"export const StorageErrorCodes = {",
|
|
21
|
+
" NOT_CONFIGURED: {",
|
|
22
|
+
" code: 'STO-001',",
|
|
23
|
+
" message: 'Image upload is not available right now',",
|
|
24
|
+
" status: HttpStatus.SERVICE_UNAVAILABLE,",
|
|
25
|
+
" },",
|
|
26
|
+
" UNAVAILABLE: {",
|
|
27
|
+
" code: 'STO-002',",
|
|
28
|
+
" message: 'The image could not be saved, please try again later',",
|
|
29
|
+
" status: HttpStatus.SERVICE_UNAVAILABLE,",
|
|
30
|
+
" },",
|
|
31
|
+
" UNREADABLE_IMAGE: {",
|
|
32
|
+
" code: 'STO-003',",
|
|
33
|
+
" message: 'The file could not be read as an image',",
|
|
34
|
+
" status: HttpStatus.BAD_REQUEST,",
|
|
35
|
+
" },",
|
|
36
|
+
"};",
|
|
37
|
+
""
|
|
38
|
+
]
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
"file": "src/app.module.ts",
|
|
42
|
+
"marker": "// @infra:imports",
|
|
43
|
+
"lines": ["import { ImageStorageModule } from './core/infrastructure/storage/image-storage.module.js';"]
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
"file": "src/app.module.ts",
|
|
47
|
+
"marker": "// @infra:modules",
|
|
48
|
+
"lines": ["ImageStorageModule,"]
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
"file": "src/core/index.ts",
|
|
52
|
+
"marker": "// @infra:barrel",
|
|
53
|
+
"lines": [
|
|
54
|
+
"export * from './infrastructure/storage/image-storage.port.js';",
|
|
55
|
+
"export * from './infrastructure/storage/image-type.js';",
|
|
56
|
+
"export * from './infrastructure/storage/cloudinary-image-storage.js';",
|
|
57
|
+
"export * from './infrastructure/storage/unconfigured-image-storage.js';",
|
|
58
|
+
"export * from './infrastructure/storage/image-storage.factory.js';",
|
|
59
|
+
"export * from './infrastructure/storage/image-storage.module.js';"
|
|
60
|
+
]
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
"file": "src/core/config/env.validation.ts",
|
|
64
|
+
"marker": "// @infra:env-imports",
|
|
65
|
+
"lines": ["import { validateStorageEnv } from './storage.env.js';"]
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
"file": "src/core/config/env.validation.ts",
|
|
69
|
+
"marker": "// @infra:env-checks",
|
|
70
|
+
"lines": ["validateStorageEnv(config, errors);"]
|
|
71
|
+
}
|
|
72
|
+
]
|
|
73
|
+
}
|
package/lib/fs-utils.mjs
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Files that cannot live under their real name inside a template
|
|
6
|
+
* (git/npm would treat them as config of the *template repo* itself).
|
|
7
|
+
*/
|
|
8
|
+
const RENAMES = {
|
|
9
|
+
_gitignore: '.gitignore',
|
|
10
|
+
_npmrc: '.npmrc',
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const SKIP_DIRS = new Set(['node_modules', 'dist', '.git']);
|
|
14
|
+
|
|
15
|
+
function isBinary(buffer) {
|
|
16
|
+
return buffer.subarray(0, 8000).includes(0);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Replaces every `{{TOKEN}}` that exists in `vars`. Unknown tokens (e.g. Handlebars) are left alone. */
|
|
20
|
+
export function applyVars(text, vars) {
|
|
21
|
+
return text.replace(/\{\{([A-Z_]+)\}\}/g, (match, key) =>
|
|
22
|
+
key in vars ? vars[key] : match,
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Copies a template folder into `dest`, replacing `{{TOKENS}}` in text files.
|
|
28
|
+
* Existing files are kept (never clobbered) unless `overwrite` is true.
|
|
29
|
+
* Returns the list of project-relative paths that were written and skipped.
|
|
30
|
+
*/
|
|
31
|
+
export function copyTemplateDir(src, dest, vars, { overwrite = true } = {}) {
|
|
32
|
+
const written = [];
|
|
33
|
+
const skipped = [];
|
|
34
|
+
|
|
35
|
+
const walk = (from, to) => {
|
|
36
|
+
fs.mkdirSync(to, { recursive: true });
|
|
37
|
+
for (const entry of fs.readdirSync(from, { withFileTypes: true })) {
|
|
38
|
+
if (entry.isDirectory() && SKIP_DIRS.has(entry.name)) continue;
|
|
39
|
+
const source = path.join(from, entry.name);
|
|
40
|
+
const target = path.join(to, RENAMES[entry.name] ?? entry.name);
|
|
41
|
+
|
|
42
|
+
if (entry.isDirectory()) {
|
|
43
|
+
walk(source, target);
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const relative = path.relative(dest, target);
|
|
48
|
+
if (!overwrite && fs.existsSync(target)) {
|
|
49
|
+
skipped.push(relative);
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const buffer = fs.readFileSync(source);
|
|
54
|
+
if (isBinary(buffer)) {
|
|
55
|
+
fs.writeFileSync(target, buffer);
|
|
56
|
+
} else {
|
|
57
|
+
fs.writeFileSync(target, applyVars(buffer.toString('utf8'), vars));
|
|
58
|
+
}
|
|
59
|
+
written.push(relative);
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
walk(src, dest);
|
|
64
|
+
return { written, skipped };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const sortKeys = (object = {}) =>
|
|
68
|
+
Object.fromEntries(Object.entries(object).sort(([a], [b]) => a.localeCompare(b)));
|
|
69
|
+
|
|
70
|
+
/** Merges dependencies / devDependencies / scripts into the project's package.json. */
|
|
71
|
+
export function mergePackageJson(projectDir, patch) {
|
|
72
|
+
const file = path.join(projectDir, 'package.json');
|
|
73
|
+
const pkg = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
74
|
+
|
|
75
|
+
for (const key of ['dependencies', 'devDependencies']) {
|
|
76
|
+
if (patch[key]) pkg[key] = sortKeys({ ...pkg[key], ...patch[key] });
|
|
77
|
+
}
|
|
78
|
+
if (patch.scripts) pkg.scripts = { ...pkg.scripts, ...patch.scripts };
|
|
79
|
+
|
|
80
|
+
fs.writeFileSync(file, `${JSON.stringify(pkg, null, 2)}\n`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Inserts `lines` right above the line that contains `marker`, keeping the marker so more
|
|
85
|
+
* code can be injected later (e.g. `create-my-stack add redis`). The inserted block takes
|
|
86
|
+
* the marker's indentation. Returns false when the file or the marker is missing.
|
|
87
|
+
*/
|
|
88
|
+
export function injectAtMarker(file, marker, lines) {
|
|
89
|
+
if (!fs.existsSync(file)) return false;
|
|
90
|
+
const text = fs.readFileSync(file, 'utf8');
|
|
91
|
+
const eol = text.includes('\r\n') ? '\r\n' : '\n';
|
|
92
|
+
const escaped = marker.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
93
|
+
const match = new RegExp(`^([ \\t]*)${escaped}.*$`, 'm').exec(text);
|
|
94
|
+
if (!match) return false;
|
|
95
|
+
|
|
96
|
+
const indent = match[1];
|
|
97
|
+
const block = lines.map((line) => (line === '' ? '' : indent + line)).join(eol);
|
|
98
|
+
const updated = `${text.slice(0, match.index)}${block}${eol}${text.slice(match.index)}`;
|
|
99
|
+
fs.writeFileSync(file, updated);
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function readJson(file) {
|
|
104
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function writeJson(file, data) {
|
|
108
|
+
fs.writeFileSync(file, `${JSON.stringify(data, null, 2)}\n`);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function appendLines(file, lines) {
|
|
112
|
+
const existing = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '';
|
|
113
|
+
const prefix = existing === '' || existing.endsWith('\n') ? '' : '\n';
|
|
114
|
+
fs.writeFileSync(file, `${existing}${prefix}${lines.join('\n')}\n`);
|
|
115
|
+
}
|
package/lib/registry.mjs
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { readJson } from './fs-utils.mjs';
|
|
5
|
+
|
|
6
|
+
export const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
7
|
+
export const TEMPLATES_DIR = path.join(ROOT, 'templates');
|
|
8
|
+
export const INFRA_DIR = path.join(ROOT, 'infra');
|
|
9
|
+
|
|
10
|
+
function loadAll(baseDir, manifestName) {
|
|
11
|
+
if (!fs.existsSync(baseDir)) return [];
|
|
12
|
+
return fs
|
|
13
|
+
.readdirSync(baseDir, { withFileTypes: true })
|
|
14
|
+
.filter((entry) => entry.isDirectory())
|
|
15
|
+
.map((entry) => {
|
|
16
|
+
const dir = path.join(baseDir, entry.name);
|
|
17
|
+
const manifest = path.join(dir, manifestName);
|
|
18
|
+
if (!fs.existsSync(manifest)) return null;
|
|
19
|
+
return { ...readJson(manifest), dir };
|
|
20
|
+
})
|
|
21
|
+
.filter(Boolean);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Every template under `templates/`, described by its `template.json`. */
|
|
25
|
+
export const loadTemplates = () => loadAll(TEMPLATES_DIR, 'template.json');
|
|
26
|
+
|
|
27
|
+
/** Every infra module under `infra/`, described by its `infra.json`. */
|
|
28
|
+
export const loadInfra = () => loadAll(INFRA_DIR, 'infra.json');
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Expands the requested infra ids with everything they `require`
|
|
32
|
+
* (e.g. email -> rabbitmq), preserving order and removing duplicates.
|
|
33
|
+
*/
|
|
34
|
+
export function resolveInfra(requested, available) {
|
|
35
|
+
const byId = new Map(available.map((infra) => [infra.id, infra]));
|
|
36
|
+
const ordered = [];
|
|
37
|
+
const added = [];
|
|
38
|
+
|
|
39
|
+
const visit = (id, parent) => {
|
|
40
|
+
const infra = byId.get(id);
|
|
41
|
+
if (!infra) throw new Error(`Unknown infra "${id}". Run "list" to see what exists.`);
|
|
42
|
+
if (ordered.some((item) => item.id === id)) return;
|
|
43
|
+
for (const dependency of infra.requires ?? []) visit(dependency, id);
|
|
44
|
+
ordered.push(infra);
|
|
45
|
+
if (!requested.includes(id)) added.push({ id, because: parent });
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
for (const id of requested) visit(id);
|
|
49
|
+
return { ordered, added };
|
|
50
|
+
}
|