@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,267 @@
1
+ /**
2
+ * WebSocket upgrades, proxied by the web server itself.
3
+ *
4
+ * WHY THIS EXISTS: a SvelteKit server route cannot serve a WebSocket upgrade.
5
+ * `+server.ts` handlers answer HTTP requests and never see an `upgrade` event, and
6
+ * the `/api/*` proxy is HTTP-only for the same reason — `fetch` cannot carry a 101.
7
+ * Meanwhile a deployment routes all public traffic to the web app, so an API
8
+ * WebSocket gateway has no path to reach at all. Development hides this, because
9
+ * Vite proxies upgrades; the feature then dies silently once deployed.
10
+ *
11
+ * ⚠ NOTHING IS FORWARDED BY DEFAULT. `WS_PROXY_PATHS` names the exact paths that may
12
+ * upgrade, comma-separated, and anything else is destroyed rather than proxied. A
13
+ * relay that forwards any path is an open tunnel to every private route on the API,
14
+ * reachable from the public origin without a session. Adding a path is a security
15
+ * decision, not a routing tweak — the API must authorise that socket at handshake
16
+ * time, from the session cookie or its own credential.
17
+ *
18
+ * This file is plain JavaScript on purpose: `server.js` imports `./build/`, which
19
+ * only exists after a build, so the entry point cannot be type-checked. Everything
20
+ * with a decision in it lives here, where it is unit-tested.
21
+ */
22
+ import http from "node:http";
23
+ import https from "node:https";
24
+
25
+ /** @typedef {Record<string, string | string[] | undefined>} Headers */
26
+ /** @typedef {{ kind: "proxy", path: string } | { kind: "reject", reason: string }} UpgradeDecision */
27
+
28
+ /**
29
+ * The upgrade paths this deployment allows, matched whole — never as a prefix, a
30
+ * wildcard, or a pattern.
31
+ *
32
+ * @returns {ReadonlySet<string>}
33
+ */
34
+ export function relayPaths() {
35
+ return new Set(
36
+ (process.env.WS_PROXY_PATHS ?? "")
37
+ .split(",")
38
+ .map((entry) => entry.trim())
39
+ .filter((entry) => entry.startsWith("/")),
40
+ );
41
+ }
42
+
43
+ /** Where upgrades are sent. Same target the HTTP proxy uses. */
44
+ export function backendBaseUrl() {
45
+ return process.env.BACKEND_INTERNAL_URL ?? "http://localhost:5002";
46
+ }
47
+
48
+ /**
49
+ * Normalize a client address for the forwarded header: map loopback to a clean IPv4
50
+ * value and unwrap IPv4-mapped addresses.
51
+ *
52
+ * @param {string | undefined} address
53
+ * @returns {string | undefined}
54
+ */
55
+ export function normalizeClientIp(address) {
56
+ if (!address) return undefined;
57
+ if (address === "::1" || address === "::") return "127.0.0.1";
58
+ if (address.startsWith("::ffff:")) return address.slice("::ffff:".length);
59
+ return address;
60
+ }
61
+
62
+ /**
63
+ * Should this upgrade be forwarded, and under which normalized path?
64
+ *
65
+ * The path is re-serialised from `URL` rather than echoed, so a target that only
66
+ * looks like an allowed path after resolving `..` cannot reach anything else. A
67
+ * request target carrying its own authority is refused outright.
68
+ *
69
+ * @param {string | undefined} url `req.url`, an origin-form request target
70
+ * @param {Headers} headers
71
+ * @param {ReadonlySet<string>} [allowed]
72
+ * @returns {UpgradeDecision}
73
+ */
74
+ export function decideUpgrade(url, headers, allowed = relayPaths()) {
75
+ const upgrade = String(headers.upgrade ?? "").toLowerCase();
76
+ if (upgrade !== "websocket") return { kind: "reject", reason: "not a websocket upgrade" };
77
+ if (!url || !url.startsWith("/") || url.startsWith("//")) {
78
+ return { kind: "reject", reason: "not an origin-form request target" };
79
+ }
80
+ let parsed;
81
+ try {
82
+ parsed = new URL(url, "http://upgrade.invalid");
83
+ } catch {
84
+ return { kind: "reject", reason: "unparsable request target" };
85
+ }
86
+ if (!allowed.has(parsed.pathname)) return { kind: "reject", reason: "not an allowed upgrade path" };
87
+ return { kind: "proxy", path: `${parsed.pathname}${parsed.search}` };
88
+ }
89
+
90
+ /**
91
+ * What the API is told about the request.
92
+ *
93
+ * Handshake headers and the session cookie pass through untouched — the API
94
+ * authorises the socket from them, so dropping either turns every connection into a
95
+ * 401. The `x-forwarded-*` trio is appended rather than replaced: behind another
96
+ * proxy this server is one hop in a chain, and overwriting it reports the wrong
97
+ * client upstream.
98
+ *
99
+ * @param {Headers} headers
100
+ * @param {{ remoteAddress?: string, encrypted?: boolean }} socket
101
+ * @returns {Headers}
102
+ */
103
+ export function upstreamHeaders(headers, socket) {
104
+ /** @type {Headers} */
105
+ const forwarded = {};
106
+ for (const [name, value] of Object.entries(headers)) {
107
+ if (value === undefined) continue;
108
+ if (name === "x-forwarded-for" || name === "x-forwarded-proto" || name === "x-forwarded-host") {
109
+ continue;
110
+ }
111
+ forwarded[name] = value;
112
+ }
113
+
114
+ const client = normalizeClientIp(socket.remoteAddress);
115
+ const chain = [headers["x-forwarded-for"], client]
116
+ .flat()
117
+ .filter((entry) => typeof entry === "string" && entry.length > 0);
118
+ if (chain.length > 0) forwarded["x-forwarded-for"] = chain.join(", ");
119
+
120
+ forwarded["x-forwarded-proto"] = headers["x-forwarded-proto"] ?? (socket.encrypted ? "https" : "http");
121
+ const host = headers["x-forwarded-host"] ?? headers.host;
122
+ if (host !== undefined) forwarded["x-forwarded-host"] = host;
123
+
124
+ return forwarded;
125
+ }
126
+
127
+ /**
128
+ * @param {string} path
129
+ * @param {Headers} headers
130
+ */
131
+ export function upstreamOptions(path, headers) {
132
+ const base = new URL(backendBaseUrl());
133
+ return {
134
+ protocol: base.protocol,
135
+ hostname: base.hostname,
136
+ port: base.port || (base.protocol === "https:" ? "443" : "80"),
137
+ path,
138
+ headers,
139
+ };
140
+ }
141
+
142
+ /**
143
+ * @param {string} statusLine
144
+ * @param {Headers} headers
145
+ * @returns {string}
146
+ */
147
+ function rawHead(statusLine, headers) {
148
+ const lines = [statusLine];
149
+ for (const [name, value] of Object.entries(headers)) {
150
+ if (value === undefined) continue;
151
+ if (Array.isArray(value)) for (const item of value) lines.push(`${name}: ${item}`);
152
+ else lines.push(`${name}: ${value}`);
153
+ }
154
+ return `${lines.join("\r\n")}\r\n\r\n`;
155
+ }
156
+
157
+ /**
158
+ * Streaming sockets carry many small frames, so Nagle's algorithm adds latency to
159
+ * every one, and the default timeout would cut an idle-but-attached connection.
160
+ *
161
+ * @param {{ setTimeout?: Function, setNoDelay?: Function, setKeepAlive?: Function }} socket
162
+ */
163
+ function tune(socket) {
164
+ socket.setTimeout?.(0);
165
+ socket.setNoDelay?.(true);
166
+ socket.setKeepAlive?.(true, 0);
167
+ }
168
+
169
+ /** The real transport: one upstream HTTP request carrying the upgrade. */
170
+ const nodeConnect = (options, hooks) => {
171
+ const transport = options.protocol === "https:" ? https : http;
172
+ const upstream = transport.request({
173
+ protocol: options.protocol,
174
+ hostname: options.hostname,
175
+ port: options.port,
176
+ path: options.path,
177
+ method: "GET",
178
+ headers: options.headers,
179
+ });
180
+ upstream.on("upgrade", (response, socket, head) => {
181
+ hooks.onUpgrade(response.statusCode ?? 101, response.headers, socket, head);
182
+ });
183
+ upstream.on("response", (response) => {
184
+ hooks.onResponse(response.statusCode ?? 502, response.statusMessage ?? "", response.headers);
185
+ response.resume(); // Drain, or the socket is never released.
186
+ });
187
+ upstream.on("error", (error) => hooks.onError(error.message));
188
+ upstream.end();
189
+ return { abort: () => upstream.destroy() };
190
+ };
191
+
192
+ /**
193
+ * @typedef {object} UpstreamHooks
194
+ * @property {(status: number, headers: Headers, socket: any, head: Uint8Array | null) => void} onUpgrade
195
+ * @property {(status: number, statusMessage: string, headers: Headers) => void} onResponse
196
+ * @property {(reason: string) => void} onError
197
+ */
198
+
199
+ /** @typedef {(options: ReturnType<typeof upstreamOptions>, hooks: UpstreamHooks) => { abort: () => void }} Connect */
200
+
201
+ /**
202
+ * Build the `upgrade` listener the built server mounts.
203
+ *
204
+ * The transport is injected so the decisions here are testable without a socket.
205
+ *
206
+ * @param {{ connect?: Connect, allowed?: ReadonlySet<string> }} [options]
207
+ */
208
+ export function createUpgradeProxy(options = {}) {
209
+ const connect = options.connect ?? nodeConnect;
210
+ const allowed = options.allowed ?? relayPaths();
211
+
212
+ return function onUpgrade(request, socket, head) {
213
+ const decision = decideUpgrade(request.url, request.headers, allowed);
214
+ if (decision.kind === "reject") {
215
+ socket.destroy();
216
+ return;
217
+ }
218
+
219
+ tune(socket);
220
+ // Bytes the client already sent belong to the stream: push them back so the pipe
221
+ // below carries them upstream in order.
222
+ if (head && head.length > 0) socket.unshift(head);
223
+
224
+ let clientGone = false;
225
+ const upstream = connect(
226
+ upstreamOptions(decision.path, upstreamHeaders(request.headers, socket)),
227
+ {
228
+ onUpgrade(status, headers, upstreamSocket, upstreamHead) {
229
+ if (clientGone) {
230
+ upstreamSocket.destroy();
231
+ return;
232
+ }
233
+ if (upstreamHead && upstreamHead.length > 0) upstreamSocket.unshift(upstreamHead);
234
+ tune(upstreamSocket);
235
+ socket.write(rawHead(`HTTP/1.1 ${status} Switching Protocols`, headers));
236
+ // A client that hangs forever is worse than one told it lost the relay, so
237
+ // either end closing takes the other down.
238
+ upstreamSocket.on("error", () => socket.destroy());
239
+ upstreamSocket.on("close", () => socket.end());
240
+ upstreamSocket.pipe(socket);
241
+ socket.pipe(upstreamSocket);
242
+ },
243
+ onResponse(status, statusMessage, headers) {
244
+ // The API refused the handshake. Relaying ITS answer is what turns "the
245
+ // socket vanished" into a diagnosable failure in the browser.
246
+ socket.write(
247
+ rawHead(`HTTP/1.1 ${status} ${statusMessage}`.trim(), {
248
+ ...headers,
249
+ connection: "close",
250
+ }),
251
+ );
252
+ socket.end();
253
+ },
254
+ onError() {
255
+ socket.write(rawHead("HTTP/1.1 502 Bad Gateway", { connection: "close" }));
256
+ socket.end();
257
+ },
258
+ },
259
+ );
260
+
261
+ socket.on("error", () => upstream.abort());
262
+ socket.on("close", () => {
263
+ clientGone = true;
264
+ upstream.abort();
265
+ });
266
+ };
267
+ }
@@ -0,0 +1,134 @@
1
+ import { PassThrough } from "node:stream";
2
+ import { describe, expect, it } from "vitest";
3
+ import { createUpgradeProxy, decideUpgrade, upstreamHeaders } from "./ws-upgrade.js";
4
+
5
+ const allowed = new Set(["/events/ws"]);
6
+ const handshake = { upgrade: "websocket", connection: "Upgrade", host: "app.example.com" };
7
+
8
+ describe("decideUpgrade", () => {
9
+ it("forwards an allowed path", () => {
10
+ expect(decideUpgrade("/events/ws", handshake, allowed)).toEqual({
11
+ kind: "proxy",
12
+ path: "/events/ws",
13
+ });
14
+ });
15
+
16
+ it("keeps the query string", () => {
17
+ expect(decideUpgrade("/events/ws?room=1", handshake, allowed)).toEqual({
18
+ kind: "proxy",
19
+ path: "/events/ws?room=1",
20
+ });
21
+ });
22
+
23
+ it.each([
24
+ ["/events/wsx", "a path that only starts the same"],
25
+ ["/events/ws/", "a trailing slash"],
26
+ ["/admin/secrets", "an unrelated path"],
27
+ ["/events/../admin/secrets", "traversal that resolves elsewhere"],
28
+ ["//evil.example.com/events/ws", "a protocol-relative target"],
29
+ ["http://evil.example.com/events/ws", "an absolute target"],
30
+ ])("rejects %s (%s)", (url) => {
31
+ expect(decideUpgrade(url, handshake, allowed).kind).toBe("reject");
32
+ });
33
+
34
+ it("rejects a request that is not a websocket upgrade", () => {
35
+ expect(decideUpgrade("/events/ws", { ...handshake, upgrade: "h2c" }, allowed).kind).toBe(
36
+ "reject",
37
+ );
38
+ });
39
+
40
+ it("forwards nothing when no path is configured", () => {
41
+ expect(decideUpgrade("/events/ws", handshake, new Set()).kind).toBe("reject");
42
+ });
43
+ });
44
+
45
+ describe("upstreamHeaders", () => {
46
+ it("passes the handshake and cookie through", () => {
47
+ const headers = upstreamHeaders(
48
+ { ...handshake, cookie: "session=abc", "sec-websocket-key": "k" },
49
+ {},
50
+ );
51
+ expect(headers.cookie).toBe("session=abc");
52
+ expect(headers["sec-websocket-key"]).toBe("k");
53
+ expect(headers.upgrade).toBe("websocket");
54
+ });
55
+
56
+ it("appends to an existing forwarded chain rather than replacing it", () => {
57
+ const headers = upstreamHeaders(
58
+ { ...handshake, "x-forwarded-for": "203.0.113.7" },
59
+ { remoteAddress: "::ffff:10.0.0.2" },
60
+ );
61
+ expect(headers["x-forwarded-for"]).toBe("203.0.113.7, 10.0.0.2");
62
+ });
63
+
64
+ it("reports the scheme the client actually used", () => {
65
+ expect(upstreamHeaders(handshake, { encrypted: true })["x-forwarded-proto"]).toBe("https");
66
+ expect(upstreamHeaders(handshake, {})["x-forwarded-proto"]).toBe("http");
67
+ });
68
+ });
69
+
70
+ describe("createUpgradeProxy", () => {
71
+ function socket() {
72
+ const stream = new PassThrough();
73
+ stream.destroy = () => {
74
+ stream.destroyed = true;
75
+ return stream;
76
+ };
77
+ return stream;
78
+ }
79
+
80
+ it("destroys a rejected upgrade without contacting the API", () => {
81
+ let called = false;
82
+ const proxy = createUpgradeProxy({ allowed, connect: () => ((called = true), { abort() {} }) });
83
+ const client = socket();
84
+ proxy({ url: "/admin/secrets", headers: handshake }, client, null);
85
+ expect(called).toBe(false);
86
+ expect(client.destroyed).toBe(true);
87
+ });
88
+
89
+ it("relays the API's refusal instead of dropping the socket", () => {
90
+ const proxy = createUpgradeProxy({
91
+ allowed,
92
+ connect: (_options, hooks) => {
93
+ hooks.onResponse(401, "Unauthorized", { "content-length": "0" });
94
+ return { abort() {} };
95
+ },
96
+ });
97
+ const client = socket();
98
+ /** @type {string[]} */
99
+ const written = [];
100
+ client.on("data", (chunk) => written.push(chunk.toString()));
101
+ proxy({ url: "/events/ws", headers: handshake }, client, null);
102
+ expect(written.join("")).toContain("HTTP/1.1 401 Unauthorized");
103
+ });
104
+
105
+ it("answers 502 when the API cannot be reached", () => {
106
+ const proxy = createUpgradeProxy({
107
+ allowed,
108
+ connect: (_options, hooks) => (hooks.onError("ECONNREFUSED"), { abort() {} }),
109
+ });
110
+ const client = socket();
111
+ /** @type {string[]} */
112
+ const written = [];
113
+ client.on("data", (chunk) => written.push(chunk.toString()));
114
+ proxy({ url: "/events/ws", headers: handshake }, client, null);
115
+ expect(written.join("")).toContain("HTTP/1.1 502 Bad Gateway");
116
+ });
117
+
118
+ it("writes the 101 and pipes both directions once upstream upgrades", () => {
119
+ const upstream = socket();
120
+ const proxy = createUpgradeProxy({
121
+ allowed,
122
+ connect: (_options, hooks) => {
123
+ hooks.onUpgrade(101, { upgrade: "websocket" }, upstream, null);
124
+ return { abort() {} };
125
+ },
126
+ });
127
+ const client = socket();
128
+ /** @type {string[]} */
129
+ const written = [];
130
+ client.on("data", (chunk) => written.push(chunk.toString()));
131
+ proxy({ url: "/events/ws", headers: handshake }, client, null);
132
+ expect(written.join("")).toContain("HTTP/1.1 101 Switching Protocols");
133
+ });
134
+ });
@@ -7,13 +7,23 @@ import { defineConfig } from "vite";
7
7
  // browser origin so the same configuration works for both entry points.
