@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
package/dist/dev.d.ts CHANGED
@@ -20,6 +20,7 @@ export type CommandRunner = (command: string, args: string[], options: {
20
20
  }) => CommandResult;
21
21
  export declare function readDevConfig(projectRoot: string): DevConfig;
22
22
  export declare function resolveDevRuntime(projectRoot: string): DevRuntime;
23
+ export declare function installedDevProfiles(projectRoot: string): string[];
23
24
  export declare function renderRuntimeCompose(runtime: DevRuntime): string;
24
25
  export declare function renderRoute(runtime: DevRuntime): string;
25
26
  export declare function runDevCommand(projectRoot: string, action: string | undefined, args: string[], runner?: CommandRunner): void;
package/dist/dev.js CHANGED
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.readDevConfig = readDevConfig;
4
4
  exports.resolveDevRuntime = resolveDevRuntime;
5
+ exports.installedDevProfiles = installedDevProfiles;
5
6
  exports.renderRuntimeCompose = renderRuntimeCompose;
6
7
  exports.renderRoute = renderRoute;
7
8
  exports.runDevCommand = runDevCommand;
@@ -17,6 +18,11 @@ const GATEWAY_LABEL = "io.podosoft.podokit.dev-gateway";
17
18
  const GATEWAY_NETWORK = "podokit-dev-gateway";
18
19
  const RUNTIME_COMPOSE = ".podokit/runtime/dev.compose.yaml";
19
20
  const DEV_CONFIG = ".podokit/dev.json";
