@podosoft/podokit 0.15.0 → 0.16.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 (100) hide show
  1. package/README.md +17 -0
  2. package/dist/add.d.ts +4 -0
  3. package/dist/add.js +59 -1
  4. package/dist/create.js +8 -1
  5. package/dist/deploy-profile.d.ts +92 -0
  6. package/dist/deploy-profile.js +603 -0
  7. package/dist/deploy-render.d.ts +18 -0
  8. package/dist/deploy-render.js +651 -0
  9. package/dist/deploy.d.ts +65 -0
  10. package/dist/deploy.js +851 -0
  11. package/dist/dev.d.ts +1 -0
  12. package/dist/dev.js +29 -5
  13. package/dist/index.d.ts +8 -0
  14. package/dist/index.js +147 -1
  15. package/dist/lib.d.ts +6 -0
  16. package/dist/lib.js +18 -1
  17. package/dist/lockfile.js +1 -0
  18. package/dist/remove.js +7 -0
  19. package/dist/templates/fullstack-nest-svelte/AGENTS.md +1 -0
  20. package/dist/templates/fullstack-nest-svelte/README.md +19 -8
  21. package/dist/templates/fullstack-nest-svelte/apps/api/Dockerfile +2 -2
  22. package/dist/templates/fullstack-nest-svelte/apps/api/package.json +1 -0
  23. package/dist/templates/fullstack-nest-svelte/apps/api/src/config/redis.connection.spec.ts +53 -0
  24. package/dist/templates/fullstack-nest-svelte/apps/api/src/config/redis.connection.ts +69 -0
  25. package/dist/templates/fullstack-nest-svelte/apps/api/src/database/migrate.ts +16 -0
  26. package/dist/templates/fullstack-nest-svelte/apps/api/src/health/health.controller.ts +19 -4
  27. package/dist/templates/fullstack-nest-svelte/apps/api/src/health/health.module.ts +5 -1
  28. package/dist/templates/fullstack-nest-svelte/apps/api/src/health/readiness.service.spec.ts +22 -0
  29. package/dist/templates/fullstack-nest-svelte/apps/api/src/health/readiness.service.ts +33 -0
  30. package/dist/templates/fullstack-nest-svelte/apps/web/Dockerfile +2 -2
  31. package/dist/templates/fullstack-nest-svelte/compose.dev.yaml +3 -5
  32. package/dist/templates/fullstack-nest-svelte/dot-agents/skills/podokit-deploy/SKILL.md +20 -0
  33. package/dist/templates/fullstack-nest-svelte/dot-agents/skills/podokit-deploy/agents/openai.yaml +4 -0
  34. package/dist/templates/fullstack-nest-svelte/dot-agents/skills/podokit-deploy/references/profile.md +37 -0
  35. package/dist/templates/fullstack-nest-svelte/dot-agents/skills/podokit-deploy/references/rollback.md +18 -0
  36. package/dist/templates/fullstack-nest-svelte/dot-claude/skills/podokit-deploy/SKILL.md +8 -0
  37. package/dist/templates/fullstack-nest-svelte/dot-env.example +6 -0
  38. package/dist/templates/fullstack-nest-svelte/infra/k3s/api-deployment.yaml +1 -1
  39. package/dist/templates/fullstack-nest-svelte/infra/k3s/ingress.yaml +0 -4
  40. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/site-runtime.svelte +6 -0
  41. package/dist/templates/modules/api-key-auth/files/apps/api/src/api-key/api-key-verifier.spec.ts +36 -0
  42. package/dist/templates/modules/api-key-auth/files/apps/api/src/api-key/api-key-verifier.ts +20 -0
  43. package/dist/templates/modules/api-key-auth/files/apps/api/src/api-key/api-key.guard.spec.ts +49 -0
  44. package/dist/templates/modules/api-key-auth/files/apps/api/src/api-key/api-key.guard.ts +3 -11
  45. package/dist/templates/modules/api-key-auth/files/apps/api/src/api-key/api-key.module.ts +3 -1
  46. package/dist/templates/modules/api-key-auth/module.manifest.json +1 -1
  47. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-environment.ts +32 -0
  48. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.runtime.spec.ts +35 -0
  49. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +3 -2
  50. package/dist/templates/modules/auth/files/apps/api/src/health/health.controller.ts +19 -4
  51. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/queue.ts +4 -5
  52. package/dist/templates/modules/bullmq/module.manifest.json +4 -1
  53. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.bridge.spec.ts +36 -0
  54. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.bridge.ts +26 -5
  55. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.service.spec.ts +35 -0
  56. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.service.ts +21 -2
  57. package/dist/templates/modules/object-storage-s3/module.manifest.json +3 -0
  58. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.spec.ts +85 -0
  59. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.ts +110 -0
  60. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.spec.ts +132 -0
  61. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.ts +100 -0
  62. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.integration.spec.ts +91 -0
  63. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.spec.ts +228 -0
  64. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.ts +97 -38
  65. package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +11 -6
  66. package/dist/templates/modules/rate-limit/module.manifest.json +23 -9
  67. package/dist/templates/modules/redis/files/apps/api/src/redis/redis.service.spec.ts +28 -0
  68. package/dist/templates/modules/redis/files/apps/api/src/redis/redis.service.ts +52 -11
  69. package/dist/templates/modules/redis/module.manifest.json +16 -1
  70. package/dist/templates/modules/sse/files/apps/api/src/events/events.controller.ts +17 -3
  71. package/dist/templates/modules/sse/files/apps/api/src/events/events.module.ts +9 -2
  72. package/dist/templates/modules/sse/files/apps/api/src/events/events.service.spec.ts +61 -0
  73. package/dist/templates/modules/sse/files/apps/api/src/events/events.service.ts +44 -4
  74. package/dist/templates/modules/sse/files/apps/api/src/events/events.transport.redis.spec.ts +42 -0
  75. package/dist/templates/modules/sse/files/apps/api/src/events/events.transport.spec.ts +97 -0
  76. package/dist/templates/modules/sse/files/apps/api/src/events/events.transport.ts +165 -0
  77. package/dist/templates/modules/sse/files/tests/api/events.api.spec.ts +41 -0
  78. package/dist/templates/modules/sse/module.manifest.json +16 -3
  79. package/dist/templates/todo/AGENTS.md +1 -0
  80. package/dist/templates/todo/README.md +12 -8
  81. package/dist/templates/todo/apps/api/Dockerfile +2 -2
  82. package/dist/templates/todo/apps/api/package.json +1 -0
  83. package/dist/templates/todo/apps/api/src/config/redis.connection.spec.ts +53 -0
  84. package/dist/templates/todo/apps/api/src/config/redis.connection.ts +69 -0
  85. package/dist/templates/todo/apps/api/src/database/migrate.ts +16 -0
  86. package/dist/templates/todo/apps/api/src/health/health.controller.ts +19 -4
  87. package/dist/templates/todo/apps/api/src/health/health.module.ts +5 -1
  88. package/dist/templates/todo/apps/api/src/health/readiness.service.spec.ts +22 -0
  89. package/dist/templates/todo/apps/api/src/health/readiness.service.ts +33 -0
  90. package/dist/templates/todo/apps/web/Dockerfile +2 -2
  91. package/dist/templates/todo/compose.dev.yaml +3 -5
  92. package/dist/templates/todo/dot-agents/skills/podokit-deploy/SKILL.md +20 -0
  93. package/dist/templates/todo/dot-agents/skills/podokit-deploy/agents/openai.yaml +4 -0
  94. package/dist/templates/todo/dot-agents/skills/podokit-deploy/references/profile.md +28 -0
  95. package/dist/templates/todo/dot-agents/skills/podokit-deploy/references/rollback.md +5 -0
  96. package/dist/templates/todo/dot-claude/skills/podokit-deploy/SKILL.md +8 -0
  97. package/dist/templates/todo/dot-env.example +6 -0
  98. package/dist/templates/todo/infra/k3s/api-deployment.yaml +1 -1
  99. package/dist/templates/todo/infra/k3s/ingress.yaml +0 -4
  100. package/package.json +2 -2