8
8
  const inDocker = process.env.VITE_DOCKER === "1";
9
9
 
10
+ // Dev parity for WebSocket upgrades. In production `server.js` proxies these paths to
11
+ // the API; without the same allowlist here, a feature would work in one and not the
12
+ // other -- which is exactly how a WebSocket gateway used to reach production dead.
13
+ // Set WS_PROXY_PATHS in the shell that runs vite; a .env file cannot reach this file.
14
+ const wsProxyPaths = (process.env.WS_PROXY_PATHS ?? "")
15
+ .split(",")
16
+ .map((entry) => entry.trim())
17
+ .filter((entry) => entry.startsWith("/"));
18
+ const backendTarget = process.env.BACKEND_INTERNAL_URL ?? "http://localhost:5002";
19
+ const wsProxy = Object.fromEntries(
20
+ wsProxyPaths.map((path) => [path, { target: backendTarget, ws: true, changeOrigin: false }]),
21
+ );
22
+
10
23
  export default defineConfig({
11
24
  plugins: [tailwindcss(), sveltekit()],
12
- server: inDocker
13
- ? {
14
- host: true,
15
- port: 5001,
16
- allowedHosts: true,
17
- }
18
- : undefined,
25
+ server: {
26
+ ...(inDocker ? { host: true, port: 5001, allowedHosts: true } : {}),
27
+ ...(wsProxyPaths.length ? { proxy: wsProxy } : {}),
28
+ },
19
29
  });
