@brunosps00/dev-workflow 1.6.0 → 1.7.1
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.
- package/.claude-plugin/plugin.json +2 -2
- package/README.md +2 -2
- package/lib/constants.js +2 -0
- package/lib/install-deps.js +37 -1
- package/lib/install-open-design.js +128 -0
- package/lib/utils.js +46 -6
- package/package.json +2 -2
- package/scaffold/en/commands/dw-help.md +3 -1
- package/scaffold/en/commands/dw-new-project.md +24 -21
- package/scaffold/en/commands/dw-open-design.md +324 -0
- package/scaffold/en/templates/project-onepager.md +12 -11
- package/scaffold/pt-br/commands/dw-help.md +3 -1
- package/scaffold/pt-br/commands/dw-new-project.md +24 -21
- package/scaffold/pt-br/commands/dw-open-design.md +323 -0
- package/scaffold/pt-br/templates/project-onepager.md +12 -11
- package/scaffold/scripts/open-design/capture-current.mjs +170 -0
- package/scaffold/scripts/open-design/gate-prototype.mjs +223 -0
- package/scaffold/skills/docker-compose-recipes/SKILL.md +6 -5
- package/scaffold/skills/docker-compose-recipes/references/compose-composition.md +7 -7
- package/scaffold/skills/docker-compose-recipes/references/env-conventions.md +3 -1
- package/scaffold/skills/docker-compose-recipes/references/healthcheck-patterns.md +4 -4
- package/scaffold/skills/docker-compose-recipes/services/mailpit.yml +4 -4
- package/scaffold/skills/docker-compose-recipes/services/postgres-pgvector.yml +31 -0
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { pathToFileURL } from "node:url";
|
|
6
|
+
|
|
7
|
+
function parseArgs(argv) {
|
|
8
|
+
const result = {};
|
|
9
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
10
|
+
const token = argv[index];
|
|
11
|
+
if (!token.startsWith("--")) continue;
|
|
12
|
+
const [rawKey, inlineValue] = token.slice(2).split("=", 2);
|
|
13
|
+
const key = rawKey.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
|
|
14
|
+
if (inlineValue !== undefined) {
|
|
15
|
+
result[key] = inlineValue;
|
|
16
|
+
continue;
|
|
17
|
+
}
|
|
18
|
+
const next = argv[index + 1];
|
|
19
|
+
if (!next || next.startsWith("--")) {
|
|
20
|
+
result[key] = true;
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
result[key] = next;
|
|
24
|
+
index += 1;
|
|
25
|
+
}
|
|
26
|
+
return result;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function importFirefox() {
|
|
30
|
+
try {
|
|
31
|
+
return (await import("@playwright/test")).firefox;
|
|
32
|
+
} catch {
|
|
33
|
+
try {
|
|
34
|
+
return (await import("playwright")).firefox;
|
|
35
|
+
} catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function slugify(value) {
|
|
42
|
+
return (
|
|
43
|
+
String(value)
|
|
44
|
+
.toLowerCase()
|
|
45
|
+
.replace(/\.html?$/, "")
|
|
46
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
47
|
+
.replace(/^-+|-+$/g, "")
|
|
48
|
+
.slice(0, 70) || "prototype"
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function addResult(results, name, pass, details = "") {
|
|
53
|
+
results.push({ name, pass: Boolean(pass), details });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function structuralChecks(page, html, results) {
|
|
57
|
+
const data = await page.evaluate(() => {
|
|
58
|
+
const visibleText = document.body ? document.body.innerText : "";
|
|
59
|
+
const hasDialogRole = Boolean(document.querySelector('[role="dialog"], [aria-modal="true"], [aria-labelledby]'));
|
|
60
|
+
const title = document.title.trim();
|
|
61
|
+
return { visibleText, hasDialogRole, title };
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
addResult(results, "title", data.title.length > 0, data.title || "missing document title");
|
|
65
|
+
addResult(results, "aberto-param", /\baberto\b/.test(html), "source contains aberto");
|
|
66
|
+
addResult(results, "replace-state", /replaceState/.test(html), "source contains replaceState");
|
|
67
|
+
addResult(results, "dialog-aria", data.hasDialogRole, "role=dialog, aria-modal, or aria-labelledby present");
|
|
68
|
+
addResult(results, "skeleton-state", /skeleton|carregando|loading/i.test(html + "\n" + data.visibleText));
|
|
69
|
+
addResult(results, "empty-state", /empty|vazio|sem\s+(resultado|dados|itens|registros)/i.test(html + "\n" + data.visibleText));
|
|
70
|
+
addResult(results, "error-state", /error|erro|falha|failed|retry|tentar novamente/i.test(html + "\n" + data.visibleText));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function visibleDialog(page, selector) {
|
|
74
|
+
const selected = selector ? page.locator(selector).first() : page.locator('[role="dialog"], [aria-modal="true"]').first();
|
|
75
|
+
try {
|
|
76
|
+
return (await selected.count()) > 0 && (await selected.isVisible());
|
|
77
|
+
} catch {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function clickFirstOpenable(page, rowSelector) {
|
|
83
|
+
const selector =
|
|
84
|
+
rowSelector ||
|
|
85
|
+
'[data-open], [data-id], tbody tr, [role="row"], [role="button"], article, li button, li, button';
|
|
86
|
+
const candidates = page.locator(selector);
|
|
87
|
+
const count = Math.min(await candidates.count(), 20);
|
|
88
|
+
for (let index = 0; index < count; index += 1) {
|
|
89
|
+
const candidate = candidates.nth(index);
|
|
90
|
+
try {
|
|
91
|
+
if (!(await candidate.isVisible())) continue;
|
|
92
|
+
await candidate.click({ timeout: 1500 });
|
|
93
|
+
return true;
|
|
94
|
+
} catch {
|
|
95
|
+
// Try the next plausible opener.
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function behavioralChecks(page, baseUrl, args, results) {
|
|
102
|
+
const dialogSelector = args.dialogSelector ? String(args.dialogSelector) : "";
|
|
103
|
+
if (!(await visibleDialog(page, dialogSelector))) {
|
|
104
|
+
const clicked = await clickFirstOpenable(page, args.rowSelector ? String(args.rowSelector) : "");
|
|
105
|
+
await page.waitForTimeout(300);
|
|
106
|
+
addResult(results, "open-drawer-click", clicked && (await visibleDialog(page, dialogSelector)), "first row/openable opens dialog or drawer");
|
|
107
|
+
} else {
|
|
108
|
+
addResult(results, "open-drawer-click", true, "dialog already visible");
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const deepLinkId = args.deepLinkId ? String(args.deepLinkId) : "";
|
|
112
|
+
if (!deepLinkId) {
|
|
113
|
+
addResult(results, "deep-link-opens", false, "missing --deep-link-id");
|
|
114
|
+
addResult(results, "esc-clears-param", false, "missing --deep-link-id");
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const url = new URL(baseUrl);
|
|
119
|
+
url.searchParams.set("aberto", deepLinkId);
|
|
120
|
+
await page.goto(url.toString(), { waitUntil: "load" });
|
|
121
|
+
await page.waitForTimeout(500);
|
|
122
|
+
|
|
123
|
+
const expectedText = String(args.expectedText || deepLinkId);
|
|
124
|
+
const deepLinkData = await page.evaluate((text) => {
|
|
125
|
+
const bodyText = document.body ? document.body.innerText : "";
|
|
126
|
+
const hasDialog = Boolean(document.querySelector('[role="dialog"], [aria-modal="true"]'));
|
|
127
|
+
return {
|
|
128
|
+
hasDialog,
|
|
129
|
+
hasText: bodyText.toLowerCase().includes(text.toLowerCase()),
|
|
130
|
+
search: window.location.search,
|
|
131
|
+
};
|
|
132
|
+
}, expectedText);
|
|
133
|
+
addResult(
|
|
134
|
+
results,
|
|
135
|
+
"deep-link-opens",
|
|
136
|
+
deepLinkData.search.includes(`aberto=${encodeURIComponent(deepLinkId)}`) && deepLinkData.hasDialog && deepLinkData.hasText,
|
|
137
|
+
`expected visible text: ${expectedText}`,
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
await page.keyboard.press("Escape");
|
|
141
|
+
await page.waitForTimeout(500);
|
|
142
|
+
const searchAfterEsc = await page.evaluate(() => window.location.search);
|
|
143
|
+
addResult(results, "esc-clears-param", !new URLSearchParams(searchAfterEsc).has("aberto"), searchAfterEsc || "empty search");
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function captureScreenshots(browser, fileUrl, outDir, slug) {
|
|
147
|
+
const shots = [];
|
|
148
|
+
for (const scheme of ["light", "dark"]) {
|
|
149
|
+
const context = await browser.newContext({
|
|
150
|
+
colorScheme: scheme,
|
|
151
|
+
viewport: { width: 1440, height: 1000 },
|
|
152
|
+
});
|
|
153
|
+
const page = await context.newPage();
|
|
154
|
+
await page.goto(fileUrl, { waitUntil: "load" });
|
|
155
|
+
await page.evaluate((theme) => {
|
|
156
|
+
document.documentElement.setAttribute("data-theme", theme);
|
|
157
|
+
}, scheme);
|
|
158
|
+
await page.waitForTimeout(300);
|
|
159
|
+
const shotPath = path.join(outDir, `${slug}-${scheme}.png`);
|
|
160
|
+
await page.screenshot({ path: shotPath, fullPage: true });
|
|
161
|
+
shots.push({ colorScheme: scheme, path: shotPath });
|
|
162
|
+
await context.close();
|
|
163
|
+
}
|
|
164
|
+
return shots;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function main() {
|
|
168
|
+
const args = parseArgs(process.argv.slice(2));
|
|
169
|
+
if (!args.file) {
|
|
170
|
+
throw new Error("Missing required argument: --file <prototype.html>");
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const filePath = path.resolve(String(args.file));
|
|
174
|
+
if (!fs.existsSync(filePath)) {
|
|
175
|
+
throw new Error(`Prototype file not found: ${filePath}`);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const firefox = await importFirefox();
|
|
179
|
+
if (!firefox) {
|
|
180
|
+
throw new Error("Playwright is not installed. Run: npm i -D @playwright/test && npx playwright install firefox");
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const html = fs.readFileSync(filePath, "utf8");
|
|
184
|
+
const outDir = path.resolve(String(args.out || path.join(".dw", ".open-design", "gate", slugify(path.basename(filePath)))));
|
|
185
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
186
|
+
const baseUrl = pathToFileURL(filePath).toString();
|
|
187
|
+
const slug = slugify(path.basename(filePath));
|
|
188
|
+
|
|
189
|
+
const browser = await firefox.launch({ headless: true });
|
|
190
|
+
const results = [];
|
|
191
|
+
let screenshots = [];
|
|
192
|
+
try {
|
|
193
|
+
const context = await browser.newContext({ colorScheme: "light", viewport: { width: 1440, height: 1000 } });
|
|
194
|
+
const page = await context.newPage();
|
|
195
|
+
await page.goto(baseUrl, { waitUntil: "load" });
|
|
196
|
+
await structuralChecks(page, html, results);
|
|
197
|
+
await behavioralChecks(page, baseUrl, args, results);
|
|
198
|
+
await context.close();
|
|
199
|
+
screenshots = await captureScreenshots(browser, baseUrl, outDir, slug);
|
|
200
|
+
} finally {
|
|
201
|
+
await browser.close();
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const failed = results.filter((result) => !result.pass);
|
|
205
|
+
const summary = {
|
|
206
|
+
file: filePath,
|
|
207
|
+
score: Number(((results.length - failed.length) / results.length * 10).toFixed(1)),
|
|
208
|
+
pass: failed.length === 0,
|
|
209
|
+
results,
|
|
210
|
+
screenshots,
|
|
211
|
+
};
|
|
212
|
+
fs.writeFileSync(path.join(outDir, `${slug}-gate.json`), JSON.stringify(summary, null, 2) + "\n", "utf8");
|
|
213
|
+
process.stdout.write(`${JSON.stringify(summary, null, 2)}\n`);
|
|
214
|
+
|
|
215
|
+
if (failed.length > 0) {
|
|
216
|
+
process.exit(1);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
main().catch((error) => {
|
|
221
|
+
process.stderr.write(`gate-prototype: ${error.message}\n`);
|
|
222
|
+
process.exit(1);
|
|
223
|
+
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: docker-compose-recipes
|
|
3
|
-
description: Use for docker-compose service blocks (postgres, redis,
|
|
3
|
+
description: Use for docker-compose service blocks (postgres, pgvector, redis, mailpit, minio, meilisearch, jaeger, traefik). Invoked by /dw-new-project, /dw-dockerize, or when composing dev/prod compose files.
|
|
4
4
|
allowed-tools:
|
|
5
5
|
- Read
|
|
6
6
|
- Write
|
|
@@ -49,13 +49,14 @@ Each row points to `services/<name>.yml`.
|
|
|
49
49
|
| Service | Use | Default port(s) | Recipe |
|
|
50
50
|
|---------|-----|-----------------|--------|
|
|
51
51
|
| Postgres 16 (alpine) | Relational DB | `5432` | `services/postgres.yml` |
|
|
52
|
+
| PostgreSQL 18 + pgvector 0.8.2 | Relational + vector DB | `5432` | `services/postgres-pgvector.yml` |
|
|
52
53
|
| MySQL 8 | Relational DB | `3306` | `services/mysql.yml` |
|
|
53
54
|
| Redis 7 (alpine) | Cache, pub/sub, BullMQ backend | `6379` | `services/redis.yml` |
|
|
54
55
|
| Memcached 1.6 (alpine) | Cache | `11211` | `services/memcached.yml` |
|
|
55
56
|
| RabbitMQ 3 (management-alpine) | Message broker + UI | `5672`, UI `15672` | `services/rabbitmq.yml` |
|
|
56
57
|
| LocalStack | AWS-compatible local (S3, SQS, SNS, DynamoDB) | `4566` | `services/localstack.yml` |
|
|
57
|
-
|
|
|
58
|
-
|
|
|
58
|
+
| Mailpit | Default email-in-dev (capture only, never sends) | SMTP `1025`, UI `8025` | `services/mailpit.yml` |
|
|
59
|
+
| MailHog | Legacy email capture alternative | SMTP `1025`, UI `8025` | `services/mailhog.yml` |
|
|
59
60
|
| smtp4dev | Windows-friendly SMTP capture | SMTP `2525`, UI `5000` | `services/smtp4dev.yml` |
|
|
60
61
|
| MinIO | S3-compatible local | API `9000`, UI `9001` | `services/minio.yml` |
|
|
61
62
|
| Meilisearch | Search engine (lightweight) | `7700` | `services/meilisearch.yml` |
|
|
@@ -76,12 +77,12 @@ Each row points to `services/<name>.yml`.
|
|
|
76
77
|
- **Pin major.minor** in every `image:` line. Patch updates are safe; major bumps are deliberate.
|
|
77
78
|
- **Always include healthcheck**. No service is allowed to be opaque about readiness.
|
|
78
79
|
- **Default to dev**. Prod transforms are explicit (see `prod-vs-dev.md`).
|
|
79
|
-
- **Email-in-dev defaults to
|
|
80
|
+
- **Email-in-dev defaults to Mailpit**. The user must opt OUT of capture-only email for dev — never silently route to a real SMTP.
|
|
80
81
|
- **Secrets never in the recipe**. Env vars reference `.env`; defaults in the recipe are OK only for non-secret config.
|
|
81
82
|
|
|
82
83
|
## Inspired by
|
|
83
84
|
|
|
84
|
-
Hand-curated by dev-workflow. Service defaults follow upstream documentation (Docker Hub `postgres`, `redis`, `
|
|
85
|
+
Hand-curated by dev-workflow. Service defaults follow upstream documentation (Docker Hub `postgres`, `pgvector/pgvector`, `redis`, `axllent/mailpit`, `minio/minio`, `getmeili/meilisearch`, `jaegertracing/all-in-one`, `traefik`). Healthcheck patterns adapted from `docker-library/healthcheck` and the official compose docs.
|
|
85
86
|
|
|
86
87
|
## Structured Return
|
|
87
88
|
|
|
@@ -4,7 +4,7 @@ This file describes how `/dw-new-project` and `/dw-dockerize` merge the standalo
|
|
|
4
4
|
|
|
5
5
|
## The merge algorithm
|
|
6
6
|
|
|
7
|
-
1. **Pick the selected services.** From the interview answers (or from stack detection in `dw-dockerize`), get the list of services to include. Example: `[postgres,
|
|
7
|
+
1. **Pick the selected services.** From the interview answers (or from stack detection in `dw-dockerize`), get the list of services to include. Example: `[postgres-pgvector, mailpit, minio]`.
|
|
8
8
|
2. **Read each `services/<name>.yml`** as a standalone fragment. Each file declares ONE top-level service block (e.g., `postgres:`) plus comments at the bottom describing the volumes block to merge.
|
|
9
9
|
3. **Concatenate under a single `services:` map.** Indent each block under `services:` in the final file. Order does not matter for compose, but for readability sort by tier: storage → cache/queue → search → email → observability → proxy.
|
|
10
10
|
4. **Collect the volumes.** Each recipe's comments list the named volumes it needs. Aggregate them under a top-level `volumes:` block. Example:
|
|
@@ -31,7 +31,7 @@ This file describes how `/dw-new-project` and `/dw-dockerize` merge the standalo
|
|
|
31
31
|
condition: service_healthy
|
|
32
32
|
```
|
|
33
33
|
|
|
34
|
-
8. **
|
|
34
|
+
8. **Apply the selected dev topology.** For `everything in Compose`, add the app service(s) at the end and bind source for hot reload. For `apps on host`, do not generate app services or Dockerfile.dev files; expose only infrastructure ports and point application-facing env vars at `localhost`.
|
|
35
35
|
|
|
36
36
|
## Final file shape
|
|
37
37
|
|
|
@@ -42,15 +42,15 @@ This file describes how `/dw-new-project` and `/dw-dockerize` merge the standalo
|
|
|
42
42
|
services:
|
|
43
43
|
# --- storage tier ---
|
|
44
44
|
postgres:
|
|
45
|
-
# ... contents from services/postgres.yml
|
|
45
|
+
# ... contents from services/postgres-pgvector.yml
|
|
46
46
|
|
|
47
47
|
# --- cache tier ---
|
|
48
48
|
redis:
|
|
49
49
|
# ... contents from services/redis.yml
|
|
50
50
|
|
|
51
51
|
# --- email-in-dev (capture only) ---
|
|
52
|
-
|
|
53
|
-
# ... contents from services/
|
|
52
|
+
mailpit:
|
|
53
|
+
# ... contents from services/mailpit.yml
|
|
54
54
|
|
|
55
55
|
# --- object storage ---
|
|
56
56
|
minio:
|
|
@@ -72,8 +72,8 @@ services:
|
|
|
72
72
|
condition: service_healthy
|
|
73
73
|
redis:
|
|
74
74
|
condition: service_healthy
|
|
75
|
-
|
|
76
|
-
condition:
|
|
75
|
+
mailpit:
|
|
76
|
+
condition: service_healthy
|
|
77
77
|
minio:
|
|
78
78
|
condition: service_healthy
|
|
79
79
|
|
|
@@ -23,7 +23,9 @@ POSTGRES_PORT=5432
|
|
|
23
23
|
DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}
|
|
24
24
|
```
|
|
25
25
|
|
|
26
|
-
Compose performs variable substitution in `.env` references
|
|
26
|
+
Compose performs variable substitution in `.env` references. When the application also runs in Compose, use service DNS such as `postgres`; when applications run on the host, the committed example must use `localhost` and the published host port instead.
|
|
27
|
+
|
|
28
|
+
For the PostgreSQL + pgvector recipe, use the same connection variables. The image provides the extension binaries, but `CREATE EXTENSION IF NOT EXISTS vector` belongs in the application's reviewed migrations, not container startup scripts.
|
|
27
29
|
|
|
28
30
|
## What goes in `.env.example` vs `.env`
|
|
29
31
|
|
|
@@ -13,7 +13,7 @@ Every recipe ships a healthcheck. App services that depend on infra MUST gate st
|
|
|
13
13
|
| RabbitMQ | `rabbitmq-diagnostics ping` | Built-in; takes ~30s to be ready, hence `start_period: 30s` |
|
|
14
14
|
| LocalStack | `curl -sf http://localhost:4566/_localstack/health \| grep -q running` | Internal endpoint reports per-service readiness |
|
|
15
15
|
| MailHog | `wget --spider http://localhost:8025` | UI port responds when SMTP is also ready |
|
|
16
|
-
| Mailpit | `wget --spider http://localhost:8025` |
|
|
16
|
+
| Mailpit | `wget --spider http://localhost:8025` | UI port responds when SMTP is also ready |
|
|
17
17
|
| smtp4dev | `wget --spider http://localhost:80` | UI on internal port 80 |
|
|
18
18
|
| MinIO | `curl -f http://localhost:9000/minio/health/live` | Documented liveness endpoint |
|
|
19
19
|
| Meilisearch | `curl -f http://localhost:7700/health` | Documented |
|
|
@@ -41,11 +41,11 @@ api:
|
|
|
41
41
|
condition: service_healthy # waits for the healthcheck to pass
|
|
42
42
|
redis:
|
|
43
43
|
condition: service_healthy
|
|
44
|
-
|
|
45
|
-
condition:
|
|
44
|
+
mailpit:
|
|
45
|
+
condition: service_healthy
|
|
46
46
|
```
|
|
47
47
|
|
|
48
|
-
`service_healthy` is the strictest gate — no traffic until the healthcheck has passed at least once. Use it for
|
|
48
|
+
`service_healthy` is the strictest gate — no traffic until the healthcheck has passed at least once. Use it for every dependency that declares a healthcheck, including Mailpit. Use `service_started` only when a dependency has no healthcheck to gate on.
|
|
49
49
|
|
|
50
50
|
## Common mistakes
|
|
51
51
|
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
# Mailpit —
|
|
1
|
+
# Mailpit — maintained email capture for local development
|
|
2
2
|
# Env vars: MAILPIT_SMTP_PORT (default 1025), MAILPIT_UI_PORT (default 8025)
|
|
3
|
-
# Application-side SMTP config:
|
|
3
|
+
# Application-side SMTP config: SMTP_HOST=mailpit; SMTP_PORT=1025
|
|
4
4
|
|
|
5
5
|
mailpit:
|
|
6
|
-
image: axllent/mailpit:
|
|
6
|
+
image: axllent/mailpit:v1.30
|
|
7
7
|
restart: unless-stopped
|
|
8
8
|
ports:
|
|
9
9
|
- "${MAILPIT_SMTP_PORT:-1025}:1025"
|
|
@@ -24,4 +24,4 @@ mailpit:
|
|
|
24
24
|
# mailpit_data: {}
|
|
25
25
|
#
|
|
26
26
|
# Prod notes:
|
|
27
|
-
# - DEV ONLY. Do not ship to prod
|
|
27
|
+
# - DEV ONLY. Do not ship to prod; replace it with a real SMTP relay or transactional email API.
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# PostgreSQL 18 + pgvector 0.8.2 — relational and vector database
|
|
2
|
+
# Env vars: POSTGRES_USER, POSTGRES_PASSWORD, POSTGRES_DB, POSTGRES_PORT (default 5432)
|
|
3
|
+
# Application-side: derive DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}
|
|
4
|
+
# The image ships pgvector; create the extension in an application migration with: CREATE EXTENSION IF NOT EXISTS vector;
|
|
5
|
+
|
|
6
|
+
postgres:
|
|
7
|
+
image: pgvector/pgvector:0.8.2-pg18
|
|
8
|
+
restart: unless-stopped
|
|
9
|
+
environment:
|
|
10
|
+
POSTGRES_USER: ${POSTGRES_USER:-app}
|
|
11
|
+
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-app}
|
|
12
|
+
POSTGRES_DB: ${POSTGRES_DB:-app}
|
|
13
|
+
ports:
|
|
14
|
+
- "${POSTGRES_PORT:-5432}:5432"
|
|
15
|
+
volumes:
|
|
16
|
+
- postgres_data:/var/lib/postgresql
|
|
17
|
+
healthcheck:
|
|
18
|
+
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-app} -d ${POSTGRES_DB:-app}"]
|
|
19
|
+
interval: 5s
|
|
20
|
+
timeout: 3s
|
|
21
|
+
retries: 10
|
|
22
|
+
start_period: 5s
|
|
23
|
+
|
|
24
|
+
# volumes block to merge:
|
|
25
|
+
# volumes:
|
|
26
|
+
# postgres_data: {}
|
|
27
|
+
#
|
|
28
|
+
# Prod notes:
|
|
29
|
+
# - Use a current patch release derived from PostgreSQL 18 and pgvector 0.8.2 or newer compatible patch.
|
|
30
|
+
# - Move POSTGRES_PASSWORD to a secret and remove the public port mapping.
|
|
31
|
+
# - Keep CREATE EXTENSION and vector indexes in reviewed application migrations.
|