@podosoft/podokit 0.16.3 → 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 (84) 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 +4 -7
  16. package/dist/deploy-profile.js +86 -274
  17. package/dist/deploy-render.js +10 -1
  18. package/dist/deploy-schema.d.ts +56 -0
  19. package/dist/deploy-schema.js +261 -0
  20. package/dist/index.d.ts +7 -0
  21. package/dist/index.js +178 -6
  22. package/dist/lib.d.ts +9 -0
  23. package/dist/lib.js +30 -1
  24. package/dist/templates/fullstack-nest-svelte/AGENTS.md +2 -1
  25. package/dist/templates/fullstack-nest-svelte/Dockerfile.dev +34 -3
  26. package/dist/templates/fullstack-nest-svelte/apps/api/Dockerfile +68 -7
  27. package/dist/templates/fullstack-nest-svelte/apps/web/Dockerfile +60 -8
  28. package/dist/templates/fullstack-nest-svelte/apps/web/package.json +2 -1
  29. package/dist/templates/fullstack-nest-svelte/apps/web/server.js +31 -0
  30. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/server/api.ts +2 -2
  31. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/server/backend-proxy.test.ts +79 -0
  32. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/server/backend-proxy.ts +24 -1
  33. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/server/ws-upgrade.js +267 -0
  34. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/server/ws-upgrade.test.js +134 -0
  35. package/dist/templates/fullstack-nest-svelte/apps/web/src/routes/api/[...path]/+server.ts +6 -2
  36. package/dist/templates/fullstack-nest-svelte/apps/web/src/routes/api/auth/[...all]/+server.ts +6 -2
  37. package/dist/templates/fullstack-nest-svelte/apps/web/vite.config.ts +17 -7
  38. package/dist/templates/fullstack-nest-svelte/dot-agents/skills/podokit-deploy/SKILL.md +55 -10
  39. package/dist/templates/fullstack-nest-svelte/dot-agents/skills/podokit-deploy/references/profile.md +50 -1
  40. package/dist/templates/fullstack-nest-svelte/dot-agents/skills/podokit-deploy/references/release.md +64 -0
  41. package/dist/templates/fullstack-nest-svelte/dot-agents/skills/podokit-deploy-fast/SKILL.md +92 -0
  42. package/dist/templates/fullstack-nest-svelte/dot-claude/skills/podokit-deploy/SKILL.md +1 -1
  43. package/dist/templates/fullstack-nest-svelte/dot-claude/skills/podokit-deploy-fast/SKILL.md +8 -0
  44. package/dist/templates/fullstack-nest-svelte/dot-dockerignore +7 -0
  45. package/dist/templates/fullstack-nest-svelte/dot-env.example +7 -0
  46. package/dist/templates/fullstack-nest-svelte/dot-github/workflows/release.yml +142 -0
  47. package/dist/templates/fullstack-nest-svelte/infra/k3s/README.md +18 -0
  48. package/dist/templates/fullstack-nest-svelte/infra/k3s/api-deployment.yaml +6 -1
  49. package/dist/templates/fullstack-nest-svelte/infra/k3s/ingress.yaml +8 -0
  50. package/dist/templates/fullstack-nest-svelte/infra/k3s/web-deployment.yaml +11 -1
  51. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-page.svelte +3 -2
  52. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/device-label.test.ts +62 -0
  53. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/device-label.ts +50 -0
  54. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/sessions/+page.svelte +3 -2
  55. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/users/+page.svelte +3 -2
  56. package/dist/templates/modules/bullmq/files/infra/k3s/worker-deployment.yaml +2 -1
  57. package/dist/templates/todo/AGENTS.md +2 -1
  58. package/dist/templates/todo/Dockerfile.dev +34 -3
  59. package/dist/templates/todo/apps/api/Dockerfile +68 -7
  60. package/dist/templates/todo/apps/web/Dockerfile +60 -8
  61. package/dist/templates/todo/apps/web/package.json +2 -1
  62. package/dist/templates/todo/apps/web/server.js +31 -0
  63. package/dist/templates/todo/apps/web/src/lib/server/api.ts +2 -2
  64. package/dist/templates/todo/apps/web/src/lib/server/backend-proxy.test.ts +79 -0
  65. package/dist/templates/todo/apps/web/src/lib/server/backend-proxy.ts +24 -1
  66. package/dist/templates/todo/apps/web/src/lib/server/ws-upgrade.js +267 -0
  67. package/dist/templates/todo/apps/web/src/lib/server/ws-upgrade.test.js +134 -0
  68. package/dist/templates/todo/apps/web/src/routes/api/[...path]/+server.ts +6 -2
  69. package/dist/templates/todo/apps/web/src/routes/api/auth/[...all]/+server.ts +6 -2
  70. package/dist/templates/todo/apps/web/vite.config.ts +17 -7
  71. package/dist/templates/todo/dot-agents/skills/podokit-deploy/SKILL.md +55 -10
  72. package/dist/templates/todo/dot-agents/skills/podokit-deploy/references/profile.md +47 -0
  73. package/dist/templates/todo/dot-agents/skills/podokit-deploy/references/release.md +64 -0
  74. package/dist/templates/todo/dot-agents/skills/podokit-deploy-fast/SKILL.md +92 -0
  75. package/dist/templates/todo/dot-claude/skills/podokit-deploy/SKILL.md +1 -1
  76. package/dist/templates/todo/dot-claude/skills/podokit-deploy-fast/SKILL.md +8 -0
  77. package/dist/templates/todo/dot-dockerignore +7 -0
  78. package/dist/templates/todo/dot-env.example +7 -0
  79. package/dist/templates/todo/dot-github/workflows/release.yml +142 -0
  80. package/dist/templates/todo/infra/k3s/README.md +18 -0
  81. package/dist/templates/todo/infra/k3s/api-deployment.yaml +6 -1
  82. package/dist/templates/todo/infra/k3s/ingress.yaml +8 -0
  83. package/dist/templates/todo/infra/k3s/web-deployment.yaml +11 -1
  84. package/package.json +1 -1