@@ -1,20 +1,65 @@
1
1
  ---
2
2
  name: podokit-deploy
3
- description: Plan, inspect, apply, verify, and roll back PodoKit Kubernetes releases with an explicit deployment profile and confirmation hash. Use when an agent needs to initialize a .podokit/deploy profile, diagnose a target cluster, preview a release, perform an approved deployment, inspect rollout state, verify public endpoints, or prepare an application rollback.
3
+ description: Plan, inspect, apply, verify, and roll back PodoKit releases with an explicit deployment profile and confirmation hash, on Kubernetes or Docker Compose. Use when an agent needs to initialize a .podokit/deploy profile, diagnose a target, preview a release, perform an approved deployment, inspect rollout state, verify public endpoints, or prepare a rollback.
4
4
  ---
5
5
 
6
6
  # Deploy a PodoKit application
7
7
 
8
+ A profile records which driver it uses, so every command below is the same for both.
9
+ Read the profile's `driver` first and say which one you are operating.
10
+
11
+ | Driver | Target | Use it when |
12
+ | --- | --- | --- |
13
+ | `kubernetes-helm` | a cluster context and namespace | you already run a cluster, or you need more than one node, cluster-managed rollouts, or per-workload scaling |
14
+ | `docker-compose` | one Docker host, local or over `ssh://` | one machine is the whole deployment and a reverse proxy in front of it terminates TLS |
15
+
8
16
  1. Run `npx @podosoft/podokit status`, `npx @podosoft/podokit diff`, and `npx @podosoft/podokit doctor`. Stop if managed-file conflicts or unsupported framework versions could make the image unreliable.