@@ -0,0 +1,603 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.profileDirectory = profileDirectory;
4
+ exports.profilePath = profilePath;
5
+ exports.loadDeploymentProfile = loadDeploymentProfile;
6
+ exports.listDeploymentProfiles = listDeploymentProfiles;
7
+ exports.initializeDeploymentProfile = initializeDeploymentProfile;
8
+ exports.assertReleaseTag = assertReleaseTag;
9
+ exports.deploymentProfileDigest = deploymentProfileDigest;
10
+ const node_crypto_1 = require("node:crypto");
11
+ const node_fs_1 = require("node:fs");
12
+ const node_path_1 = require("node:path");
13
+ const lockfile_1 = require("./lockfile");
14
+ const PROFILE_NAME = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
15
+ const DNS_LABEL = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
16
+ const IMAGE_NAME = /^(?:[a-z0-9]+(?:[._-][a-z0-9]+)*(?::[1-9][0-9]{0,4})?\/)?(?:[a-z0-9]+(?:[._-][a-z0-9]+)*\/)*[a-z0-9]+(?:[._-][a-z0-9]+)*$/;
17
+ const IMAGE_TAG = /^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$/;
18
+ const RESOURCE_VALUE = /^[1-9][0-9]*(?:m|Mi|Gi)?$/;
19
+ const STORAGE_VALUE = /^[1-9][0-9]*(?:Mi|Gi|Ti)$/;
20
+ const SENSITIVE_KEY = /(password|passphrase|secret|token|private.?key|credential|access.?key|api.?keys?|signing.?key|encryption.?key)/i;
21
+ const STABLE_SEMVER_TAG_PATTERN = "^v(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)$";
22
+ function containsUrlCredentials(value) {
23
+ if (!value.includes("://"))
24
+ return false;
25
+ try {
26
+ const parsed = new URL(value);
27
+ return parsed.username !== "" || parsed.password !== "";
28
+ }
29
+ catch {
30
+ return false;
31
+ }
32
+ }
33
+ function isRecord(value) {
34
+ return typeof value === "object" && value !== null && !Array.isArray(value);
35
+ }
36
+ function assertOnlyKeys(value, allowed, field) {
37
+ const unexpected = Object.keys(value).filter((key) => !allowed.includes(key));
38
+ if (unexpected.length) {
39
+ throw new Error(`Deployment profile field "${field}" contains unknown key(s): ${unexpected.join(", ")}.`);
40
+ }
41
+ }
42
+ function requiredRecord(parent, key) {
43
+ const value = parent[key];
44
+ if (!isRecord(value))
45
+ throw new Error(`Deployment profile field "${key}" must be an object.`);
46
+ return value;
47
+ }
48
+ function requiredString(parent, key) {
49
+ const value = parent[key];
50
+ if (typeof value !== "string" || value.trim() === "") {
51
+ throw new Error(`Deployment profile field "${key}" must be a non-empty string.`);
52
+ }
53
+ return value;
54
+ }
55
+ function optionalString(parent, key) {
56
+ const value = parent[key];
57
+ if (value === null || value === undefined)
58
+ return null;
59
+ if (typeof value !== "string" || value.trim() === "") {
60
+ throw new Error(`Deployment profile field "${key}" must be a string or null.`);
61
+ }
62
+ return value;
63
+ }
64
+ function requiredNumber(parent, key) {
65
+ const value = parent[key];
66
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 1) {
67
+ throw new Error(`Deployment profile field "${key}" must be a positive integer.`);
68
+ }
69
+ return value;
70
+ }
71
+ function parseRequiredKeys(value, field) {
72
+ if (!Array.isArray(value)) {
73
+ throw new Error(`Deployment profile field "${field}" must be a string array.`);
74
+ }
75
+ const keys = value.map((entry) => {
76
+ if (typeof entry !== "string" || !/^[A-Z][A-Z0-9_]*$/.test(entry)) {
77
+ throw new Error(`Deployment profile field "${field}" contains an invalid environment key.`);
78
+ }
79
+ return entry;
80
+ });
81
+ if (new Set(keys).size !== keys.length) {
82
+ throw new Error(`Deployment profile field "${field}" contains duplicate keys.`);
83
+ }
84
+ return keys.sort();
85
+ }
86
+ function parseNamedSecret(value, field) {
87
+ if (!isRecord(value)) {
88
+ throw new Error(`Deployment profile field "${field}" must be an object.`);
89
+ }
90
+ assertOnlyKeys(value, ["name", "requiredKeys"], field);
91
+ const parsed = {
92
+ name: requiredString(value, "name"),
93
+ requiredKeys: parseRequiredKeys(value.requiredKeys, `${field}.requiredKeys`),
94
+ };
95
+ validateDnsLabel(parsed.name, `${field}.name`);
96
+ return parsed;
97
+ }
98
+ function validateDnsLabel(value, field) {
99
+ if (!DNS_LABEL.test(value)) {
100
+ throw new Error(`Deployment profile field "${field}" must be a Kubernetes DNS label.`);
101
+ }
102
+ }
103
+ function validateDnsSubdomain(value, field) {
104
+ if (value.length > 253 ||
105
+ !value.split(".").every((label) => DNS_LABEL.test(label))) {
106
+ throw new Error(`Deployment profile field "${field}" must be a Kubernetes DNS subdomain.`);
107
+ }
108
+ }
109
+ function parseImageReference(value) {
110
+ if (value.length > 512 || value.includes("://") || /[\s\u0000-\u001f\u007f]/.test(value)) {
111
+ return null;
112
+ }
113
+ const at = value.indexOf("@");
114
+ if (at !== -1 && at !== value.lastIndexOf("@"))
115
+ return null;
116
+ const digest = at === -1 ? null : value.slice(at + 1);
117
+ const tagged = at === -1 ? value : value.slice(0, at);
118
+ const lastSlash = tagged.lastIndexOf("/");
119
+ const lastColon = tagged.lastIndexOf(":");
120
+ const hasTag = lastColon > lastSlash;
121
+ const name = hasTag ? tagged.slice(0, lastColon) : tagged;
122
+ const tag = hasTag ? tagged.slice(lastColon + 1) : null;
123
+ if (!IMAGE_NAME.test(name) ||
124
+ (tag !== null && !IMAGE_TAG.test(tag)) ||
125
+ (digest !== null && !/^sha256:[a-f0-9]{64}$/.test(digest))) {
126
+ return null;
127
+ }
128
+ return { name, tag, digest };
129
+ }
130
+ function validateImage(value, field) {
131
+ const parsed = parseImageReference(value);
132
+ if (!parsed || parsed.tag === "latest" || (!parsed.tag && !parsed.digest)) {
133
+ throw new Error(`Deployment profile field "${field}" must use an explicit non-latest image tag or digest.`);
134
+ }
135
+ }
136
+ function validateImageRepository(value, field) {
137
+ const parsed = parseImageReference(value);
138
+ if (!parsed || parsed.tag || parsed.digest) {
139
+ throw new Error(`Deployment profile field "${field}" must be an image repository without a tag or digest.`);
140
+ }
141
+ }
142
+ function validateResource(value, field) {
143
+ if (!RESOURCE_VALUE.test(value)) {
144
+ throw new Error(`Deployment profile field "${field}" is not a supported resource quantity.`);
145
+ }
146
+ }
147
+ function validateStorage(value, field) {
148
+ if (!STORAGE_VALUE.test(value)) {
149
+ throw new Error(`Deployment profile field "${field}" is not a supported storage quantity.`);
150
+ }
151
+ }
152
+ function parseResources(value, field) {
153
+ assertOnlyKeys(value, ["cpuRequest", "cpuLimit", "memoryRequest", "memoryLimit"], field);
154
+ const parsed = {
155
+ cpuRequest: requiredString(value, "cpuRequest"),
156
+ cpuLimit: requiredString(value, "cpuLimit"),
157
+ memoryRequest: requiredString(value, "memoryRequest"),
158
+ memoryLimit: requiredString(value, "memoryLimit"),
159
+ };
160
+ for (const [key, quantity] of Object.entries(parsed))
161
+ validateResource(quantity, `${field}.${key}`);
162
+ return parsed;
163
+ }
164
+ function parseWorkload(value, field) {
165
+ assertOnlyKeys(value, ["replicas", "resources"], field);
166
+ return {
167
+ replicas: requiredNumber(value, "replicas"),
168
+ resources: parseResources(requiredRecord(value, "resources"), `${field}.resources`),
169
+ };
170
+ }
171
+ function parseMode(value, field) {
172
+ if (value !== "inCluster" && value !== "external" && value !== "disabled") {
173
+ throw new Error(`Deployment profile field "${field}" must be inCluster, external, or disabled.`);
174
+ }
175
+ return value;
176
+ }
177
+ function parseStatefulDependency(value, field, extraKeys = []) {
178
+ assertOnlyKeys(value, ["mode", "image", "secretName", "storageClassName", "storageSize", ...extraKeys], field);
179
+ const parsed = {
180
+ mode: parseMode(value.mode, `${field}.mode`),
181
+ image: requiredString(value, "image"),
182
+ secretName: requiredString(value, "secretName"),
183
+ storageClassName: typeof value.storageClassName === "string" ? value.storageClassName : "",
184
+ storageSize: requiredString(value, "storageSize"),
185
+ };
186
+ validateImage(parsed.image, `${field}.image`);
187
+ validateDnsLabel(parsed.secretName, `${field}.secretName`);
188
+ validateStorage(parsed.storageSize, `${field}.storageSize`);
189
+ if (parsed.storageClassName && parsed.storageClassName.length > 253) {
190
+ throw new Error(`Deployment profile field "${field}.storageClassName" is too long.`);
191
+ }
192
+ return parsed;
193
+ }
194
+ function parseRedisDependency(value, field) {
195
+ return parseStatefulDependency(value, field);
196
+ }
197
+ function validateHttpsOrigin(value) {
198
+ let parsed;
199
+ try {
200
+ parsed = new URL(value);
201
+ }
202
+ catch {
203
+ throw new Error(`Deployment verification baseUrl "${value}" is invalid.`);
204
+ }
205
+ if (parsed.username || parsed.password) {
206
+ throw new Error("Deployment verification baseUrl must not contain URL credentials; use a credential-free HTTPS origin.");
207
+ }
208
+ if (parsed.protocol !== "https:" || parsed.pathname !== "/" || parsed.search || parsed.hash) {
209
+ throw new Error("Deployment verification baseUrl must be an HTTPS origin without a path.");
210
+ }
211
+ }
212
+ function parseProfile(value) {
213
+ if (!isRecord(value))
214
+ throw new Error("Deployment profile must be a JSON object.");
215
+ assertOnlyKeys(value, [
216
+ "schemaVersion",
217
+ "driver",
218
+ "target",
219
+ "release",
220
+ "exposure",
221
+ "workloads",
222
+ "dependencies",
223
+ "secrets",
224
+ "runtimeConfig",
225
+ "verification",
226
+ ], "root");
227
+ if (value.schemaVersion !== 1) {
228
+ throw new Error(`Unsupported deployment profile schemaVersion: ${String(value.schemaVersion)}`);
229
+ }
230
+ if (value.driver !== "kubernetes-helm") {
231
+ throw new Error(`Unsupported deployment driver: ${String(value.driver)}`);
232
+ }
233
+ const target = requiredRecord(value, "target");
234
+ const release = requiredRecord(value, "release");
235
+ const exposure = requiredRecord(value, "exposure");
236
+ const workloads = requiredRecord(value, "workloads");
237
+ const dependencies = requiredRecord(value, "dependencies");
238
+ const secrets = requiredRecord(value, "secrets");
239
+ const verification = requiredRecord(value, "verification");
240
+ const runtimeConfig = requiredRecord(value, "runtimeConfig");
241
+ assertOnlyKeys(target, ["context", "clusterFingerprint", "namespace", "release", "createNamespace"], "target");
242
+ assertOnlyKeys(release, ["strategy", "tagPattern", "apiRepository", "webRepository"], "release");
243
+ assertOnlyKeys(exposure, ["mode", "host", "ingressClassName", "tlsSecretName"], "exposure");
244
+ assertOnlyKeys(workloads, ["api", "web", "worker"], "workloads");
245
+ assertOnlyKeys(dependencies, ["postgres", "redis", "objectStorage"], "dependencies");
246
+ assertOnlyKeys(secrets, ["api", "web", "imagePull"], "secrets");
247
+ assertOnlyKeys(verification, ["baseUrl", "checks"], "verification");
248
+ const context = requiredString(target, "context");
249
+ const clusterFingerprint = requiredString(target, "clusterFingerprint");
250
+ if (!/^sha256:[a-f0-9]{64}$/.test(clusterFingerprint)) {
251
+ throw new Error("Deployment target.clusterFingerprint must be a sha256 fingerprint.");
252
+ }
253
+ const namespace = requiredString(target, "namespace");
254
+ const releaseName = requiredString(target, "release");
255
+ validateDnsLabel(namespace, "target.namespace");
256
+ validateDnsLabel(releaseName, "target.release");
257
+ if (releaseName.length > 40) {
258
+ throw new Error("Deployment target.release must be at most 40 characters.");
259
+ }
260
+ if (target.createNamespace !== false) {
261
+ throw new Error("Deployment target.createNamespace must be false in schema version 1.");
262
+ }
263
+ const tagPattern = requiredString(release, "tagPattern");
264
+ if (tagPattern !== STABLE_SEMVER_TAG_PATTERN) {
265
+ throw new Error("Deployment release.tagPattern must require stable vMAJOR.MINOR.PATCH tags.");
266
+ }
267
+ if (release.strategy !== "shared-tag") {
268
+ throw new Error("Deployment release.strategy must be shared-tag.");
269
+ }
270
+ const apiRepository = requiredString(release, "apiRepository");
271
+ const webRepository = requiredString(release, "webRepository");
272
+ validateImageRepository(apiRepository, "release.apiRepository");
273
+ validateImageRepository(webRepository, "release.webRepository");
274
+ const exposureMode = exposure.mode;
275
+ if (exposureMode !== "ingress" && exposureMode !== "nodePort") {
276
+ throw new Error("Deployment exposure.mode must be ingress or nodePort.");
277
+ }
278
+ const host = requiredString(exposure, "host");
279
+ if (exposureMode === "ingress") {
280
+ validateDnsSubdomain(host, "exposure.host");
281
+ }
282
+ const ingressClassName = requiredString(exposure, "ingressClassName");
283
+ validateDnsSubdomain(ingressClassName, "exposure.ingressClassName");
284
+ const tlsSecretName = optionalString(exposure, "tlsSecretName");
285
+ if (tlsSecretName)
286
+ validateDnsSubdomain(tlsSecretName, "exposure.tlsSecretName");
287
+ const postgres = parseStatefulDependency(requiredRecord(dependencies, "postgres"), "dependencies.postgres");
288
+ const redis = parseRedisDependency(requiredRecord(dependencies, "redis"), "dependencies.redis");
289
+ const objectStorageRecord = requiredRecord(dependencies, "objectStorage");
290
+ assertOnlyKeys(objectStorageRecord, [
291
+ "mode",
292
+ "image",
293
+ "secretName",
294
+ "storageClassName",
295
+ "storageSize",
296
+ "bucket",
297
+ "clientImage",
298
+ ], "dependencies.objectStorage");
299
+ const objectStorage = {
300
+ ...parseStatefulDependency(objectStorageRecord, "dependencies.objectStorage", ["bucket", "clientImage"]),
301
+ bucket: requiredString(objectStorageRecord, "bucket"),
302
+ clientImage: requiredString(objectStorageRecord, "clientImage"),
303
+ };
304
+ validateDnsLabel(objectStorage.bucket, "dependencies.objectStorage.bucket");
305
+ validateImage(objectStorage.clientImage, "dependencies.objectStorage.clientImage");
306
+ const parsedConfig = {};
307
+ for (const [key, entry] of Object.entries(runtimeConfig)) {
308
+ if (!/^[A-Z][A-Z0-9_]*$/.test(key)) {
309
+ throw new Error(`Deployment runtimeConfig key "${key}" must be an uppercase environment name.`);
310
+ }
311
+ if (SENSITIVE_KEY.test(key)) {
312
+ throw new Error(`Deployment runtimeConfig key "${key}" may contain a secret; use a Kubernetes Secret.`);
313
+ }
314
+ if (["PORT", "BACKEND_INTERNAL_URL"].includes(key)) {
315
+ throw new Error(`Deployment runtimeConfig key "${key}" is managed by PodoKit.`);
316
+ }
317
+ if (typeof entry !== "string") {
318
+ throw new Error(`Deployment runtimeConfig value "${key}" must be a string.`);
319
+ }
320
+ if (containsUrlCredentials(entry)) {
321
+ throw new Error(`Deployment runtimeConfig value "${key}" contains URL credentials; use a Kubernetes Secret.`);
322
+ }
323
+ parsedConfig[key] = entry;
324
+ }
325
+ const baseUrl = requiredString(verification, "baseUrl");
326
+ validateHttpsOrigin(baseUrl);
327
+ const baseOrigin = new URL(baseUrl).origin;
328
+ const checksValue = verification.checks;
329
+ if (!Array.isArray(checksValue) || checksValue.length === 0) {
330
+ throw new Error("Deployment verification.checks must be a non-empty array.");
331
+ }
332
+ const checks = checksValue.map((entry, index) => {
333
+ if (!isRecord(entry)) {
334
+ throw new Error(`Deployment verification.checks[${index}] must be an object.`);
335
+ }
336
+ assertOnlyKeys(entry, ["path", "expectedStatus", "expectedJson"], `verification.checks[${index}]`);
337
+ const path = requiredString(entry, "path");
338
+ if (!path.startsWith("/") ||
339
+ path.startsWith("//") ||
340
+ path.includes("\\") ||
341
+ /[\u0000-\u001f\u007f]/.test(path) ||
342
+ new URL(path, baseUrl).origin !== baseOrigin) {
343
+ throw new Error("Every deployment verification path must be an absolute URL path.");
344
+ }
345
+ const expectedStatus = requiredNumber(entry, "expectedStatus");
346
+ if (expectedStatus < 100 || expectedStatus > 599) {
347
+ throw new Error("Deployment verification expectedStatus must be an HTTP status.");
348
+ }
349
+ const expectedJsonValue = entry.expectedJson;
350
+ let expectedJson;
351
+ if (expectedJsonValue !== undefined) {
352
+ if (!isRecord(expectedJsonValue)) {
353
+ throw new Error("Deployment verification expectedJson must be an object.");
354
+ }
355
+ expectedJson = {};
356
+ for (const [key, value] of Object.entries(expectedJsonValue)) {
357
+ if (value !== null &&
358
+ typeof value !== "string" &&
359
+ typeof value !== "number" &&
360
+ typeof value !== "boolean") {
361
+ throw new Error("Deployment verification expectedJson values must be scalar.");
362
+ }
363
+ expectedJson[key] = value;
364
+ }
365
+ }
366
+ return { path, expectedStatus, ...(expectedJson ? { expectedJson } : {}) };
367
+ });
368
+ const profile = {
369
+ schemaVersion: 1,
370
+ driver: "kubernetes-helm",
371
+ target: {
372
+ context,
373
+ clusterFingerprint,
374
+ namespace,
375
+ release: releaseName,
376
+ createNamespace: false,
377
+ },
378
+ release: {
379
+ strategy: "shared-tag",
380
+ tagPattern,
381
+ apiRepository,
382
+ webRepository,
383
+ },
384
+ exposure: {
385
+ mode: exposureMode,
386
+ host,
387
+ ingressClassName,
388
+ tlsSecretName,
389
+ },
390
+ workloads: {
391
+ api: parseWorkload(requiredRecord(workloads, "api"), "workloads.api"),
392
+ web: parseWorkload(requiredRecord(workloads, "web"), "workloads.web"),
393
+ worker: workloads.worker === null
394
+ ? null
395
+ : parseWorkload(requiredRecord(workloads, "worker"), "workloads.worker"),
396
+ },
397
+ dependencies: { postgres, redis, objectStorage },
398
+ secrets: {
399
+ api: parseNamedSecret(secrets.api, "secrets.api"),
400
+ web: secrets.web === null
401
+ ? null
402
+ : parseNamedSecret(secrets.web, "secrets.web"),
403
+ imagePull: requiredString(secrets, "imagePull"),
404
+ },
405
+ runtimeConfig: parsedConfig,
406
+ verification: { baseUrl, checks },
407
+ };
408
+ validateDnsLabel(profile.secrets.imagePull, "secrets.imagePull");
409
+ return profile;
410
+ }
411
+ function profileDirectory(projectRoot) {
412
+ return (0, node_path_1.join)((0, node_path_1.resolve)(projectRoot), ".podokit", "deploy");
413
+ }
414
+ function profilePath(projectRoot, name) {
415
+ if (!PROFILE_NAME.test(name)) {
416
+ throw new Error(`Invalid deployment profile name "${name}".`);
417
+ }
418
+ return (0, node_path_1.join)(profileDirectory(projectRoot), `${name}.json`);
419
+ }
420
+ function loadDeploymentProfile(projectRoot, name) {
421
+ const path = profilePath(projectRoot, name);
422
+ if (!(0, node_fs_1.existsSync)(path))
423
+ throw new Error(`Deployment profile "${name}" does not exist at ${path}.`);
424
+ let parsed;
425
+ try {
426
+ parsed = JSON.parse((0, node_fs_1.readFileSync)(path, "utf8"));
427
+ }
428
+ catch (error) {
429
+ throw new Error(`Cannot read deployment profile "${name}": ${error.message}`);
430
+ }
431
+ return parseProfile(parsed);
432
+ }
433
+ function listDeploymentProfiles(projectRoot) {
434
+ const directory = profileDirectory(projectRoot);
435
+ if (!(0, node_fs_1.existsSync)(directory))
436
+ return [];
437
+ return (0, node_fs_1.readdirSync)(directory)
438
+ .filter((entry) => entry.endsWith(".json"))
439
+ .sort()
440
+ .map((entry) => {
441
+ const name = entry.slice(0, -5);
442
+ return { name, path: (0, node_path_1.join)(directory, entry), profile: loadDeploymentProfile(projectRoot, name) };
443
+ });
444
+ }
445
+ function initializeDeploymentProfile(projectRoot, name, options) {
446
+ const manifest = (0, lockfile_1.readManifest)(projectRoot);
447
+ if (!manifest)
448
+ throw new Error("Not a PodoKit project: .podokit/manifest.json is missing.");
449
+ const projectName = manifest.answers.projectName;
450
+ if (!projectName)
451
+ throw new Error("PodoKit manifest is missing answers.projectName.");
452
+ const path = profilePath(projectRoot, name);
453
+ if ((0, node_fs_1.existsSync)(path))
454
+ throw new Error(`Deployment profile "${name}" already exists.`);
455
+ const releaseName = projectName
456
+ .toLowerCase()
457
+ .replace(/[^a-z0-9-]+/g, "-")
458
+ .replace(/^-+|-+$/g, "")
459
+ .slice(0, 40);
460
+ if (!releaseName)
461
+ throw new Error("Project name cannot be converted to a Kubernetes release name.");
462
+ const host = options.host ?? "app.example.com";
463
+ const modules = new Set(manifest.modules.map((module) => module.name));
464
+ const usesRedis = [...modules].some((module) => ["redis", "bullmq", "job-progress", "rate-limit", "sse"].includes(module));
465
+ const usesObjectStorage = modules.has("object-storage-s3");
466
+ const usesAuth = modules.has("auth");
467
+ const apiRequiredKeys = [
468
+ ...(usesAuth ? ["BETTER_AUTH_SECRET"] : []),
469
+ ...(modules.has("api-key-auth") ? ["API_KEYS"] : []),
470
+ ];
471
+ const defaultProfile = {
472
+ schemaVersion: 1,
473
+ driver: "kubernetes-helm",
474
+ target: {
475
+ context: options.context,
476
+ clusterFingerprint: options.clusterFingerprint,
477
+ namespace: releaseName,
478
+ release: releaseName,
479
+ createNamespace: false,
480
+ },
481
+ release: {
482
+ strategy: "shared-tag",
483
+ tagPattern: STABLE_SEMVER_TAG_PATTERN,
484
+ apiRepository: `ghcr.io/example/${releaseName}-api`,
485
+ webRepository: `ghcr.io/example/${releaseName}-web`,
486
+ },
487
+ exposure: {
488
+ mode: "ingress",
489
+ host,
490
+ ingressClassName: "traefik",
491
+ tlsSecretName: null,
492
+ },
493
+ workloads: {
494
+ api: {
495
+ replicas: 2,
496
+ resources: {
497
+ cpuRequest: "250m",
498
+ cpuLimit: "1000m",
499
+ memoryRequest: "512Mi",
500
+ memoryLimit: "1Gi",
501
+ },
502
+ },
503
+ web: {
504
+ replicas: 2,
505
+ resources: {
506
+ cpuRequest: "100m",
507
+ cpuLimit: "500m",
508
+ memoryRequest: "256Mi",
509
+ memoryLimit: "1Gi",
510
+ },
511
+ },
512
+ worker: modules.has("bullmq")
513
+ ? {
514
+ replicas: 1,
515
+ resources: {
516
+ cpuRequest: "100m",
517
+ cpuLimit: "500m",
518
+ memoryRequest: "256Mi",
519
+ memoryLimit: "512Mi",
520
+ },
521
+ }
522
+ : null,
523
+ },
524
+ dependencies: {
525
+ postgres: {
526
+ mode: "inCluster",
527
+ image: "postgres:16.10-alpine",
528
+ secretName: `${releaseName}-postgres`,
529
+ storageClassName: "",
530
+ storageSize: "8Gi",
531
+ },
532
+ redis: {
533
+ mode: usesRedis ? "inCluster" : "disabled",
534
+ image: "redis:7.4.5-alpine",
535
+ secretName: `${releaseName}-redis`,
536
+ storageClassName: "",
537
+ storageSize: "4Gi",
538
+ },
539
+ objectStorage: {
540
+ mode: usesObjectStorage ? "inCluster" : "disabled",
541
+ image: "quay.io/minio/minio:RELEASE.2025-09-07T16-13-09Z",
542
+ clientImage: "quay.io/minio/mc:RELEASE.2025-08-13T08-35-41Z",
543
+ secretName: `${releaseName}-object-storage`,
544
+ storageClassName: "",
545
+ storageSize: "8Gi",
546
+ bucket: "app",
547
+ },
548
+ },
549
+ secrets: {
550
+ api: {
551
+ name: `${releaseName}-api`,
552
+ requiredKeys: apiRequiredKeys,
553
+ },
554
+ web: null,
555
+ imagePull: "registry-credentials",
556
+ },
557
+ runtimeConfig: {
558
+ NODE_ENV: "production",
559
+ CORS_ORIGIN: `https://${host}`,
560
+ ADDRESS_HEADER: "x-forwarded-for",
561
+ XFF_DEPTH: "1",
562
+ ...(usesAuth ? { BETTER_AUTH_URL: `https://${host}` } : {}),
563
+ ...(modules.has("sse") && usesRedis ? { SSE_TRANSPORT: "redis" } : {}),
564
+ ...(modules.has("rate-limit")
565
+ ? {
566
+ RATE_LIMIT_TTL: "60",
567
+ RATE_LIMIT_MAX: "300",
568
+ RATE_LIMIT_AUTH_TTL: "60",
569
+ RATE_LIMIT_AUTH_MAX: "20",
570
+ RATE_LIMIT_RUNTIME_MAX: "1000",
571
+ RATE_LIMIT_TRUSTED_PROXY_HOPS: "1",
572
+ RATE_LIMIT_PROXY_HEADER: "x-forwarded-for",
573
+ RATE_LIMIT_STORAGE_TIMEOUT_MS: "1000",
574
+ RATE_LIMIT_UNAVAILABLE_RETRY_AFTER: "1",
575
+ }
576
+ : {}),
577
+ },
578
+ verification: {
579
+ baseUrl: `https://${host}`,
580
+ checks: [
581
+ { path: "/", expectedStatus: 200 },
582
+ { path: "/api/health", expectedStatus: 200, expectedJson: { status: "ok" } },
583
+ {
584
+ path: "/api/health/ready",
585
+ expectedStatus: 200,
586
+ expectedJson: { status: "ready" },
587
+ },
588
+ ],
589
+ },
590
+ };
591
+ const validated = parseProfile(defaultProfile);
592
+ (0, node_fs_1.mkdirSync)(profileDirectory(projectRoot), { recursive: true });
593
+ (0, node_fs_1.writeFileSync)(path, `${JSON.stringify(validated, null, 2)}\n`, { flag: "wx" });
594
+ return { name, path, profile: validated };
595
+ }
596
+ function assertReleaseTag(profile, release) {
597
+ if (release === "latest" || !new RegExp(profile.release.tagPattern).test(release)) {
598
+ throw new Error(`Release "${release}" does not match deployment tag pattern ${profile.release.tagPattern}.`);
599
+ }
600
+ }
601
+ function deploymentProfileDigest(profile) {
602
+ return `sha256:${(0, node_crypto_1.createHash)("sha256").update(JSON.stringify(profile)).digest("hex")}`;
603
+ }
@@ -0,0 +1,18 @@
1
+ import type { DeployProfileV1 } from "./deploy-profile";
2
+ export interface DeploymentRuntime {
3
+ root: string;
4
+ dependencyChart: string;
5
+ applicationChart: string;
6
+ migrationManifest: string;
7
+ dependencyManifest: string;
8
+ applicationManifest: string;
9
+ }
10
+ export interface DeploymentImages {
11
+ api: string;
12
+ web: string;
13
+ postgres: string | null;
14
+ redis: string | null;
15
+ objectStorage: string | null;
16
+ objectStorageClient: string | null;
17
+ }
18
+ export declare function renderDeployment(projectRoot: string, profileName: string, profile: DeployProfileV1, release: string, resolvedImages?: DeploymentImages, rolloutStateDigest?: string): DeploymentRuntime;