@podosoft/podokit 0.16.4 → 0.17.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 (75) hide show
  1. package/dist/deploy-compose-exec.d.ts +93 -0
  2. package/dist/deploy-compose-exec.js +198 -0
  3. package/dist/deploy-compose-profile.d.ts +107 -0
  4. package/dist/deploy-compose-profile.js +361 -0
  5. package/dist/deploy-compose-render.d.ts +48 -0
  6. package/dist/deploy-compose-render.js +333 -0
  7. package/dist/deploy-compose-sync.d.ts +144 -0
  8. package/dist/deploy-compose-sync.js +412 -0
  9. package/dist/deploy-compose.d.ts +98 -0
  10. package/dist/deploy-compose.js +667 -0
  11. package/dist/deploy-dispatch.d.ts +33 -0
  12. package/dist/deploy-dispatch.js +58 -0
  13. package/dist/deploy-driver.d.ts +25 -0
  14. package/dist/deploy-driver.js +78 -0
  15. package/dist/deploy-profile.d.ts +3 -10
  16. package/dist/deploy-profile.js +82 -291
  17. package/dist/deploy-schema.d.ts +56 -0
  18. package/dist/deploy-schema.js +261 -0
  19. package/dist/index.d.ts +7 -0
  20. package/dist/index.js +178 -6
  21. package/dist/lib.d.ts +9 -0
  22. package/dist/lib.js +30 -1
  23. package/dist/templates/fullstack-nest-svelte/AGENTS.md +2 -1
  24. package/dist/templates/fullstack-nest-svelte/Dockerfile.dev +34 -3
  25. package/dist/templates/fullstack-nest-svelte/apps/api/Dockerfile +68 -7
  26. package/dist/templates/fullstack-nest-svelte/apps/web/Dockerfile +60 -8
  27. package/dist/templates/fullstack-nest-svelte/apps/web/server.js +31 -0
  28. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/server/backend-proxy.test.ts +21 -0
  29. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/server/backend-proxy.ts +15 -1
  30. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/server/ws-upgrade.js +267 -0
  31. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/server/ws-upgrade.test.js +134 -0
  32. package/dist/templates/fullstack-nest-svelte/apps/web/vite.config.ts +17 -7
  33. package/dist/templates/fullstack-nest-svelte/dot-agents/skills/podokit-deploy/SKILL.md +55 -10
  34. package/dist/templates/fullstack-nest-svelte/dot-agents/skills/podokit-deploy/references/profile.md +50 -1
  35. package/dist/templates/fullstack-nest-svelte/dot-agents/skills/podokit-deploy/references/release.md +64 -0
  36. package/dist/templates/fullstack-nest-svelte/dot-agents/skills/podokit-deploy-fast/SKILL.md +92 -0
  37. package/dist/templates/fullstack-nest-svelte/dot-claude/skills/podokit-deploy/SKILL.md +1 -1
  38. package/dist/templates/fullstack-nest-svelte/dot-claude/skills/podokit-deploy-fast/SKILL.md +8 -0
  39. package/dist/templates/fullstack-nest-svelte/dot-dockerignore +7 -0
  40. package/dist/templates/fullstack-nest-svelte/dot-env.example +7 -0
  41. package/dist/templates/fullstack-nest-svelte/dot-github/workflows/release.yml +142 -0
  42. package/dist/templates/fullstack-nest-svelte/infra/k3s/README.md +18 -0
  43. package/dist/templates/fullstack-nest-svelte/infra/k3s/api-deployment.yaml +6 -1
  44. package/dist/templates/fullstack-nest-svelte/infra/k3s/ingress.yaml +8 -0
  45. package/dist/templates/fullstack-nest-svelte/infra/k3s/web-deployment.yaml +11 -1
  46. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-page.svelte +3 -2
  47. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/device-label.test.ts +62 -0
  48. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/device-label.ts +50 -0
  49. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/sessions/+page.svelte +3 -2
  50. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/users/+page.svelte +3 -2
  51. package/dist/templates/modules/bullmq/files/infra/k3s/worker-deployment.yaml +2 -1
  52. package/dist/templates/todo/AGENTS.md +2 -1
  53. package/dist/templates/todo/Dockerfile.dev +34 -3
  54. package/dist/templates/todo/apps/api/Dockerfile +68 -7
  55. package/dist/templates/todo/apps/web/Dockerfile +60 -8
  56. package/dist/templates/todo/apps/web/server.js +31 -0
  57. package/dist/templates/todo/apps/web/src/lib/server/backend-proxy.test.ts +21 -0
  58. package/dist/templates/todo/apps/web/src/lib/server/backend-proxy.ts +15 -1
  59. package/dist/templates/todo/apps/web/src/lib/server/ws-upgrade.js +267 -0
  60. package/dist/templates/todo/apps/web/src/lib/server/ws-upgrade.test.js +134 -0
  61. package/dist/templates/todo/apps/web/vite.config.ts +17 -7
  62. package/dist/templates/todo/dot-agents/skills/podokit-deploy/SKILL.md +55 -10
  63. package/dist/templates/todo/dot-agents/skills/podokit-deploy/references/profile.md +47 -0
  64. package/dist/templates/todo/dot-agents/skills/podokit-deploy/references/release.md +64 -0
  65. package/dist/templates/todo/dot-agents/skills/podokit-deploy-fast/SKILL.md +92 -0
  66. package/dist/templates/todo/dot-claude/skills/podokit-deploy/SKILL.md +1 -1
  67. package/dist/templates/todo/dot-claude/skills/podokit-deploy-fast/SKILL.md +8 -0
  68. package/dist/templates/todo/dot-dockerignore +7 -0
  69. package/dist/templates/todo/dot-env.example +7 -0
  70. package/dist/templates/todo/dot-github/workflows/release.yml +142 -0
  71. package/dist/templates/todo/infra/k3s/README.md +18 -0
  72. package/dist/templates/todo/infra/k3s/api-deployment.yaml +6 -1
  73. package/dist/templates/todo/infra/k3s/ingress.yaml +8 -0
  74. package/dist/templates/todo/infra/k3s/web-deployment.yaml +11 -1
  75. package/package.json +1 -1