9
17
  2. Read [references/profile.md](references/profile.md) before creating or changing a deployment profile.
10
- 3. Run `npx @podosoft/podokit deploy doctor --profile <name>`. Resolve every failure without printing Secret values.
11
- 4. Run `npx @podosoft/podokit deploy plan --profile <name> --release <stable-semver> --json`.
12
- 5. Show the user the context, cluster fingerprint result, namespace, Helm release, exact images, current revision and status, actions, migration warning, and plan hash.
13
- 6. Do not apply until the user explicitly approves that exact plan.
14
- 7. Apply with `npx @podosoft/podokit deploy apply --profile <name> --release <stable-semver> --confirm <plan-hash>`.
15
- 8. Run `npx @podosoft/podokit deploy status --profile <name> --json` and `npx @podosoft/podokit deploy verify --profile <name> --json`.
16
- 9. Report the exact images, Helm revision, ready replicas, restart totals, and verification result.
18
+ 3. Make sure the images exist for that tag. Read [references/release.md](references/release.md): tagging `vX.Y.Z` runs the release workflow, which builds and pushes both images and stops there. Never apply a release whose images were not published.
19
+ 4. Run `npx @podosoft/podokit deploy doctor --profile <name>`. Resolve every failure without printing secret values.
20
+ 5. Run `npx @podosoft/podokit deploy plan --profile <name> --release <stable-semver> --json`.
21
+ 6. Show the user the driver, the target (context plus namespace and Helm release, or Docker context plus Compose project), the fingerprint result, the exact image digests, the current revision, the actions, the migration warning, and the plan hash.
22
+ 7. Do not apply until the user explicitly approves that exact plan.
23
+ 8. Apply with `npx @podosoft/podokit deploy apply --profile <name> --release <stable-semver> --confirm <plan-hash>`.
24
+ 9. Run `npx @podosoft/podokit deploy status --profile <name> --json` and `npx @podosoft/podokit deploy verify --profile <name> --json`.
25
+ 10. Report the exact images, the revision, running replicas, restart totals, and the verification result. Report `syncDrift` too: a non-empty value means containers are running locally synced artifacts and the release tag does not describe what is serving.
26
+
27
+ ## Iterating against a deployment is a different command
28
+
29
+ If the user is changing code and wants to see it on the deployment, a release is the
30
+ wrong loop — it rebuilds and republishes to move a few megabytes of compiled output.
31
+ Use the `podokit-deploy-fast` skill (`podo deploy sync`, docker-compose driver only).
32
+ Reach for a release when the change needs a migration, changes runtime dependencies,
33
+ or is going to real users.
34
+
35
+ ## The migration runs against the release that is still serving
36
+
37
+ Both drivers run the migration **before** the new containers start, and both keep the
38
+ previous release serving while the new one rolls out. So a migration must be
39
+ compatible with the code already running:
40
+
41
+ - Additive changes are safe: new tables, new nullable columns, new indexes.
42
+ - Dropping or renaming a column, or tightening a constraint, breaks the running
43
+ release the moment the migration commits — before any new code is live.
44
+ - Destructive changes belong in a **second** release, after the code that stopped
45
+ using the column has fully rolled out.
46
+
47
+ Rollback does not reverse migrations on either driver. If an older image cannot read
48
+ the current schema, roll forward instead.
49
+
50
+ ## Rollback
51
+
52
+ Read [references/rollback.md](references/rollback.md), preview the target revision
53
+ without `--confirm`, show the new plan hash, and obtain explicit approval.
54
+
55
+ The compose driver can only reproduce a revision whose rendered project still matches
56
+ the current profile byte for byte; if the profile changed since, it refuses and tells
57
+ you to roll forward.
17
58
 