@@ -0,0 +1,92 @@
1
+ ---
2
+ name: podokit-deploy-fast
3
+ description: Push locally built artifacts into a running Docker Compose deployment and restart it, without building or publishing an image. Use when iterating on code against a deployed environment and a full release round trip is too slow. Not for releases, migrations, dependency changes, or anything going to real users.
4
+ ---
5
+
6
+ # Sync code into a running deployment
7
+
8
+ `podo deploy sync` copies this project's build output into the containers that are
9
+ already running and restarts them. `docker-compose` driver only — read the profile's
10
+ `driver` first and stop if it is `kubernetes-helm`, where the equivalent would have
11
+ to reach every node and would stop being a shortcut.
12
+
13
+ ```bash
14
+ npx @podosoft/podokit deploy sync --profile <name> --build
15
+ ```
16
+
17
+ ## Say what this does before doing it
18
+
19
+ It is not a release, and the user has to understand three consequences before you run
20
+ it:
21
+
22
+ 1. **The deployment will run code its image tag does not describe.** A marker file
23
+ records the sync and `podo deploy status` reports it under `syncDrift`.
24
+ 2. **Every connection the restarted containers hold is dropped** — WebSocket sessions,
25
+ in-flight requests, anything attached.
26
+ 3. **It disappears on the next release.** Container writable layers do not survive
27
+ recreation, so `podo deploy apply` restores the image with no cleanup step. That is
28
+ the property that makes this safe; do not build anything that depends on the copy
29
+ persisting.
30
+
31
+ ## When NOT to use it
32
+
33
+ Use a release instead — build the images and `podo deploy apply` — when any of these
34
+ is true. The first three are refusals the command makes itself; the last two it
35
+ cannot detect, which is why they are your job.
36
+
37
+ | Situation | Why |
38
+ | --- | --- |
39
+ | runtime dependencies changed | the container's `node_modules` was installed `--omit=dev` from the old manifest, so the new code imports something that is not there |
40
+ | a release holds the deployment lock | an apply is in progress |
41
+ | nothing is running for that project | there is no container to copy into |
42
+ | **the change needs a migration** | sync never migrates, and a migration belongs with the release whose code needs it |
43
+ | **the change is going to real users** | nothing is pushed, so nothing is reproducible from it |
44
+
45
+ ## Procedure
46
+
47
+ 1. Read the profile's `driver`. Stop unless it is `docker-compose`.
48
+ 2. Tell the user what will restart and that connections drop. Get agreement if anyone
49
+ is using the deployment.
50
+ 3. Run with `--build` unless the project is already built. `--build` compiles in the
51
+ order the images do — every `packages/*` before the apps — because a root build
52
+ script runs workspaces in manifest order and an app listed first would compile
53
+ against a workspace package's previous output.
54
+ 4. Report what was copied, which containers restarted, and that the deployment is now
55
+ in sync drift.
56
+ 5. Verify against the running deployment, not against the build: request a path the
57
+ change affects. If part of the build output is excluded (below), check one of
58
+ those paths too — that is the failure this exclusion exists to prevent, and it
59
+ does not make the page look broken.
60
+
61
+ ## Restoring the image
62
+
63
+ ```bash
64
+ npx @podosoft/podokit deploy sync --profile <name> --revert
65
+ ```
66
+
67
+ This recreates the containers from the Compose project **already on the target**,
68
+ which is the description of what was actually applied — not a fresh render, which
69
+ would reproduce the profile as it is now.
70
+
71
+ A release does the same thing as a side effect, so `--revert` is for abandoning a
72
+ sync, not a required cleanup step.
73
+
74
+ ## Excluding part of the build output
75
+
76
+ Some of a build output can come from a toolchain the developer's machine does not
77
+ have. Overwriting that part replaces real artifacts with an index of artifacts that
78
+ are no longer there, and the application keeps serving pages while the missing files
79
+ 404 — so nothing looks broken. Those paths belong in the profile:
80
+
81
+ ```json
82
+ { "sync": { "exclude": ["apps/web/build/static-assets/generated"] } }
83
+ ```
84
+
85
+ If you find yourself about to sync over something this machine cannot rebuild, add it
86
+ here rather than remembering not to. `--clean` refuses while an exclude sits beneath
87
+ a synced path, for the same reason.
88
+
89
+ ## Boundary
90
+
91
+ Never use this to ship. Never run migrations alongside it. Never create or rotate
92
+ credentials, and never print a secret value.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: podokit-deploy
3
- description: Plan, inspect, apply, verify, and roll back PodoKit Kubernetes releases with an explicit profile and confirmation hash. Use for Kubernetes deployment and rollback work in a generated PodoKit project.
3
+ description: Plan, inspect, apply, verify, and roll back PodoKit releases with an explicit profile and confirmation hash, on Kubernetes or Docker Compose. Use for release, rollout, and rollback work in a generated PodoKit project.
4
4
  ---