21
+ const DEV_PROFILE_BY_MODULE = new Map([
22
+ ["redis", "cache"],
23
+ ["object-storage-s3", "storage"],
24
+ ["bullmq", "queue"],
25
+ ]);
20
26
  function defaultRunner(command, args, options) {
21
27
  const result = (0, node_child_process_1.spawnSync)(command, args, {
22
28
  encoding: "utf8",
@@ -96,6 +102,16 @@ function resolveDevRuntime(projectRoot) {
96
102
  runtimeComposePath: (0, node_path_1.join)(root, RUNTIME_COMPOSE),
97
103
  };
98
104
  }
105
+ function installedDevProfiles(projectRoot) {
106
+ const manifest = (0, lockfile_1.readManifest)(projectRoot);
107
+ if (!manifest)
108
+ return [];
109
+ return [
110
+ ...new Set(manifest.modules
111
+ .map((module) => DEV_PROFILE_BY_MODULE.get(module.name))
112
+ .filter((profile) => profile !== undefined)),
113
+ ];
114
+ }
99
115
  function renderRuntimeCompose(runtime) {
100
116
  return `# Generated by podo dev. Do not edit.\nservices:\n traefik:\n profiles: [podokit-legacy-proxy]\n web:\n networks:\n ${GATEWAY_NETWORK}:\n aliases: [${runtime.alias}]\nnetworks:\n ${GATEWAY_NETWORK}:\n external: true\n name: ${GATEWAY_NETWORK}\n`;
101
117
  }
@@ -226,9 +242,10 @@ function runCompose(runtime, args, runner) {
226
242
  throw new Error(`docker compose ${args.join(" ")} exited with status ${String(result.status)}.`);
227
243
  }
228
244
  }
229
- function composeCommand(action, args) {
245
+ function composeCommand(action, args, installedProfiles) {
230
246
  const global = [];
231
247
  const command = [];
248
+ const explicitProfiles = new Set();
232
249
  for (let index = 0; index < args.length; index += 1) {
233
250
  const value = args[index];
234
251
  if (value === undefined)
@@ -238,16 +255,22 @@ function composeCommand(action, args) {
238
255
  if (!profile)
239
256
  throw new Error("--profile requires a value.");
240
257
  global.push(value, profile);
258
+ explicitProfiles.add(profile);
241
259
  index += 1;
242
260
  }
243
261
  else if (value.startsWith("--profile=")) {
244
262
  global.push(value);
263
+ explicitProfiles.add(value.slice("--profile=".length));
245
264
  }
246
265
  else {
247
266
  command.push(value);
248
267
  }
249
268
  }
250
- const activeProfiles = action === "down" ? ["--profile", "*"] : [];
269
+ const activeProfiles = action === "down"
270
+ ? ["--profile", "*"]
271
+ : installedProfiles
272
+ .filter((profile) => !explicitProfiles.has(profile))
273
+ .flatMap((profile) => ["--profile", profile]);
251
274
  return [...activeProfiles, ...global, action, ...command];
252
275
  }
253
276
  function stopGatewayWhenUnused(runner) {
@@ -258,6 +281,7 @@ function stopGatewayWhenUnused(runner) {
258
281
  }
259
282
  function runDevCommand(projectRoot, action, args, runner = defaultRunner) {
260
283
  const runtime = resolveDevRuntime(projectRoot);
284
+ const profiles = installedDevProfiles(projectRoot);
261
285
  writeRuntimeFiles(runtime);
262
286
  if (action === "url") {
263
287
  process.stdout.write(`http://${runtime.config.hostname}\n`);
@@ -274,7 +298,7 @@ function runDevCommand(projectRoot, action, args, runner = defaultRunner) {
274
298
  process.stdout.write(`Development URL: http://${runtime.config.hostname}\n`);
275
299
  if (runtime.config.publicUrl)
276
300
  process.stdout.write(`Public OAuth URL: ${runtime.config.publicUrl}\n`);
277
- runCompose(runtime, composeCommand("watch", args), runner);
301
+ runCompose(runtime, composeCommand("watch", args, profiles), runner);
278
302
  }
279
303
  catch (error) {
280
304
  unregisterRoute(runtime);
@@ -284,13 +308,13 @@ function runDevCommand(projectRoot, action, args, runner = defaultRunner) {
284
308
  return;
285
309
  }
286
310
  if (action === "down") {
287
- runCompose(runtime, composeCommand("down", args), runner);
311
+ runCompose(runtime, composeCommand("down", args, profiles), runner);
288
312
  unregisterRoute(runtime);
289
313
  stopGatewayWhenUnused(runner);
290
314
  return;
291
315
  }
292
316
  if (action === "exec" || action === "logs" || action === "ps") {
293
- runCompose(runtime, composeCommand(action, args), runner);
317
+ runCompose(runtime, composeCommand(action, args, profiles), runner);
294
318
  return;
295
319
  }
296
320
  throw new Error("Usage: podo dev <watch|exec|logs|ps|down|url> [docker compose options]");
package/dist/index.d.ts CHANGED
@@ -16,6 +16,14 @@ interface ParsedArgs {
16
16
  positionals: string[];
17
17
  localeName?: string;
18
18
  localeDirection?: LocaleDirection;
19
+ profile?: string;
20
+ release?: string;
21
+ confirm?: string;
22
+ revision?: number;
23
+ context?: string;
24
+ clusterFingerprint?: string;
25
+ host?: string;
26
+ json: boolean;
19
27
  }
20
28
  export declare function parseArgs(argv: string[]): ParsedArgs;
21
29
  export {};
package/dist/index.js CHANGED
@@ -14,6 +14,9 @@ const update_1 = require("./update");
14
14
  const eject_1 = require("./eject");
15
15
  const dev_1 = require("./dev");
16
16
  const locale_1 = require("./locale");
17
+ const deploy_1 = require("./deploy");
18
+ const deploy_profile_1 = require("./deploy-profile");
19
+ const deploy_render_1 = require("./deploy-render");
17
20
  const HELP = `podo — PodoKit project generator
18
21
 
19
22
  Usage:
@@ -24,6 +27,7 @@ Usage:
24
27
  podo diff List PodoKit-managed files you have edited
25
28
  podo doctor Check framework versions against supported ranges
26
29
  podo dev <action> [...] Run the shared, portless container development gateway
30
+ podo deploy <action> Plan, apply, verify, or roll back a Kubernetes deployment
27
31
  podo locale <command> Add, validate, activate, or list JSON locales
28
32
  podo update [--apply] Preview (or apply) what a version update would change
29
33
  podo eject <path...> Take ownership of managed files (update skips them)
@@ -34,6 +38,14 @@ Options:
34
38
  --pm <name> Package manager: npm | pnpm | yarn (default: npm)
35
39
  --name <label> Display name for a locale
36
40
  --direction <direction> Text direction: ltr | rtl (default: ltr)
41
+ --profile <name> Deployment profile name
42
+ --release <tag> Immutable shared API/web release tag
43
+ --confirm <plan-hash> Exact deployment or rollback plan hash
44
+ --revision <number> Helm revision for rollback
45
+ --context <name> Explicit Kubernetes context for profile initialization
46
+ --cluster-fingerprint <sha256:...> Expected cluster fingerprint (auto-detected if omitted)
47
+ --host <hostname> Public hostname for profile initialization
48
+ --json Emit machine-readable JSON when supported
37
49
  --adopt Adopt colliding paths explicitly declared managed by a module
38
50
  --no-ai Skip AI agent guidance (AGENTS.md, CLAUDE.md, editor rules)
39
51
  -y, --yes Skip prompts and accept defaults
@@ -47,6 +59,22 @@ Example:
47
59
  npx @podosoft/podokit create my-app --template todo
48
60
  cd my-app && npx @podosoft/podokit add auth
49
61
  `;
62
+ const DEPLOY_HELP = `podo deploy — Kubernetes and Helm deployment workflow
63
+
64
+ Usage:
65
+ podo deploy init --profile <name> --context <context> [--host <hostname>]
66
+ podo deploy doctor --profile <name> [--json]
67
+ podo deploy render --profile <name> --release <tag> [--json]
68
+ podo deploy plan --profile <name> --release <tag> [--json]
69
+ podo deploy apply --profile <name> --release <tag> --confirm <plan-hash>
70
+ podo deploy status --profile <name> [--json]
71
+ podo deploy verify --profile <name> [--json]
72
+ podo deploy rollback --profile <name> --revision <number> [--confirm <plan-hash>]
73
+
74
+ The namespace, referenced Secrets, ingress controller, and storage classes must
75
+ already exist. Plan and rollback require access to the profile's exact cluster.
76
+ Apply and rollback require confirmation of the exact plan hash.
77
+ `;
50
78
  function parseArgs(argv) {
51
79
  const parsed = {
52
80
  help: false,
@@ -54,6 +82,7 @@ function parseArgs(argv) {
54
82
  apply: false,
55
83
  adopt: false,
56
84
  ai: true,
85
+ json: false,
57
86
  positionals: [],
58
87
  };
59
88
  const positionals = [];
@@ -96,6 +125,34 @@ function parseArgs(argv) {
96
125
  }
97
126
  parsed.localeDirection = direction;
98
127
  }
128
+ else if (arg === "--profile") {
129
+ parsed.profile = argv[++i];
130
+ }
131
+ else if (arg === "--release") {
132
+ parsed.release = argv[++i];
133
+ }
134
+ else if (arg === "--confirm") {
135
+ parsed.confirm = argv[++i];
136
+ }
137
+ else if (arg === "--revision") {
138
+ const revision = Number(argv[++i]);
139
+ if (!Number.isInteger(revision) || revision < 1) {
140
+ throw new Error("--revision must be a positive integer.");
141
+ }
142
+ parsed.revision = revision;
143
+ }
144
+ else if (arg === "--context") {
145
+ parsed.context = argv[++i];
146
+ }
147
+ else if (arg === "--cluster-fingerprint") {
148
+ parsed.clusterFingerprint = argv[++i];
149
+ }
150
+ else if (arg === "--host") {
151
+ parsed.host = argv[++i];
152
+ }
153
+ else if (arg === "--json") {
154
+ parsed.json = true;
155
+ }
99
156
  else if (arg !== undefined && !arg.startsWith("-")) {
100
157
  positionals.push(arg);
101
158
  }
@@ -112,7 +169,7 @@ function fail(message) {
112
169
  async function main(argv) {
113
170
  const args = parseArgs(argv);
114
171
  if (args.help || !args.command) {
115
- process.stdout.write(HELP);
172
+ process.stdout.write(args.help && args.command === "deploy" ? DEPLOY_HELP : HELP);
116
173
  return;
117
174
  }
118
175
  const modulesDir = (0, node_path_1.join)(__dirname, "templates", "modules");
@@ -236,6 +293,95 @@ async function main(argv) {
236
293
  }
237
294
  return;
238
295
  }
296
+ if (args.command === "deploy") {
297
+ const action = args.positionals[1] ?? "status";
298
+ const profileName = args.profile;
299
+ if (!profileName) {
300
+ fail("Deployment commands require --profile <name>.");
301
+ }
302
+ try {
303
+ if (action === "init") {
304
+ if (!args.context) {
305
+ fail("Usage: podo deploy init --profile <name> --context <context> [--host <hostname>].");
306
+ }
307
+ const clusterFingerprint = args.clusterFingerprint ?? (0, deploy_1.inspectClusterFingerprint)(args.context);
308
+ const initialized = (0, deploy_profile_1.initializeDeploymentProfile)(process.cwd(), profileName, {
309
+ context: args.context,
310
+ clusterFingerprint,
311
+ host: args.host,
312
+ });
313
+ process.stdout.write(args.json
314
+ ? `${JSON.stringify(initialized, null, 2)}\n`
315
+ : `Created deployment profile ${initialized.name} at ${initialized.path}.\n`);
316
+ return;
317
+ }
318
+ if (action === "doctor") {
319
+ const findings = (0, deploy_1.doctorDeployment)(process.cwd(), profileName);
320
+ process.stdout.write(args.json
321
+ ? `${JSON.stringify(findings, null, 2)}\n`
322
+ : `${findings.map((finding) => `${finding.ok ? "ok " : "FAIL"} ${finding.message}`).join("\n")}\n`);
323
+ if (findings.some((finding) => !finding.ok))
324
+ process.exitCode = 1;
325
+ return;
326
+ }
327
+ if (action === "render") {
328
+ if (!args.release)
329
+ fail("podo deploy render requires --release <tag>.");
330
+ const profile = (0, deploy_profile_1.loadDeploymentProfile)(process.cwd(), profileName);
331
+ const plan = (0, deploy_1.planDeployment)(process.cwd(), profileName, args.release);
332
+ const runtime = (0, deploy_render_1.renderDeployment)(process.cwd(), profileName, profile, args.release, plan.images, plan.rolloutStateDigest);
333
+ process.stdout.write(args.json
334
+ ? `${JSON.stringify({ plan, runtime }, null, 2)}\n`
335
+ : `Rendered deployment into ${runtime.root}.\nPlan hash: ${plan.planHash}\n`);
336
+ return;
337
+ }
338
+ if (action === "plan") {
339
+ if (!args.release)
340
+ fail("podo deploy plan requires --release <tag>.");
341
+ const plan = (0, deploy_1.planDeployment)(process.cwd(), profileName, args.release);
342
+ process.stdout.write(args.json
343
+ ? `${JSON.stringify(plan, null, 2)}\n`
344
+ : `${plan.actions.map((entry) => `${entry.order}. ${entry.description}`).join("\n")}\nPlan hash: ${plan.planHash}\n`);
345
+ return;
346
+ }
347
+ if (action === "apply") {
348
+ if (!args.release || !args.confirm) {
349
+ fail("podo deploy apply requires --release <tag> --confirm <plan-hash>.");
350
+ }
351
+ const result = await (0, deploy_1.applyDeployment)(process.cwd(), profileName, args.release, args.confirm);
352
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
353
+ return;
354
+ }
355
+ if (action === "status") {
356
+ const result = (0, deploy_1.getDeploymentStatus)(process.cwd(), profileName);
357
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
358
+ return;
359
+ }
360
+ if (action === "verify") {
361
+ const result = await (0, deploy_1.verifyDeployment)(process.cwd(), profileName);
362
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
363
+ if (!result.ok)
364
+ process.exitCode = 1;
365
+ return;
366
+ }
367
+ if (action === "rollback") {
368
+ if (!args.revision)
369
+ fail("podo deploy rollback requires --revision <number>.");
370
+ if (!args.confirm) {
371
+ const plan = (0, deploy_1.planRollback)(process.cwd(), profileName, args.revision);
372
+ process.stdout.write(`${JSON.stringify(plan, null, 2)}\nRe-run with --confirm ${plan.planHash}.\n`);
373
+ return;
374
+ }
375
+ const result = await (0, deploy_1.rollbackDeployment)(process.cwd(), profileName, args.revision, args.confirm);
376
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
377
+ return;
378
+ }
379
+ fail(`Unknown deploy command "${action}". Use init, doctor, render, plan, apply, status, verify, or rollback.`);
380
+ }
381
+ catch (err) {
382
+ fail(err.message);
383
+ }
384
+ }
239
385
  if (args.command === "locale") {
240
386
  const action = args.positionals[1] ?? "list";
241
387
  const code = args.positionals[2];
package/dist/lib.d.ts CHANGED
@@ -10,6 +10,12 @@ export { planUpdate, summarize } from "./update";
10
10
  export type { UpdatePlan, FileChange } from "./update";
11
11
  export { readDevConfig, renderRoute, renderRuntimeCompose, resolveDevRuntime, runDevCommand } from "./dev";
12
12
  export type { CommandRunner, DevConfig, DevRuntime } from "./dev";
13
+ export { applyDeployment, doctorDeployment, getDeploymentStatus, inspectClusterFingerprint, planDeployment, planRollback, rollbackDeployment, verifyDeployment, } from "./deploy";
14
+ export type { DeploymentDoctorFinding, DeploymentPlan, DeploymentPlanAction, DeploymentStatus, VerificationResult, } from "./deploy";
15
+ export { assertReleaseTag, deploymentProfileDigest, initializeDeploymentProfile, listDeploymentProfiles, loadDeploymentProfile, } from "./deploy-profile";
16
+ export type { DependencyMode, DeployProfileV1, DeploymentProfileSummary, InitializeDeploymentProfileOptions, ObjectStorageDependencyProfile, RedisDependencyProfile, ResourceProfile, StatefulDependencyProfile, WorkloadProfile, } from "./deploy-profile";
17
+ export { renderDeployment } from "./deploy-render";
18
+ export type { DeploymentRuntime } from "./deploy-render";
13
19
  /** Absolute path to the templates bundled in this installed package. */
14
20
  export declare function builtinTemplatesDir(): string;
15
21
  /** Absolute path to the modules bundled in this installed package. */
package/dist/lib.js CHANGED
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.runDevCommand = exports.resolveDevRuntime = exports.renderRuntimeCompose = exports.renderRoute = exports.readDevConfig = exports.summarize = exports.planUpdate = exports.SUPPORTED_FRAMEWORKS = exports.doctor = exports.diff = exports.status = exports.addModule = exports.listModules = exports.isKnownTemplate = exports.TEMPLATE_NAMES = exports.DEFAULT_TEMPLATE = exports.TEMPLATES = exports.create = void 0;
3
+ exports.renderDeployment = exports.loadDeploymentProfile = exports.listDeploymentProfiles = exports.initializeDeploymentProfile = exports.deploymentProfileDigest = exports.assertReleaseTag = exports.verifyDeployment = exports.rollbackDeployment = exports.planRollback = exports.planDeployment = exports.inspectClusterFingerprint = exports.getDeploymentStatus = exports.doctorDeployment = exports.applyDeployment = exports.runDevCommand = exports.resolveDevRuntime = exports.renderRuntimeCompose = exports.renderRoute = exports.readDevConfig = exports.summarize = exports.planUpdate = exports.SUPPORTED_FRAMEWORKS = exports.doctor = exports.diff = exports.status = exports.addModule = exports.listModules = exports.isKnownTemplate = exports.TEMPLATE_NAMES = exports.DEFAULT_TEMPLATE = exports.TEMPLATES = exports.create = void 0;
4
4
  exports.builtinTemplatesDir = builtinTemplatesDir;
5
5
  exports.builtinModulesDir = builtinModulesDir;
6
6
  // Library entry point: the reusable functions behind the `podo` CLI, for other
@@ -30,6 +30,23 @@ Object.defineProperty(exports, "renderRoute", { enumerable: true, get: function
30
30
  Object.defineProperty(exports, "renderRuntimeCompose", { enumerable: true, get: function () { return dev_1.renderRuntimeCompose; } });
31
31
  Object.defineProperty(exports, "resolveDevRuntime", { enumerable: true, get: function () { return dev_1.resolveDevRuntime; } });
32
32
  Object.defineProperty(exports, "runDevCommand", { enumerable: true, get: function () { return dev_1.runDevCommand; } });
33
+ var deploy_1 = require("./deploy");
34
+ Object.defineProperty(exports, "applyDeployment", { enumerable: true, get: function () { return deploy_1.applyDeployment; } });
35
+ Object.defineProperty(exports, "doctorDeployment", { enumerable: true, get: function () { return deploy_1.doctorDeployment; } });
36
+ Object.defineProperty(exports, "getDeploymentStatus", { enumerable: true, get: function () { return deploy_1.getDeploymentStatus; } });
37
+ Object.defineProperty(exports, "inspectClusterFingerprint", { enumerable: true, get: function () { return deploy_1.inspectClusterFingerprint; } });
38
+ Object.defineProperty(exports, "planDeployment", { enumerable: true, get: function () { return deploy_1.planDeployment; } });
39
+ Object.defineProperty(exports, "planRollback", { enumerable: true, get: function () { return deploy_1.planRollback; } });
40
+ Object.defineProperty(exports, "rollbackDeployment", { enumerable: true, get: function () { return deploy_1.rollbackDeployment; } });
41
+ Object.defineProperty(exports, "verifyDeployment", { enumerable: true, get: function () { return deploy_1.verifyDeployment; } });
42
+ var deploy_profile_1 = require("./deploy-profile");
43
+ Object.defineProperty(exports, "assertReleaseTag", { enumerable: true, get: function () { return deploy_profile_1.assertReleaseTag; } });
44
+ Object.defineProperty(exports, "deploymentProfileDigest", { enumerable: true, get: function () { return deploy_profile_1.deploymentProfileDigest; } });
45
+ Object.defineProperty(exports, "initializeDeploymentProfile", { enumerable: true, get: function () { return deploy_profile_1.initializeDeploymentProfile; } });
46
+ Object.defineProperty(exports, "listDeploymentProfiles", { enumerable: true, get: function () { return deploy_profile_1.listDeploymentProfiles; } });
47
+ Object.defineProperty(exports, "loadDeploymentProfile", { enumerable: true, get: function () { return deploy_profile_1.loadDeploymentProfile; } });
48
+ var deploy_render_1 = require("./deploy-render");
49
+ Object.defineProperty(exports, "renderDeployment", { enumerable: true, get: function () { return deploy_render_1.renderDeployment; } });
33
50
  /** Absolute path to the templates bundled in this installed package. */
34
51
  function builtinTemplatesDir() {
35
52
  return (0, node_path_1.join)(__dirname, "templates");
package/dist/lockfile.js CHANGED
@@ -53,6 +53,7 @@ exports.DEFAULT_OWNED_GLOBS = [
53
53
  // AI agent guidance — yours to customize; update never touches it.
54
54
  "AGENTS.md",
55
55
  "CLAUDE.md",
56
+ ".agents/**",
56
57
  ".claude/**",
57
58
  ".cursor/**",
58
59
  ".github/copilot-instructions.md",
package/dist/remove.js CHANGED
@@ -110,6 +110,13 @@ function removeModule(options) {
110
110
  const sharedManagedOverrides = new Set(others.flatMap((other) => other.manifest.managedOverrides ?? []));
111
111
  const targetManagedOverrides = new Set(targetManifest.managedOverrides ?? []);
112
112
  manifest.managedOverrides = (manifest.managedOverrides ?? []).filter((glob) => !targetManagedOverrides.has(glob) || sharedManagedOverrides.has(glob));
113
+ const defaultOwnedGlobs = new Set(lockfile_1.DEFAULT_OWNED_GLOBS);
114
+ const sharedOwnedGlobs = new Set(others.flatMap((other) => other.manifest.ownedGlobs ?? []));
115
+ const targetOwnedGlobs = new Set(targetManifest.ownedGlobs ?? []);
116
+ manifest.ownedGlobs = manifest.ownedGlobs.filter((glob) => !targetOwnedGlobs.has(glob) ||
117
+ defaultOwnedGlobs.has(glob) ||
118
+ sharedOwnedGlobs.has(glob) ||
119
+ keptEdited.some((path) => (0, lockfile_1.matchGlob)(path, glob)));
113
120
  (0, lockfile_1.writeManifest)(projectRoot, manifest);
114
121
  (0, lockfile_1.writeFilesLock)(projectRoot, (0, lockfile_1.computeFilesLock)(projectRoot, manifest.ownedGlobs, manifest.managedOverrides));
115
122
  return { module, removed, keptEdited, keptShared, unwired };
@@ -69,6 +69,7 @@ Useful commands:
69
69
  - `podo remove <module>` — un-apply a module (inverse of add; refuses if another module needs it, keeps files you edited).
70
70
  - `podo status` / `podo diff` — see your local edits vs. what PodoKit generated.
71
71
  - `podo update` — pull in template/module improvements (3-way merges your edits; never touches owned files).
72
+ - `podo deploy` — plan, apply, verify, or roll back an immutable Kubernetes release. Use `.agents/skills/podokit-deploy`.
72
73
 
73
74
  ## Commit conventions
74
75
 
@@ -33,16 +33,17 @@ npx @podosoft/podokit dev exec api npm run migration:run -w {{projectName}}-api
33
33
  npx @podosoft/podokit dev down
34
34
  ```
35
35
 
36
- If installed modules require extra services, use their profiles on the initial
37
- watch command instead:
36
+ `dev watch` reads `.podokit/manifest.json` and automatically activates `cache`,
37
+ `storage`, and `queue` when installed modules require Redis, MinIO, or a worker.
38
+ You can still activate an additional Compose profile explicitly:
38
39
 
39
40
  ```bash
40
- npx @podosoft/podokit dev watch \
41
- --profile cache --profile storage --profile queue
41
+ npx @podosoft/podokit dev watch --profile dev
42
42
  ```
43
43
 
44
- Pass the same profile flags to other lifecycle commands when applicable. `dev down`
45
- removes this project's stack and route. If it is the final registered
44
+ Explicit profile flags are preserved for other lifecycle commands. `dev down`
45
+ activates all profiles while removing this project's stack and route. If it is
46
+ the final registered
46
47
  project, it also removes the shared gateway and network. Source changes are synced
47
48
  through Compose Watch, including Vite HMR on the same portless browser origin.
48
49
 
@@ -79,8 +80,18 @@ stable HTTPS tunnel, see the PodoKit [development guide](https://github.com/podo
79
80
 
80
81
  ## Deploy
81
82
 
82
- Docker Compose in `infra/docker`; example k3s manifests in `infra/k3s`
83
- (use `secret.example.yaml` as a template — never commit real secrets).
83
+ Docker Compose lives in `infra/docker`; the basic k3s manifests in `infra/k3s`
84
+ route every public path through the web proxy. For a managed release on an
85
+ existing cluster, initialize an application-owned profile:
86
+
87
+ ```bash
88
+ npx @podosoft/podokit deploy init --profile production --context production --host app.example.com
89
+ npx @podosoft/podokit deploy doctor --profile production
90
+ npx @podosoft/podokit deploy plan --profile production --release v1.2.3
91
+ ```
92
+
93
+ Use existing Kubernetes Secrets and never commit their values. See the PodoKit
94
+ [deployment guide](https://github.com/podosoft-dev/podokit/blob/main/docs/deployment.md).
84
95
 
85
96
  Build production images from the workspace root so Docker can use the committed
86
97
  lockfile and all workspace package manifests:
@@ -16,12 +16,12 @@ RUN npm run build --workspace=apps/api
16
16
  FROM node:22-alpine AS runtime
17
17
  WORKDIR /app
18
18
  ENV NODE_ENV=production
19
- RUN addgroup -S app && adduser -S app -G app
19
+ RUN addgroup -S -g 101 app && adduser -S -D -H -u 100 -G app app
20
20
  COPY --from=build /app/node_modules ./node_modules
21
21
  COPY --from=deps /app/apps/api ./apps/api
22
22
  COPY --from=build /app/apps/api/dist ./apps/api/dist
23
23
  COPY --from=build /app/apps/api/scripts ./apps/api/scripts
24
24
  WORKDIR /app/apps/api
25
- USER app
25
+ USER 100:101
26
26
  EXPOSE 3000
27
27
  CMD ["node", "dist/main"]
@@ -8,6 +8,7 @@
8
8
  "start": "node dist/main",
9
9
  "lint": "tsc -p tsconfig.json --noEmit",
10
10
  "test": "jest --passWithNoTests",
11
+ "migrate:all": "node dist/database/migrate",
11
12
  "typeorm": "typeorm-ts-node-commonjs -d src/database/data-source.ts",
12
13
  "migration:run": "npm run typeorm -- migration:run",
13
14
  "migration:revert": "npm run typeorm -- migration:revert",
@@ -0,0 +1,53 @@
1
+ import { describe, expect, it } from "@jest/globals";
2
+ import { redisConnectionOptions } from "./redis.connection";
3
+
4
+ describe("redisConnectionOptions", () => {
5
+ it("prefers an authenticated URL over individual settings", () => {
6
+ expect(
7
+ redisConnectionOptions({
8
+ REDIS_URL: "rediss://user:p%40ss@cache.example.com:6381/4",
9
+ REDIS_HOST: "ignored",
10
+ REDIS_PASSWORD: "ignored",
11
+ }),
12
+ ).toEqual({
13
+ host: "cache.example.com",
14
+ port: 6381,
15
+ db: 4,
16
+ username: "user",
17
+ password: "p@ss",
18
+ tls: {},
19
+ });
20
+ });
21
+
22
+ it("uses authenticated host settings when a URL is absent", () => {
23
+ expect(
24
+ redisConnectionOptions({
25
+ REDIS_HOST: "cache",
26
+ REDIS_PORT: "6380",
27
+ REDIS_DB: "2",
28
+ REDIS_USERNAME: "app",
29
+ REDIS_PASSWORD: "secret",
30
+ REDIS_TLS: "true",
31
+ }),
32
+ ).toEqual({
33
+ host: "cache",
34
+ port: 6380,
35
+ db: 2,
36
+ username: "app",
37
+ password: "secret",
38
+ tls: {},
39
+ });
40
+ });
41
+
42
+ it("does not include a malformed URL value in its error", () => {
43
+ const secretUrl = "not-a-url-with-a-secret";
44
+ expect(() => redisConnectionOptions({ REDIS_URL: secretUrl })).toThrow(
45
+ "REDIS_URL must be a valid redis:// or rediss:// URL",
46
+ );
47
+ try {
48
+ redisConnectionOptions({ REDIS_URL: secretUrl });
49
+ } catch (error) {
50
+ expect(String(error)).not.toContain(secretUrl);
51
+ }
52
+ });
53
+ });
@@ -0,0 +1,69 @@
1
+ export interface RedisConnectionOptions {
2
+ host: string;
3
+ port: number;
4
+ db: number;
5
+ username?: string;
6
+ password?: string;
7
+ tls?: Record<string, never>;
8
+ lazyConnect?: boolean;
9
+ enableReadyCheck?: boolean;
10
+ enableOfflineQueue?: boolean;
11
+ maxRetriesPerRequest?: number | null;
12
+ connectTimeout?: number;
13
+ commandTimeout?: number;
14
+ }
15
+
16
+ function integer(name: string, value: string | undefined, fallback: number): number {
17
+ if (value === undefined || value === "") return fallback;
18
+ const parsed = Number(value);
19
+ if (!Number.isInteger(parsed) || parsed < 0) {
20
+ throw new Error(`${name} must be a non-negative integer`);
21
+ }
22
+ return parsed;
23
+ }
24
+
25
+ function decoded(value: string, name: string): string {
26
+ try {
27
+ return decodeURIComponent(value);
28
+ } catch {
29
+ throw new Error(`${name} contains invalid percent encoding`);
30
+ }
31
+ }
32
+
33
+ function fromUrl(value: string): RedisConnectionOptions {
34
+ let url: URL;
35
+ try {
36
+ url = new URL(value);
37
+ } catch {
38
+ throw new Error("REDIS_URL must be a valid redis:// or rediss:// URL");
39
+ }
40
+ if (url.protocol !== "redis:" && url.protocol !== "rediss:") {
41
+ throw new Error("REDIS_URL must use the redis:// or rediss:// protocol");
42
+ }
43
+ const dbPath = url.pathname.replace(/^\/+/, "");
44
+ return {
45
+ host: url.hostname || "localhost",
46
+ port: integer("REDIS_URL port", url.port, 6379),
47
+ db: integer("REDIS_URL database", dbPath, 0),
48
+ ...(url.username ? { username: decoded(url.username, "REDIS_URL username") } : {}),
49
+ ...(url.password ? { password: decoded(url.password, "REDIS_URL password") } : {}),
50
+ ...(url.protocol === "rediss:" ? { tls: {} } : {}),
51
+ };
52
+ }
53
+
54
+ export function redisConnectionOptions(
55
+ env: NodeJS.ProcessEnv = process.env,
56
+ overrides: Partial<RedisConnectionOptions> = {},
57
+ ): RedisConnectionOptions {
58
+ const base = env.REDIS_URL
59
+ ? fromUrl(env.REDIS_URL)
60
+ : {
61
+ host: env.REDIS_HOST ?? "localhost",
62
+ port: integer("REDIS_PORT", env.REDIS_PORT, 6379),
63
+ db: integer("REDIS_DB", env.REDIS_DB, 0),
64
+ ...(env.REDIS_USERNAME ? { username: env.REDIS_USERNAME } : {}),
65
+ ...(env.REDIS_PASSWORD ? { password: env.REDIS_PASSWORD } : {}),
66
+ ...(env.REDIS_TLS === "true" ? { tls: {} } : {}),
67
+ };
68
+ return { ...base, ...overrides };
69
+ }
@@ -0,0 +1,16 @@
1
+ import dataSource from "./data-source";
2
+
3
+ async function migrate(): Promise<void> {
4
+ await dataSource.initialize();
5
+ try {
6
+ await dataSource.runMigrations({ transaction: "all" });
7
+ } finally {
8
+ await dataSource.destroy();
9
+ }
10
+ }
11
+
12
+ void migrate().catch((error: unknown) => {
13
+ const message = error instanceof Error ? error.message : String(error);
14
+ process.stderr.write(`Migration failed: ${message}\n`);
15
+ process.exitCode = 1;
16
+ });