18
- For rollback, read [references/rollback.md](references/rollback.md), preview the target revision without `--confirm`, show the new plan hash, and obtain explicit approval.
59
+ ## Boundary
19
60
 
20
- Never create or rotate cluster credentials, registry credentials, DNS, virtual machines, Kubernetes Secrets, or backup repositories. Never put a password, token, private key, connection URL containing credentials, or Secret value in a profile, command argument, log, or report.
61
+ Never create or rotate cluster credentials, registry credentials, DNS records,
62
+ virtual machines, Kubernetes Secrets, env files on a deployment host, TLS
63
+ certificates, or backup repositories. Never put a password, token, private key,
64
+ connection URL containing credentials, or secret value in a profile, a command
65
+ argument, a log, or a report.
@@ -2,6 +2,8 @@
2
2
 
3
3
  The profile at `.podokit/deploy/<name>.json` is application-owned and contains no Secret values.
4
4
 
5
+ ## kubernetes-helm (default)
6
+
5
7
  Initialize it with an explicit kubeconfig context:
6
8
 
7
9
  ```bash
@@ -26,3 +28,48 @@ Required keys:
26
28
  Use `inCluster` when the generated Helm release should manage a dependency, `external` when application configuration points to an existing service, and `disabled` only when the application does not require it.
27
29
 
28
30
  Use the same stable SemVer tag for API and web. `latest`, branch tags, and empty tags are rejected. Planning resolves every deployed tag to an immutable registry digest through Docker Buildx.
31
+
32
+ ## docker-compose
33
+
34
+ Initialize it with an explicit Docker context. The context may be local or `ssh://`,
35
+ and it is pinned by a fingerprint of the endpoint plus the daemon ID, so repointing
36
+ it at a different host invalidates the profile instead of deploying somewhere else.
37
+
38
+ ```bash
39
+ npx @podosoft/podokit deploy init \
40
+ --profile production \
41
+ --driver docker-compose \
42
+ --context production \
43
+ --host app.example.com \
44
+ --secrets-dir /etc/podokit/example-app
45
+ ```
46
+
47
+ The fields that differ from the Kubernetes driver:
48
+
49
+ - `target`: Docker context, endpoint fingerprint, Compose project name
50
+ - `exposure`: `bindAddress` and `port` published from the web service. It defaults to
51
+ `127.0.0.1`, because the expected front door is a reverse proxy on the same host —
52
+ binding every interface publishes the app before TLS exists
53
+ - `workloads`: replicas plus Compose `cpus` and `memory` limits
54
+ - `dependencies`: `managed`, `external`, or `disabled`, each with a named Docker
55
+ volume and the path of its env file on the target host
56
+ - `secrets`: env-file paths and required key names, plus `registryLogin`
57
+
58
+ Credentials live in env files **on the target host**, never in the profile and never
59
+ in the rendered Compose project. The doctor reads only the key names, in a throwaway
60
+ container that mounts the file read-only. Create those files with the host's own
61
+ secret tooling before deploying; PodoKit does not write them.
62
+
63
+ Required keys:
64
+
65
+ | Env file | Required keys |
66
+ | --- | --- |
67
+ | API | Profile-declared keys plus enabled dependency connection keys: `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_DB`, `REDIS_PASSWORD`, `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY` |
68
+ | web | Only explicitly declared web keys; omitted by default |
69
+ | PostgreSQL dependency | `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_DB` |
70
+ | Redis dependency | `REDIS_PASSWORD` |
71
+ | Object storage dependency | `MINIO_ROOT_USER`, `MINIO_ROOT_PASSWORD`, `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY` |
72
+
73
+ Data volumes are declared `external`, so `docker compose down -v` cannot delete the
74
+ database. Editing an env file changes the containers' rollout label, so the next
75
+ apply actually restarts them instead of leaving stale environment values running.
@@ -0,0 +1,64 @@
1
+ # Publishing the images a release deploys
2
+
3
+ `podo deploy` consumes images. Building them is `.github/workflows/release.yml`,
4
+ which triggers on a `v*.*.*` tag, verifies the commit, builds both images from the
5
+ repository root, and pushes them under that one tag. It does not roll out.
6
+
7
+ ```bash
8
+ git tag v1.2.3
9
+ git push origin v1.2.3
10
+ ```
11
+
12
+ Then plan and apply as the main skill describes. `podo deploy plan` resolves the tag
13
+ to an immutable digest, so a tag whose images were never pushed fails there rather
14
+ than half way through a rollout.
15
+
16
+ ## Before the first tag: check the runner
17
+
18
+ **GitHub-hosted runners are free for public repositories only.** In a private
19
+ repository every minute is metered against the account's included Actions minutes and
20
+ billed beyond them, and image builds are usually the most expensive job a project
21
+ runs. A self-hosted runner consumes no Actions minutes at all.
22
+
23
+ The workflow reads the choice from a variable, so it is a setting rather than an edit:
24
+
25
+ | Variable | Unset | Notes |
26
+ | --- | --- | --- |
27
+ | `PODOKIT_RUNNER` | `["ubuntu-latest"]` | JSON array of labels. Self-hosted example: `["self-hosted","Linux","X64"]` |
28
+ | `PODOKIT_DEPLOY_PROFILE` | `production` | which `.podokit/deploy/<name>.json` names the image repositories |
29
+ | `PODOKIT_IMAGE_PLATFORM` | `linux/amd64` | the architecture the **deployment target** runs, not the runner's |
30
+
31
+ Nothing warns when a private repository is still on the default: the workflow
32
+ succeeds either way and the difference appears on a bill. **When asked to set up
33
+ releases, check the repository's visibility first and say which case it is.**
34
+
35
+ `PODOKIT_IMAGE_PLATFORM` is the other one worth stating out loud. Building for the
36
+ wrong architecture produces `exec format error` at rollout — after the migration has
37
+ already run — and building for the right one on a runner of a different architecture
38
+ works but runs under emulation, which is slow enough to be mistaken for a problem
39
+ with Docker.
40
+
41
+ ## Secrets
42
+
43
+ `REGISTRY_USERNAME` and `REGISTRY_PASSWORD`, for the registry named by the profile's
44
+ `release.apiRepository`. A robot or deploy account is enough for pushing, but some
45
+ registries do not let a robot **create** a repository — push the first tag of a new
46
+ repository with an account that can, or create the repository in the registry first.
47
+
48
+ Never print either value, and never put one in a profile, a command argument, or a
49
+ report.
50
+
51
+ ## Building by hand
52
+
53
+ Only when a tag is not the right trigger. Build for the target's architecture, tag
54
+ both images identically, and push before planning:
55
+
56
+ ```bash
57
+ docker build --platform linux/amd64 -f apps/api/Dockerfile -t <api-repository>:v1.2.3 .
58
+ docker build --platform linux/amd64 -f apps/web/Dockerfile -t <web-repository>:v1.2.3 .
59
+ docker push <api-repository>:v1.2.3
60
+ docker push <web-repository>:v1.2.3
61
+ ```
62
+
63
+ A bare local tag cannot be deployed: it does not match the profile's stable SemVer
64
+ pattern and cannot be resolved to a digest.