5
5
 
6
6
  # PodoKit deployment
@@ -0,0 +1,8 @@
1
+ ---
2
+ name: podokit-deploy-fast
3
+ description: Push locally built artifacts into a running Docker Compose deployment and restart it, without building or publishing an image. Use when iterating on code against a deployed environment and a full release round trip is too slow. Not for releases, migrations, dependency changes, or anything going to real users.
4
+ ---
5
+
6
+ # PodoKit fast deployment sync
7
+
8
+ Read and follow the canonical skill at `../../../.agents/skills/podokit-deploy-fast/SKILL.md`.
@@ -6,3 +6,10 @@ dist
6
6
  **/.svelte-kit
7
7
  .git
8
8
  *.log
9
+
10
+ # Real environment files never enter a build context. The build stages copy the
11
+ # whole repository, so an .env left here is baked into an image layer even when the
12
+ # runtime stage does not copy it -- and getting-started tells you to create one.
13
+ .env
14
+ .env.*
15
+ !.env.example
@@ -12,6 +12,13 @@ POSTGRES_DB=podokit
12
12
 
13
13
  # Web -> API boundary (server-side only; not exposed to the browser)
14
14
  BACKEND_INTERNAL_URL=http://localhost:5002
15
+
16
+ # Exact paths the web server may proxy as WebSocket upgrades to the API, comma
17
+ # separated. Empty means none: a SvelteKit route cannot answer an upgrade, so a
18
+ # WebSocket gateway on the API is unreachable until it is named here. Each path is
19
+ # matched whole and must be authorised by the API at handshake time -- a relay that
20
+ # forwards anything is an open tunnel to every private route.
21
+ WS_PROXY_PATHS=
15
22
  CORS_ORIGIN=http://localhost:5001
