@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.
@@ -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, mailhog, minio, meilisearch, jaeger, traefik). Invoked by /dw-new-project, /dw-dockerize, or when composing dev/prod compose files.
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
- | MailHog | Default email-in-dev (capture only, never sends) | SMTP `1025`, UI `8025` | `services/mailhog.yml` |
58
- | Mailpit | Modern MailHog alternative | SMTP `1025`, UI `8025` | `services/mailpit.yml` |
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 MailHog**. The user must opt OUT of capture-only email for dev — never silently route to a real SMTP.
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`, `mailhog/mailhog`, `minio/minio`, `getmeili/meilisearch`, `jaegertracing/all-in-one`, `traefik`). Healthcheck patterns adapted from `docker-library/healthcheck` and the official compose docs.
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, redis, mailhog, minio]`.
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. **Add the app service(s) at the end.** For monorepo projects, declare an `api` service (and `web` if backend serves the frontend) that builds from the local Dockerfile.dev and binds the source as a volume for hot reload.
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
- mailhog:
53
- # ... contents from services/mailhog.yml
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
- mailhog:
76
- condition: service_started
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, so the application sees `DATABASE_URL` already-resolved.
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` | Same |
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
- mailhog:
45
- condition: service_started # MailHog is light enough that started == ready
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 all data stores. Use `service_started` only for stateless services (proxy, mail capture).
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 — modern alternative to MailHog (active maintenance, full-text search, REST API)
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: same as MailHog (drop-in replacement)
3
+ # Application-side SMTP config: SMTP_HOST=mailpit; SMTP_PORT=1025
4
4
 
5
5
  mailpit:
6
- image: axllent/mailpit:latest
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. See mailhog.yml notes for prod email guidance.
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.