@@ -0,0 +1,18 @@
1
+ # Example k3s manifests
2
+
3
+ These are **illustrative**. `podo deploy` does not read or apply them.
4
+
5
+ For a real deployment, initialize a profile and let PodoKit render the manifests it
6
+ will actually apply — with image digests, probes, disruption budgets, a migration
7
+ Job, and a confirmation hash:
8
+
9
+ ```bash
10
+ podo deploy init --profile production --context production --host app.example.com
11
+ podo deploy render --profile production --release v1.2.3
12
+ ```
13
+
14
+ Keep these files if you want a starting point for hand-managed manifests, or delete
15
+ the directory if you deploy with `podo deploy`. If you do apply them by hand, replace
16
+ every `example.com` and `ghcr.io/example/...` placeholder first, and note that the
17
+ image tags below are placeholders too: the deployment tooling rejects `latest`,
18
+ branch tags, and empty tags, and so should you.
@@ -1,3 +1,4 @@
1
+ # Example only. See README.md in this directory; `podo deploy` renders its own.
1
2
  apiVersion: apps/v1
2
3
  kind: Deployment
3
4
  metadata:
@@ -13,7 +14,8 @@ spec:
13
14
  spec:
14
15
  containers:
15
16
  - name: api
16
- image: ghcr.io/example/podokit-api:latest
17
+ # Placeholder. Use one immutable, stable SemVer tag for api and web.
18
+ image: ghcr.io/example/podokit-api:v0.0.0
17
19
  ports:
18
20
  - containerPort: 3000
19
21
  envFrom:
@@ -22,3 +24,6 @@ spec:
22
24
  readinessProbe:
23
25
  httpGet: { path: /health/ready, port: 3000 }
24
26
  initialDelaySeconds: 5
27
+ livenessProbe:
28
+ httpGet: { path: /health, port: 3000 }
29
+ initialDelaySeconds: 15
@@ -1,3 +1,4 @@
1
+ # Example only. See README.md in this directory; `podo deploy` renders its own.
1
2
  apiVersion: traefik.io/v1alpha1
2
3
  kind: Middleware
3
4
  metadata:
@@ -14,6 +15,13 @@ metadata:
14
15
  annotations:
15
16
  traefik.ingress.kubernetes.io/router.middlewares: podokit-compression@kubernetescrd
16
17
  spec:
18
+ # Name the controller explicitly rather than relying on a default IngressClass.
19
+ ingressClassName: traefik
20
+ # app-config sets an https:// origin, so the route has to terminate TLS. Create the
21
+ # Secret out of band (cert-manager, or an existing certificate) and name it here.
22
+ tls:
23
+ - hosts: [example.com]
24
+ secretName: app-tls
17
25
  rules:
18
26
  - host: example.com
19
27
  http:
@@ -1,3 +1,4 @@
1
+ # Example only. See README.md in this directory; `podo deploy` renders its own.
1
2
  apiVersion: apps/v1
2
3
  kind: Deployment
3
4
  metadata:
@@ -13,8 +14,17 @@ spec:
13
14
  spec:
14
15
  containers:
15
16
  - name: web
16
- image: ghcr.io/example/podokit-web:latest
17
+ # Placeholder. Use one immutable, stable SemVer tag for api and web.
18
+ image: ghcr.io/example/podokit-web:v0.0.0
17
19
  ports:
18
20
  - containerPort: 3000
19
21
  envFrom:
20
22
  - configMapRef: { name: app-config }
23
+ # Without these a rolling update sends traffic to a process that has not
24
+ # bound its port yet.
25
+ readinessProbe:
26
+ httpGet: { path: /, port: 3000 }
27
+ initialDelaySeconds: 5
28
+ livenessProbe:
29
+ httpGet: { path: /, port: 3000 }
30
+ initialDelaySeconds: 15
@@ -9,6 +9,7 @@
9
9
  import * as Table from "$lib/components/ui/table";
10
10
  import * as Dialog from "$lib/components/ui/dialog";
11
11
  import DataTable, { type DataTableColumn, type SortState, DEFAULT_PAGE_SIZE } from "$lib/components/data-table.svelte";
12
+ import { deviceLabel } from "$lib/device-label";
12
13
  import { toast } from "svelte-sonner";
13
14
  import { api } from "$lib/api";
14
15
  import { getI18n, fmt, formatDateTime } from "$lib/i18n";
@@ -382,7 +383,7 @@
382
383
  let sessionsSort = $state<SortState | null>(null);