16
23
  # adapter-node request limit; leave room for a 2 MiB multipart upload
17
24
  BODY_SIZE_LIMIT=3M
@@ -0,0 +1,142 @@
1
+ # Build and publish the release images for a tag.
2
+ #
3
+ # `podo deploy` consumes images; it does not build them. This is the other half:
4
+ # tagging a commit produces the two images that `podo deploy apply` then rolls out.
5
+ # It stops at the push, because rolling out requires confirming a plan hash and that
6
+ # approval belongs to a person.
7
+ #
8
+ # ── Which runner, and what it costs ──────────────────────────────────────────────
9
+ #
10
+ # GitHub-hosted runners are free for PUBLIC repositories only. In a PRIVATE
11
+ # repository every minute is metered against the account's included Actions minutes
12
+ # and billed beyond them -- and image builds are the most expensive job most
13
+ # projects run. A self-hosted runner consumes no Actions minutes at all, whatever
14
+ # the repository's visibility.
15
+ #
16
+ # So the runner is a variable rather than a constant:
17
+ #
18
+ # PODOKIT_RUNNER repository or organization variable, a JSON array of labels.
19
+ # Unset -> ["ubuntu-latest"], which is right for a public
20
+ # repository and costs money in a private one.
21
+ # Self-hosted example: ["self-hosted","Linux","X64"]
22
+ #
23
+ # Set it before the first tag if this repository is private. Nothing here warns you
24
+ # when you have not: the workflow succeeds either way, and the difference shows up
25
+ # on a bill.
26
+ #
27
+ # ── The other two variables ──────────────────────────────────────────────────────
28
+ #
29
+ # PODOKIT_DEPLOY_PROFILE which .podokit/deploy/<name>.json names the image
30
+ # repositories. Unset -> production.
31
+ # PODOKIT_IMAGE_PLATFORM the architecture the deployment target runs.
32
+ # Unset -> linux/amd64. Building for the wrong one
33
+ # produces "exec format error" at rollout, after the
34
+ # migration has already run.
35
+ #
36
+ # ── Secrets ──────────────────────────────────────────────────────────────────────
37
+ #
38
+ # REGISTRY_USERNAME / REGISTRY_PASSWORD credentials for the registry named by
39
+ # the profile. A robot or deploy account is enough, but note that some registries
40
+ # refuse to let a robot CREATE a repository -- push the first tag of a new
41
+ # repository with an account that can, or create it in the registry first.
42
+
43
+ name: release
44
+
45
+ on:
46
+ push:
47
+ tags:
48
+ - "v*.*.*"
49
+
50
+ concurrency:
51
+ group: release-${{ github.ref }}
52
+ cancel-in-progress: false
53
+
54
+ jobs:
55
+ images:
56
+ # One job builds both images rather than a matrix of two. A self-hosted pool is
57
+ # usually small, and fanning out here takes runners away from every other
58
+ # repository that shares it for no wall-clock gain on a multi-core machine.
59
+ runs-on: ${{ fromJSON(vars.PODOKIT_RUNNER || '["ubuntu-latest"]') }}
60
+ steps:
61
+ - uses: actions/checkout@v5
62
+
63
+ - uses: actions/setup-node@v6
64
+ with:
65
+ node-version: 22
66
+ cache: npm
67
+
68
+ - run: npm ci --no-audit --no-fund
69
+
70
+ # Verify before publishing. An image built from a commit that never passed is
71
+ # indistinguishable from one that did, once it is in the registry.
72
+ - run: npm run lint
73
+ - run: npm test
74
+
75
+ - name: Read the release target from the deployment profile
76
+ id: profile
77
+ run: |
78
+ node --input-type=module -e '
79
+ import { readFileSync } from "node:fs";
80
+ const name = process.env.PROFILE || "production";
81
+ const path = `.podokit/deploy/${name}.json`;
82
+ let profile;
83
+ try {
84
+ profile = JSON.parse(readFileSync(path, "utf8"));
85
+ } catch {
86
+ throw new Error(`No deployment profile at ${path}. Run podo deploy init first.`);
87
+ }
88
+ const { apiRepository, webRepository } = profile.release ?? {};
89
+ if (!apiRepository || !webRepository) {
90
+ throw new Error(`${path} does not declare release.apiRepository and release.webRepository.`);
91
+ }
92
+ // Everything before the first slash is a registry host only when it
93
+ // looks like one; otherwise the reference is a Docker Hub short name.
94
+ const first = apiRepository.split("/")[0];
95
+ const registry = /[.:]/.test(first) ? first : "";
96
+ const out = [
97
+ `api=${apiRepository}`,
98
+ `web=${webRepository}`,
99
+ `registry=${registry}`,
100
+ `tag=${process.env.GITHUB_REF_NAME}`,
101
+ ].join("\n");
102
+ process.stdout.write(out + "\n");
103
+ ' >> "$GITHUB_OUTPUT"
104
+ env:
105
+ PROFILE: ${{ vars.PODOKIT_DEPLOY_PROFILE }}
106
+
107
+ - uses: docker/setup-buildx-action@v3
108
+
109
+ - uses: docker/login-action@v3
110
+ with:
111
+ registry: ${{ steps.profile.outputs.registry }}
112
+ username: ${{ secrets.REGISTRY_USERNAME }}
113
+ password: ${{ secrets.REGISTRY_PASSWORD }}
114
+
115
+ - name: Build and push the API image
116
+ uses: docker/build-push-action@v6
117
+ with:
118
+ context: .
119
+ file: apps/api/Dockerfile
120
+ platforms: ${{ vars.PODOKIT_IMAGE_PLATFORM || 'linux/amd64' }}
121
+ push: true
122
+ tags: ${{ steps.profile.outputs.api }}:${{ steps.profile.outputs.tag }}
123
+ cache-from: type=gha,scope=api
124
+ cache-to: type=gha,scope=api,mode=max
125
+
126
+ - name: Build and push the web image
127
+ uses: docker/build-push-action@v6
128
+ with:
129
+ context: .
130
+ file: apps/web/Dockerfile
131
+ platforms: ${{ vars.PODOKIT_IMAGE_PLATFORM || 'linux/amd64' }}
132
+ push: true
133
+ tags: ${{ steps.profile.outputs.web }}:${{ steps.profile.outputs.tag }}
134
+ cache-from: type=gha,scope=web
135
+ cache-to: type=gha,scope=web,mode=max
136
+
137
+ - name: Report what to deploy
138
+ run: |
139
+ echo "Pushed ${{ steps.profile.outputs.api }}:${{ steps.profile.outputs.tag }}" >> "$GITHUB_STEP_SUMMARY"
140
+ echo "Pushed ${{ steps.profile.outputs.web }}:${{ steps.profile.outputs.tag }}" >> "$GITHUB_STEP_SUMMARY"
141
+ echo "" >> "$GITHUB_STEP_SUMMARY"
142
+ echo 'Roll out with `podo deploy plan --profile <name> --release ${{ steps.profile.outputs.tag }}`, then apply the printed hash.' >> "$GITHUB_STEP_SUMMARY"
@@ -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