383
384
  let busy = $state(false);
384
385
  const sessionsColumns: DataTableColumn<Session>[] = [
385
- { key: "userAgent", label: i18n.t.sessions.device, sortable: true, value: (s) => s.userAgent ?? "" },
386
+ { key: "userAgent", label: i18n.t.sessions.device, sortable: true, value: (s) => deviceLabel(s.userAgent) ?? s.userAgent ?? "" },
386
387
  { key: "ipAddress", label: i18n.t.sessions.ip, sortable: true, value: (s) => s.ipAddress ?? "" },
387
388
  { key: "createdAt", label: i18n.t.sessions.since, sortable: true, value: (s) => new Date(s.createdAt).getTime() },
388
389
  { key: "status", label: i18n.t.sessions.status },
@@ -793,7 +794,7 @@
793
794
  >
794
795
  {#snippet row(s)}
795
796
  {@const isCurrent = s.id === data.currentSessionId}
796
- <Table.Cell class="max-w-xs truncate">{s.userAgent ?? i18n.t.sessions.unknown}</Table.Cell>
797
+ <Table.Cell class="max-w-xs truncate" title={s.userAgent ?? ""}>{deviceLabel(s.userAgent) ?? s.userAgent ?? i18n.t.sessions.unknown}</Table.Cell>
797
798
  <Table.Cell class="text-muted-foreground">{s.ipAddress ?? "—"}</Table.Cell>
798
799
  <Table.Cell class="text-muted-foreground">{formatDateTime(s.createdAt)}</Table.Cell>
799
800
  <Table.Cell>{#if isCurrent}<Badge>{i18n.t.sessions.current}</Badge>{/if}</Table.Cell>
@@ -0,0 +1,62 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { deviceLabel } from "./device-label";
3
+
4
+ describe("deviceLabel", () => {
5
+ it("names the browser and the machine", () => {
6
+ expect(
7
+ deviceLabel(
8
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36",
9
+ ),
10
+ ).toBe("Chrome · macOS");
11
+ expect(
12
+ deviceLabel(
13
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36",
14
+ ),
15
+ ).toBe("Chrome · Windows");
16
+ });
17
+
18
+ // Each of these contains an earlier engine's token, so a naive check in the wrong
19
+ // order reports every one of them as Chrome or Safari.
20
+ it("does not mistake a derivative for the engine it embeds", () => {
21
+ expect(deviceLabel("Mozilla/5.0 (Windows NT 10.0) Chrome/141.0.0.0 Safari/537.36 Edg/141.0.0.0")).toBe(
22
+ "Edge · Windows",
23
+ );
24
+ expect(deviceLabel("Mozilla/5.0 (Windows NT 10.0) Chrome/141.0.0.0 Safari/537.36 OPR/121.0.0.0")).toBe(
25
+ "Opera · Windows",
26
+ );
27
+ expect(
28
+ deviceLabel("Mozilla/5.0 (Linux; Android 15) AppleWebKit/537.36 Chrome/141.0.0.0 SamsungBrowser/28.0 Safari/537.36"),
29
+ ).toBe("Samsung Internet · Android");
30
+ expect(
31
+ deviceLabel("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 Version/18.0 Safari/605.1.15"),
32
+ ).toBe("Safari · macOS");
33
+ });
34
+
35
+ it("names an iPhone and an iPad rather than the engine's host platform", () => {
36
+ expect(
37
+ deviceLabel("Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 Version/18.0 Mobile/15E148 Safari/604.1"),
38
+ ).toBe("Safari · iPhone");
39
+ // iPadOS reports Macintosh in the same string, so order decides this one.
40
+ expect(
41
+ deviceLabel("Mozilla/5.0 (iPad; CPU OS 18_0 like Mac OS X) AppleWebKit/605.1.15 Version/18.0 Mobile/15E148 Safari/604.1"),
42
+ ).toBe("Safari · iPad");
43
+ expect(deviceLabel("Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) CriOS/141.0.0.0 Mobile/15E148 Safari/604.1")).toBe(
44
+ "Chrome · iPhone",
45
+ );
46
+ });
47
+
48
+ it("gives up on something that is not a browser, so the caller can show it raw", () => {
49
+ // The exact value is the whole point for these: "node" is the symptom of a
50
+ // proxy that forgot to forward the header, and a CLI's name identifies it.
51
+ expect(deviceLabel("node")).toBeNull();
52
+ expect(deviceLabel("curl/8.7.1")).toBeNull();
53
+ expect(deviceLabel("")).toBeNull();
54
+ expect(deviceLabel(null)).toBeNull();
55
+ expect(deviceLabel(undefined)).toBeNull();
56
+ });
57
+
58
+ it("reports whichever half it can read", () => {
59
+ expect(deviceLabel("Mozilla/5.0 (Windows NT 10.0; Win64; x64)")).toBe("Windows");
60
+ expect(deviceLabel("Firefox/142.0")).toBe("Firefox");
61
+ });
62
+ });
@@ -0,0 +1,50 @@
1
+ /**
2
+ * A readable device name for a session's user agent.
3
+ *
4
+ * The sessions tables label this column "device", and a raw user agent does not
5
+ * answer that question — truncated to fit a cell, every modern browser starts with
6
+ * the same "Mozilla/5.0 (…" and the part that differs is off the end. This reduces
7
+ * it to the two things someone scanning their own sessions is looking for: which
8
+ * browser, on which machine.
9
+ *
10
+ * Returns null when the string is not a browser at all — a CLI, a script, or the
11
+ * proxy's own default. The caller shows the raw value then, because for those the
12
+ * exact string is the only useful information.
13
+ */
14
+
15
+ /** Order matters: every one of these also contains an earlier engine's token. */
16
+ const BROWSERS: Array<[RegExp, string]> = [
17
+ [/\bEdg(?:e|A|iOS)?\//, "Edge"],
18
+ [/\bOPR\/|\bOpera\//, "Opera"],
19
+ [/\bSamsungBrowser\//, "Samsung Internet"],
20
+ [/\bFirefox\/|\bFxiOS\//, "Firefox"],
21
+ [/\bCriOS\//, "Chrome"],
22
+ [/\bChrome\/|\bChromium\//, "Chrome"],
23
+ [/\bSafari\//, "Safari"],
24
+ ];
25
+
26
+ /** iPadOS reports as Macintosh, so the iPad check has to come before macOS. */
27
+ const PLATFORMS: Array<[RegExp, string]> = [
28
+ [/\biPhone\b/, "iPhone"],
29
+ [/\biPad\b/, "iPad"],
30
+ [/\bAndroid\b/, "Android"],
31
+ [/\bCrOS\b/, "ChromeOS"],
32
+ [/\bWindows NT\b/, "Windows"],
33
+ [/\bMacintosh\b|\bMac OS X\b/, "macOS"],
34
+ [/\bLinux\b/, "Linux"],
35
+ ];
36
+
37
+ function match(candidates: Array<[RegExp, string]>, value: string): string | null {
38
+ for (const [pattern, label] of candidates) {
39
+ if (pattern.test(value)) return label;
40
+ }
41
+ return null;
42
+ }
43
+
44
+ export function deviceLabel(userAgent: string | null | undefined): string | null {
45
+ if (!userAgent) return null;
46
+ const browser = match(BROWSERS, userAgent);
47
+ const platform = match(PLATFORMS, userAgent);
48
+ if (browser && platform) return `${browser} · ${platform}`;
49
+ return browser ?? platform;
50
+ }
@@ -9,6 +9,7 @@
9
9
  import { toast } from "svelte-sonner";
10
10
  import { api } from "$lib/api";
11
11
  import { getI18n, fmt, formatDateTime } from "$lib/i18n";
12
+ import { deviceLabel } from "$lib/device-label";
12
13
 
13
14
  const i18n = getI18n();
14
15
 
@@ -43,7 +44,7 @@
43
44
 
44
45
  const columns = $derived<DataTableColumn<Row>[]>([
45
46
  { key: "user", label: i18n.t.adminSessions.user, sortable: true, value: (r) => r.userName },
46
- { key: "device", label: i18n.t.adminSessions.device, sortable: true, value: (r) => r.userAgent },
47
+ { key: "device", label: i18n.t.adminSessions.device, sortable: true, value: (r) => deviceLabel(r.userAgent) ?? r.userAgent ?? "" },
47
48
  { key: "ip", label: i18n.t.adminSessions.ip, sortable: true, value: (r) => r.ipAddress },
48
49
  { key: "createdAt", label: i18n.t.adminSessions.since, sortable: true },
49
50
  { key: "expiresAt", label: i18n.t.adminSessions.expires, sortable: true, value: (r) => (r.expiresAt ? new Date(r.expiresAt).getTime() : 0) },
@@ -134,7 +135,7 @@
134
135
  </div>
135
136
  <div class="text-muted-foreground text-xs">{r.userEmail}</div>
136
137
  </Table.Cell>
137
- <Table.Cell class="max-w-xs truncate">{r.userAgent ?? i18n.t.adminSessions.unknown}</Table.Cell>
138
+ <Table.Cell class="max-w-xs truncate" title={r.userAgent ?? ""}>{deviceLabel(r.userAgent) ?? r.userAgent ?? i18n.t.adminSessions.unknown}</Table.Cell>
138
139
  <Table.Cell class="text-muted-foreground">{r.ipAddress ?? "—"}</Table.Cell>
139
140
  <Table.Cell class="text-muted-foreground">{formatDateTime(r.createdAt)}</Table.Cell>
140
141
  <Table.Cell class="text-muted-foreground">{r.expiresAt ? formatDateTime(r.expiresAt) : "—"}</Table.Cell>
@@ -17,6 +17,7 @@
17
17
  import { api } from "$lib/api";
18
18
  import { getI18n, fmt, formatDateTime } from "$lib/i18n";
19
19
  import { cn } from "$lib/utils";
20
+ import { deviceLabel } from "$lib/device-label";
20
21
  import type { PageData } from "./$types";
21
22
 
22
23
  let { data }: { data: PageData } = $props();
@@ -194,7 +195,7 @@
194
195
  let mSessionsPage = $state(1);
195
196
  let mSessionsSort = $state<SortState | null>(null);
196
197
  const mSessionsColumns: DataTableColumn<Session>[] = [
197
- { key: "userAgent", label: i18n.t.adminSessions.device, sortable: true },
198
+ { key: "userAgent", label: i18n.t.adminSessions.device, sortable: true, value: (s) => deviceLabel(s.userAgent) ?? s.userAgent ?? "" },
198
199
  { key: "ipAddress", label: i18n.t.adminSessions.ip, sortable: true },
199
200
  { key: "createdAt", label: i18n.t.adminSessions.since, sortable: true, value: (s) => new Date(s.createdAt).getTime() },
200
201
  { key: "actions", label: "", class: "w-10" },
@@ -557,7 +558,7 @@
557
558
  empty={i18n.t.adminSessions.empty}
558
559
  >
559
560
  {#snippet row(s)}
560
- <Table.Cell class="max-w-40 truncate">{s.userAgent ?? i18n.t.adminSessions.unknown}</Table.Cell>
561
+ <Table.Cell class="max-w-40 truncate" title={s.userAgent ?? ""}>{deviceLabel(s.userAgent) ?? s.userAgent ?? i18n.t.adminSessions.unknown}</Table.Cell>
561
562
  <Table.Cell class="text-muted-foreground">{s.ipAddress ?? "—"}</Table.Cell>
562
563
  <Table.Cell class="text-muted-foreground">{formatDateTime(s.createdAt)}</Table.Cell>
563
564
  <Table.Cell><Button variant="ghost" size="sm" disabled={busy} onclick={() => revokeSession(s.token)}>{i18n.t.users.revokeSession}</Button></Table.Cell>
@@ -1,3 +1,4 @@
1
+ # Example only. See infra/k3s/README.md; `podo deploy` renders its own.
1
2
  # BullMQ worker — runs the same API image with the worker entrypoint.
2
3
  # It has no HTTP server, so there is no Service or Ingress.
3
4
  apiVersion: apps/v1
@@ -15,7 +16,7 @@ spec:
15
16
  spec:
16
17
  containers:
17
18
  - name: worker
18
- image: ghcr.io/example/podokit-api:latest
19
+ image: ghcr.io/example/podokit-api:v0.0.0
19
20
  command: ["node", "dist/main-worker"]
20
21
  envFrom:
21
22
  - configMapRef: { name: app-config }
@@ -70,7 +70,8 @@ Useful commands:
70
70
  - `podo remove <module>` — un-apply a module (inverse of add; refuses if another module needs it, keeps files you edited).
71
71
  - `podo status` / `podo diff` — see your local edits vs. what PodoKit generated.
72
72
  - `podo update` — pull in template/module improvements (3-way merges your edits; never touches owned files).
73
- - `podo deploy` — plan, apply, verify, or roll back an immutable Kubernetes release. Use `.agents/skills/podokit-deploy`.
73
+ - `podo deploy` — plan, apply, verify, or roll back an immutable release on Kubernetes or one Docker host. Tag `vX.Y.Z` to build and push the images (`.github/workflows/release.yml`; check its runner — GitHub-hosted minutes are free for public repositories only). Use `.agents/skills/podokit-deploy`.
74
+ - `podo deploy sync` — copy local build output into a running Compose deployment and restart it, for iterating without a release. Not a release: no image is published and the next `apply` discards it. Use `.agents/skills/podokit-deploy-fast`.
74
75
 
75
76
  ## Commit conventions
76
77
 
@@ -1,6 +1,24 @@
1
1
  # Development image for the PodoKit monorepo (web + api).
2
2
  # Dependencies and source are baked in; `docker compose watch` syncs your
3
3
  # edits into the running container so the native file watchers fire.
4
+
5
+ # Every workspace manifest, collected rather than listed.
6
+ #
7
+ # Listing them by hand only works for the workspaces this template starts with. A
8
+ # project that adds `packages/*` then has workspace manifests that never reach the
9
+ # image, so npm does not know those names are local and goes looking for them on the
10
+ # public registry — `npm error 404 '@scope/thing' is not in this registry`, from an
11
+ # install that succeeds on the developer's own machine. The production images
12
+ # collect manifests for exactly this reason; this one was the exception until a
13
+ # generated project with shared packages could not start its dev stack at all.
14
+ FROM node:22-bookworm-slim AS manifests
15
+ WORKDIR /src
16
+ COPY . .
17
+ RUN mkdir -p /app \
18
+ && cp package.json /app/ \
19
+ && find . -name package.json -not -path "*/node_modules/*" \
20
+ -exec sh -c 'mkdir -p "/app/$(dirname "$1")" && cp "$1" "/app/$1"' _ {} \;
21
+
4
22
  FROM node:22-bookworm-slim
5
23
 
6
24
  # git for the toolchain (editors, AI agents); build tools for native modules.
@@ -14,9 +32,7 @@ WORKDIR /app
14
32
  # NB: the host package-lock.json is intentionally NOT copied — a lockfile
15
33
  # generated on macOS/Windows prunes platform-specific optional deps and can drop
16
34
  # packages (e.g. @swc/cli) on linux. Resolving fresh in-container avoids that.
17
- COPY package.json ./
18
- COPY apps/api/package.json ./apps/api/package.json
19
- COPY apps/web/package.json ./apps/web/package.json
35
+ COPY --from=manifests /app ./
20
36
  ARG PODOKIT_NPM_REGISTRY=""
21
37
  RUN if [ -n "$PODOKIT_NPM_REGISTRY" ]; then npm config set registry "$PODOKIT_NPM_REGISTRY"; fi \
22
38
  && npm install
@@ -24,4 +40,19 @@ RUN if [ -n "$PODOKIT_NPM_REGISTRY" ]; then npm config set registry "$PODOKIT_NP
24
40
  # Baked source (kept in sync during development by `docker compose watch`).
25
41
  COPY . .
26
42
 
43
+ # Workspace packages compile before the apps that import them. Apps import them by
44
+ # their published entry point (`dist/index.js`), so without this the API starts and
45
+ # dies on MODULE_NOT_FOUND for a package whose symlink is right there — the link
46
+ # resolves, the built file behind it does not exist. `--if-present` keeps a project
47
+ # with no packages, or one whose packages need no build, working unchanged.
48
+ #
49
+ # `docker compose watch` syncs app source only, so a change under packages/* needs
50
+ # this image rebuilt rather than a container restart.
51
+ RUN if [ -d packages ]; then \
52
+ for manifest in packages/*/package.json; do \
53
+ [ -e "$manifest" ] || continue; \
54
+ npm run build --if-present --workspace "$(dirname "$manifest")"; \
55
+ done; \
56
+ fi
57
+
27
58
  EXPOSE 5001 5002
@@ -1,27 +1,88 @@
1
1
  # syntax=docker/dockerfile:1
2
+
3
+ # The npm that reads package-lock.json must be the major that wrote it. node:22
4
+ # bundles npm 10, and a lockfile written by npm 11 records platform-specific
5
+ # packages as extraneous with no `optional` flag; npm 10 rejects those with
6
+ # EBADPLATFORM instead of skipping them, so `npm ci` fails inside the image while
7
+ # `npm install` on the developer's machine is fine. Keep this in step with the npm
8
+ # the project is developed with.
9
+ ARG NPM_VERSION=11.17.0
10
+
11
+ # Every workspace manifest, and nothing else.
12
+ #
13
+ # Listing them by hand only works for the workspaces this template starts with: npm
14
+ # does NOT fail when a workspace's package.json is absent, it silently installs a
15
+ # tree without that package's dependencies. A project that adds packages/* then
16
+ # builds against different versions than it resolves locally, and only inside the
17
+ # image. Collecting them keeps `npm ci` correct whatever the workspace layout is,
18
+ # and the stages below still cache on the manifests' content.
19
+ FROM node:22-alpine AS manifests
20
+ WORKDIR /src
21
+ COPY . .
22
+ RUN mkdir -p /app \
23
+ && cp package.json package-lock.json /app/ \
24
+ && find . -name package.json -not -path "*/node_modules/*" \
25
+ -exec sh -c 'mkdir -p "/app/$(dirname "$1")" && cp "$1" "/app/$1"' _ {} \;
26
+
2
27
  FROM node:22-alpine AS deps
3
28
  WORKDIR /app
4
- COPY package.json package-lock.json ./
5
- COPY apps/api/package.json ./apps/api/package.json
6
- COPY apps/web/package.json ./apps/web/package.json
7
- COPY tests/package.json ./tests/package.json
29
+ COPY --from=manifests /app ./
30
+ ARG NPM_VERSION
31
+ RUN npm i -g "npm@${NPM_VERSION}"
8
32
  RUN npm ci --no-audit --no-fund
9
33
 
34
+ # Production dependencies only, resolved from the same lockfile. The runtime stage
35
+ # copies node_modules from here and never from deps or build, so the shipped image
36
+ # does not carry the TypeScript compiler, Vite, or the test tooling.
37
+ FROM node:22-alpine AS prod-deps
38
+ WORKDIR /app
39
+ COPY --from=manifests /app ./
40
+ ARG NPM_VERSION
41
+ RUN npm i -g "npm@${NPM_VERSION}"
42
+ # The runtime tree for this image only.
43
+ #
44
+ # Scoping to the one workspace matters: --omit=dev at the root still installs the
45
+ # union of every workspace's production dependencies, so the API image carried Vite
46
+ # and the web image carried Nest. Measured on a generated project, this is 198
47
+ # packages instead of 283. TypeScript stays either way -- typeorm depends on ts-node,
48
+ # which depends on it, so it is a production dependency and not bloat.
49
+ RUN npm ci --omit=dev --no-audit --no-fund --workspace=apps/api --include-workspace-root
50
+
10
51
  FROM node:22-alpine AS build
11
52
  WORKDIR /app
12
- COPY --from=deps /app/node_modules ./node_modules
53
+ # Everything npm ci produced, not just the root node_modules: npm nests any
54
+ # dependency it cannot hoist, and copying only the root directory leaves a workspace
55
+ # package compiling against the wrong version of it.
56
+ COPY --from=deps /app ./
13
57
  COPY . .
58
+ # Local workspace packages compile before the app that imports them. Apps import
59
+ # them by their published entry points and .dockerignore excludes dist, so nothing
60
+ # here has compiled output until it is compiled here.
61
+ RUN if [ -d packages ]; then \
62
+ for manifest in packages/*/package.json; do \
63
+ [ -e "$manifest" ] || continue; \
64
+ npm run build --if-present --workspace "$(dirname "$manifest")"; \
65
+ done; \
66
+ fi
14
67
  RUN npm run build --workspace=apps/api
68
+ # The runtime needs the real workspace package directories, not just the symlinks
69
+ # npm leaves in node_modules pointing at them. Without this the image boots and
70
+ # dies on "Cannot find module <package>" -- a dangling link, at runtime only.
71
+ # mkdir keeps the COPY valid for a project that has no packages/ at all.
72
+ RUN mkdir -p /app/packages
15
73
 
16
74
  FROM node:22-alpine AS runtime
17
75
  WORKDIR /app
18
76
  ENV NODE_ENV=production
19
77
  RUN addgroup -S -g 101 app && adduser -S -D -H -u 100 -G app app
20
- COPY --from=build /app/node_modules ./node_modules
21
- COPY --from=deps /app/apps/api ./apps/api
78
+ COPY --from=prod-deps /app/node_modules ./node_modules
79
+ COPY --from=manifests /app/apps/api ./apps/api
80
+ COPY --from=build /app/packages ./packages
22
81
  COPY --from=build /app/apps/api/dist ./apps/api/dist
23
82
  COPY --from=build /app/apps/api/scripts ./apps/api/scripts
24
83
  WORKDIR /app/apps/api
25
84
  USER 100:101
26
85
  EXPOSE 3000
86
+ HEALTHCHECK --interval=10s --timeout=5s --start-period=30s --retries=6 \
87
+ CMD node -e "fetch('http://127.0.0.1:3000/health/ready').then((r) => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"
27
88
  CMD ["node", "dist/main"]
@@ -1,26 +1,78 @@
1
1
  # syntax=docker/dockerfile:1
2
+
3
+ # The npm that reads package-lock.json must be the major that wrote it. node:22
4
+ # bundles npm 10, and a lockfile written by npm 11 records platform-specific
5
+ # packages as extraneous with no `optional` flag; npm 10 rejects those with
6
+ # EBADPLATFORM instead of skipping them, so `npm ci` fails inside the image while
7
+ # `npm install` on the developer's machine is fine. Keep this in step with the npm
8
+ # the project is developed with.
9
+ ARG NPM_VERSION=11.17.0
10
+
11
+ # See apps/api/Dockerfile for why the workspace manifests are collected rather than
12
+ # listed: an absent workspace package.json does not fail `npm ci`, it silently
13
+ # changes the resolved dependency tree inside the image.
14
+ FROM node:22-alpine AS manifests
15
+ WORKDIR /src
16
+ COPY . .
17
+ RUN mkdir -p /app \
18
+ && cp package.json package-lock.json /app/ \
19
+ && find . -name package.json -not -path "*/node_modules/*" \
20
+ -exec sh -c 'mkdir -p "/app/$(dirname "$1")" && cp "$1" "/app/$1"' _ {} \;
21
+
2
22
  FROM node:22-alpine AS deps
3
23
  WORKDIR /app
4
- COPY package.json package-lock.json ./
5
- COPY apps/api/package.json ./apps/api/package.json
6
- COPY apps/web/package.json ./apps/web/package.json
7
- COPY tests/package.json ./tests/package.json
24
+ COPY --from=manifests /app ./
25
+ ARG NPM_VERSION
26
+ RUN npm i -g "npm@${NPM_VERSION}"
8
27
  RUN npm ci --no-audit --no-fund
9
28
 
29
+ FROM node:22-alpine AS prod-deps
30
+ WORKDIR /app
31
+ COPY --from=manifests /app ./
32
+ ARG NPM_VERSION
33
+ RUN npm i -g "npm@${NPM_VERSION}"
34
+ # The runtime tree for this image only.
35
+ #
36
+ # Scoping to the one workspace matters: --omit=dev at the root still installs the
37
+ # union of every workspace's production dependencies, so the API image carried Vite
38
+ # and the web image carried Nest. Measured on a generated project, this is 198
39
+ # packages instead of 283. TypeScript stays either way -- typeorm depends on ts-node,
40
+ # which depends on it, so it is a production dependency and not bloat.
41
+ RUN npm ci --omit=dev --no-audit --no-fund --workspace=apps/web --include-workspace-root
42
+
10
43
  FROM node:22-alpine AS build
11
44
  WORKDIR /app
12
- COPY --from=deps /app/node_modules ./node_modules
45
+ COPY --from=deps /app ./
13
46
  COPY . .
47
+ RUN if [ -d packages ]; then \
48
+ for manifest in packages/*/package.json; do \
49
+ [ -e "$manifest" ] || continue; \
50
+ npm run build --if-present --workspace "$(dirname "$manifest")"; \
51
+ done; \
52
+ fi
14
53
  RUN npm run build --workspace=apps/web
54
+ # The runtime needs the real workspace package directories, not just the symlinks
55
+ # npm leaves in node_modules pointing at them. Without this the image boots and
56
+ # dies on "Cannot find module <package>" -- a dangling link, at runtime only.
57
+ # mkdir keeps the COPY valid for a project that has no packages/ at all.
58
+ RUN mkdir -p /app/packages
15
59
 
16
60
  FROM node:22-alpine AS runtime
17
61
  WORKDIR /app
18
62
  ENV NODE_ENV=production
19
63
  RUN addgroup -S -g 101 app && adduser -S -D -H -u 100 -G app app
20
- COPY --from=build /app/node_modules ./node_modules
21
- COPY --from=deps /app/apps/web ./apps/web
64
+ COPY --from=prod-deps /app/node_modules ./node_modules
65
+ COPY --from=manifests /app/apps/web ./apps/web
66
+ COPY --from=build /app/packages ./packages
22
67
  COPY --from=build /app/apps/web/build ./apps/web/build
68
+ # The entry and the modules it imports run OUTSIDE the bundle, so they ship as
69
+ # source. Without them the image falls back to an entry that cannot answer a
70
+ # WebSocket upgrade.
71
+ COPY --from=build /app/apps/web/server.js ./apps/web/server.js
72
+ COPY --from=build /app/apps/web/src/lib/server ./apps/web/src/lib/server
23
73
  WORKDIR /app/apps/web
24
74
  USER 100:101
25
75
  EXPOSE 3000
26
- CMD ["node", "build"]
76
+ HEALTHCHECK --interval=10s --timeout=5s --start-period=30s --retries=6 \
77
+ CMD node -e "fetch('http://127.0.0.1:3000/').then((r) => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"
78
+ CMD ["node", "server.js"]
@@ -0,0 +1,31 @@
1
+ /**
2
+ * The production entry point: adapter-node's server plus WebSocket upgrades.
3
+ *
4
+ * `node build` — adapter-node's own entry — serves HTTP perfectly well and drops
5
+ * every WebSocket upgrade into SvelteKit, which cannot answer one. This entry keeps
6
+ * adapter-node in charge of listening, graceful shutdown, and the PORT/HOST/
7
+ * IDLE_TIMEOUT contract, and adds the one thing it cannot do.
8
+ *
9
+ * Nothing is proxied unless `WS_PROXY_PATHS` names a path, so a deployment that has
10
+ * no WebSocket behaves exactly as `node build` did.
11
+ *
12
+ * ⚠ Not type-checked: it imports `./build/`, which only exists after `npm run build`,
13
+ * and lint must not depend on a build. Everything with a decision in it lives in
14
+ * `src/lib/server/ws-upgrade.js`, which is unit-tested; this is wiring.
15
+ */
16
+ import { server } from "./build/index.js";
17
+ import { createUpgradeProxy, relayPaths } from "./src/lib/server/ws-upgrade.js";
18
+
19
+ const allowed = relayPaths();
20
+ if (allowed.size > 0) {
21
+ // polka exposes the http.Server it was handed. If a future adapter stops doing so,
22
+ // fail at boot: serving silently without upgrades is the bug this entry exists for.
23
+ const httpServer = server.server;
24
+ if (!httpServer || typeof httpServer.on !== "function") {
25
+ throw new Error(
26
+ "adapter-node did not expose its HTTP server; the WebSocket upgrade proxy cannot be mounted",
27
+ );
28
+ }
29
+ httpServer.on("upgrade", createUpgradeProxy({ allowed }));
30
+ console.log(`Mounted WebSocket upgrade proxy for ${[...allowed].join(", ")}`);
31
+ }
@@ -55,4 +55,25 @@ describe("backend proxy client address handling", () => {
55
55
  expect(response.status).toBe(204);
56
56
  expect(upstreamHeaders?.get("x-forwarded-for")).toBe("203.0.113.9");
57
57
  });
58
+
59
+ // Not forwarding this does not leave the header unset: fetch substitutes its own
60
+ // default, so the API records "node" for every session and the account page shows
61
+ // that as the device for all of them.
62
+ it("forwards the browser's user agent so sessions record the real device", async () => {
63
+ let upstreamHeaders: Headers | undefined;
64
+ vi.stubGlobal("fetch", async (_input: RequestInfo | URL, init?: RequestInit) => {
65
+ upstreamHeaders = new Headers(init?.headers);
66
+ return new Response(null, { status: 204 });
67
+ });
68
+
69
+ const agent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Chrome/141.0.0.0 Safari/537.36";
70
+ const request = new Request("http://app.localhost/api/auth/sign-in/email", {
71
+ method: "POST",
72
+ headers: { "user-agent": agent },
73
+ });
74
+ const response = await proxyRequest(request, "http://api:5002/auth/sign-in/email");
75
+
76
+ expect(response.status).toBe(204);
77
+ expect(upstreamHeaders?.get("user-agent")).toBe(agent);
78
+ });
58
79
  });
@@ -1,7 +1,21 @@
1
1
  // Server-side proxy boundary. The browser never talks to the API directly:
2
2
  // requests go through SvelteKit server routes, which forward an allowlist of
3
3
  // headers to BACKEND_INTERNAL_URL and relay the response (including Set-Cookie).
4
- const FORWARDED_HEADERS = ["authorization", "cookie", "content-type", "accept", "origin", "referer"];
4
+ //
5
+ // `user-agent` is on the list for the same reason the client IP is forwarded
6
+ // below: better-auth records it on the session and the account page shows it as
7
+ // the device. Leaving it off does not produce an empty column -- fetch supplies
8
+ // its own default, so every session in the database reads "node", and the list
9
+ // of "your devices" becomes a list of identical rows that identify nothing.
10
+ const FORWARDED_HEADERS = [
11
+ "authorization",
12
+ "cookie",
13
+ "content-type",
14
+ "accept",
15
+ "origin",
16
+ "referer",
17
+ "user-agent",
18
+ ];
5
19
  const RELAYED_RESPONSE_HEADERS = ["content-type", "location", "cache-control"];
6
20
 
7
21
  export function backendBaseUrl(): string {