@imwz/wp-pattern-sentinel 0.1.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.
package/README.md ADDED
@@ -0,0 +1,154 @@
1
+ # wp-pattern-sentinel
2
+
3
+ Browser-based WordPress block pattern validator. Loads each pattern into the Gutenberg editor via Playwright, saves it, and checks for block validation errors and content mismatches.
4
+
5
+ ## Why browser-based?
6
+
7
+ WordPress block validation is a JavaScript concern. The editor's `save()` function can inject styles, reorder CSS classes, and drop attributes in ways that PHP cannot replicate. Only a real browser can catch these errors.
8
+
9
+ ## Credentials
10
+
11
+ Credentials are resolved in this order — the first match wins:
12
+
13
+ 1. **`--trellis` flag** — reads directly from Roots Trellis vault + `wordpress_sites.yml`
14
+ 2. **CLI flags** — `--url`, `--user`, `--pass`
15
+ 3. **Environment variables** — `WP_URL`, `WP_USER`, `WP_PASS`
16
+ 4. **`.env` file** — placed in the directory where you run sentinel
17
+ 5. **Interactive prompt** — sentinel asks if nothing else is set (password is masked)
18
+
19
+ `.env` is git-ignored. Never commit real credentials.
20
+
21
+ ---
22
+
23
+ ## Roots Trellis integration
24
+
25
+ If your project uses [Roots Trellis](https://roots.io/trellis/), pass `--trellis` and sentinel reads everything it needs from the vault and `wordpress_sites.yml` — no manual credential setup required.
26
+
27
+ ```bash
28
+ # Auto-detect site from cwd, use development env
29
+ sentinel --trellis path/to/patterns/
30
+
31
+ # Specify a site explicitly
32
+ sentinel --trellis --site=demo.imagewize.com path/to/patterns/
33
+
34
+ # Validate a multisite subsite
35
+ sentinel --trellis --site=demo.imagewize.com --subsite=store path/to/patterns/
36
+
37
+ # Staging or production vault
38
+ sentinel --trellis --env=staging --site=imagewize.com path/to/patterns/
39
+
40
+ # Explicit trellis directory (if auto-discovery fails)
41
+ sentinel --trellis --trellis-dir=/path/to/trellis path/to/patterns/
42
+ ```
43
+
44
+ **Requirements:**
45
+ - `ansible-vault` installed (`brew install ansible` or `pip install ansible`)
46
+ - `trellis/.vault_pass` present (standard Trellis setup)
47
+
48
+ Sentinel auto-discovers the Trellis directory by walking up from the current working directory. It also auto-detects the site by matching cwd against each site's `local_path` in `wordpress_sites.yml`.
49
+
50
+ **Trellis flags:**
51
+
52
+ | Flag | Default | Description |
53
+ |------|---------|-------------|
54
+ | `--trellis` | — | Enable Trellis credential source |
55
+ | `--trellis-dir` | auto-discover | Path to your `trellis/` directory |
56
+ | `--site` | auto-detect from cwd | Site key, e.g. `demo.imagewize.com` |
57
+ | `--env` | `development` | Trellis environment (`development`, `staging`, `production`) |
58
+ | `--subsite` | — | Multisite subsite slug (appended to URL) |
59
+
60
+ ---
61
+
62
+ ## Quickstart with `.env`
63
+
64
+ ```bash
65
+ cp .env.example .env
66
+ # edit .env with your site URL and admin credentials
67
+ ```
68
+
69
+ ---
70
+
71
+ ## Install
72
+
73
+ ```bash
74
+ npm install
75
+ npx playwright install chromium
76
+ ```
77
+
78
+ ## Usage
79
+
80
+ ```bash
81
+ # Minimal — credentials come from .env
82
+ node bin/sentinel.js path/to/patterns/
83
+
84
+ # Validate a directory (credentials via flags)
85
+ node bin/sentinel.js \
86
+ --url=http://imagewize.test \
87
+ --user=admin \
88
+ --pass=secret \
89
+ path/to/patterns/
90
+
91
+ # Validate specific files
92
+ node bin/sentinel.js patterns/hero.php patterns/cta.php
93
+
94
+ # JSON output (one result object per line)
95
+ node bin/sentinel.js --json --url=... path/to/patterns/
96
+
97
+ # Keep draft pages in WordPress after validation
98
+ node bin/sentinel.js --keep-page --url=... path/to/patterns/
99
+
100
+ # Run headed (watch the browser)
101
+ node bin/sentinel.js --no-headless --url=... path/to/patterns/
102
+
103
+ # Adjust concurrency (default: 4)
104
+ node bin/sentinel.js --concurrency=6 --url=... path/to/patterns/
105
+ ```
106
+
107
+ ## Options
108
+
109
+ | Flag | Default | Description |
110
+ |------|---------|-------------|
111
+ | `--url` | `http://localhost` | WordPress site URL |
112
+ | `--user` | `admin` | Admin username |
113
+ | `--pass` | `password` | Admin password |
114
+ | `--headless` | `true` | Run browser headless |
115
+ | `--concurrency` | `4` | Parallel workers |
116
+ | `--json` | `false` | Output JSON (one result per line) |
117
+ | `--keep-page` | `false` | Don't delete draft pages after validation |
118
+ | `--width` | `1280` | Viewport width |
119
+ | `--height` | `800` | Viewport height |
120
+
121
+ ## Architecture
122
+
123
+ ```
124
+ bin/sentinel.js CLI entry point
125
+ src/
126
+ main.js Orchestration — context pool, p-queue, summary
127
+ login.js loginToWordPress()
128
+ editor.js createDraftPage, insertPatternIntoEditor, savePage, deletePage, extractBlockContent
129
+ validation.js checkBlockValidation, compareContent
130
+ args.js parseArgs, resolveFiles
131
+ format.js log, formatResult, printSummary
132
+ ```
133
+
134
+ Each worker gets its own authenticated `BrowserContext` so session failures are isolated. Login happens once per context before the queue starts.
135
+
136
+ ## npm publish
137
+
138
+ When ready to publish:
139
+
140
+ ```bash
141
+ npm login
142
+ npm publish --access public
143
+ ```
144
+
145
+ Then use globally:
146
+
147
+ ```bash
148
+ npx wp-pattern-sentinel --url=http://imagewize.test --user=admin --pass=secret patterns/
149
+ ```
150
+
151
+ ## Exit codes
152
+
153
+ - `0` — all patterns passed
154
+ - `1` — one or more patterns failed
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env node
2
+ import { main } from '../src/main.js';
3
+
4
+ main().catch(err => {
5
+ console.error(err.message);
6
+ process.exit(1);
7
+ });
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@imwz/wp-pattern-sentinel",
3
+ "version": "0.1.0",
4
+ "description": "Browser-based WordPress block pattern validator using Playwright",
5
+ "type": "module",
6
+ "engines": {
7
+ "node": ">=18"
8
+ },
9
+ "bin": {
10
+ "sentinel": "bin/sentinel.js"
11
+ },
12
+ "scripts": {
13
+ "start": "node bin/sentinel.js"
14
+ },
15
+ "files": [
16
+ "bin/",
17
+ "src/",
18
+ "README.md"
19
+ ],
20
+ "keywords": [
21
+ "wordpress",
22
+ "gutenberg",
23
+ "block-patterns",
24
+ "playwright",
25
+ "validation"
26
+ ],
27
+ "license": "MIT",
28
+ "dependencies": {
29
+ "js-yaml": "^4.1.0",
30
+ "p-queue": "^8.0.1",
31
+ "playwright": "^1.44.0"
32
+ },
33
+ "contributors": [
34
+ {
35
+ "name": "Jasper Frumau",
36
+ "email": "jasper@imagewize.com"
37
+ }
38
+ ]
39
+ }
package/src/args.js ADDED
@@ -0,0 +1,162 @@
1
+ import { parseArgs as nodeParseArgs } from 'util';
2
+ import path from 'path';
3
+ import fs from 'fs';
4
+ import { prompt } from './prompt.js';
5
+ import { findTrellisDir, loadTrellisCredentials } from './trellis.js';
6
+
7
+ /**
8
+ * Credential resolution priority:
9
+ * 1. --trellis flag → reads Roots Trellis vault + wordpress_sites.yml
10
+ * 2. CLI flags → --url, --user, --pass
11
+ * 3. Env vars → WP_URL, WP_USER, WP_PASS
12
+ * 4. .env file → loaded from cwd automatically
13
+ * 5. Interactive → prompted if still missing (password is masked)
14
+ */
15
+
16
+ const ARG_OPTIONS = {
17
+ // Credentials
18
+ url: { type: 'string' },
19
+ user: { type: 'string' },
20
+ pass: { type: 'string' },
21
+ // Trellis integration
22
+ trellis: { type: 'boolean', default: false },
23
+ 'trellis-dir': { type: 'string' },
24
+ site: { type: 'string' },
25
+ env: { type: 'string', default: 'development' },
26
+ subsite: { type: 'string' },
27
+ // Behaviour
28
+ headless: { type: 'boolean', default: true },
29
+ json: { type: 'boolean', default: false },
30
+ 'keep-page': { type: 'boolean', default: false },
31
+ concurrency: { type: 'string', default: '4' },
32
+ width: { type: 'string', default: '1280' },
33
+ height: { type: 'string', default: '800' },
34
+ };
35
+
36
+ /**
37
+ * Parse a .env file and add any keys not already in process.env.
38
+ * Supports KEY=value, KEY="value", KEY='value', and # comments.
39
+ * Silently skips if no .env file exists.
40
+ */
41
+ function loadDotEnv() {
42
+ const envPath = path.join(process.cwd(), '.env');
43
+ try {
44
+ const content = fs.readFileSync(envPath, 'utf8');
45
+ for (const line of content.split('\n')) {
46
+ const trimmed = line.trim();
47
+ if (!trimmed || trimmed.startsWith('#')) continue;
48
+ const eq = trimmed.indexOf('=');
49
+ if (eq === -1) continue;
50
+ const key = trimmed.slice(0, eq).trim();
51
+ const val = trimmed.slice(eq + 1).trim().replace(/^["']|["']$/g, '');
52
+ if (!(key in process.env)) process.env[key] = val;
53
+ }
54
+ } catch {
55
+ // No .env — that's fine
56
+ }
57
+ }
58
+
59
+ export async function parseArgs(args) {
60
+ loadDotEnv();
61
+
62
+ const { values, positionals } = nodeParseArgs({
63
+ args,
64
+ options: ARG_OPTIONS,
65
+ allowPositionals: true,
66
+ });
67
+
68
+ let url, user, pass;
69
+
70
+ // --- Source 1: Trellis vault ---
71
+ if (values.trellis) {
72
+ const trellisDir = values['trellis-dir']
73
+ ? path.resolve(values['trellis-dir'])
74
+ : findTrellisDir();
75
+
76
+ ({ url, user, pass } = loadTrellisCredentials({
77
+ trellisDir,
78
+ site: values.site,
79
+ env: values.env,
80
+ subsite: values.subsite ?? null,
81
+ }));
82
+
83
+ } else {
84
+ // --- Source 2: CLI flags ---
85
+ url = values.url;
86
+ user = values.user;
87
+ pass = values.pass;
88
+
89
+ // --- Source 3+4: Env vars / .env file ---
90
+ url ??= process.env.WP_URL;
91
+ user ??= process.env.WP_USER;
92
+ pass ??= process.env.WP_PASS;
93
+
94
+ // --- Source 5: Interactive prompt ---
95
+ if (!url) {
96
+ url = await prompt('WordPress URL (e.g. http://site.test): ');
97
+ if (!url) throw new Error('WordPress URL is required.');
98
+ }
99
+ if (!user) {
100
+ user = await prompt('WordPress admin username: ');
101
+ if (!user) throw new Error('WordPress username is required.');
102
+ }
103
+ if (!pass) {
104
+ pass = await prompt('WordPress admin password: ', { hidden: true });
105
+ if (!pass) throw new Error('WordPress password is required.');
106
+ }
107
+ }
108
+
109
+ return {
110
+ url: url.replace(/\/$/, ''),
111
+ user,
112
+ pass,
113
+ headless: values.headless,
114
+ json: values.json,
115
+ keepPage: values['keep-page'],
116
+ concurrency: Math.max(1, parseInt(values.concurrency, 10)),
117
+ viewport: {
118
+ width: parseInt(values.width, 10),
119
+ height: parseInt(values.height, 10),
120
+ },
121
+ files: positionals,
122
+ };
123
+ }
124
+
125
+ export function resolveFiles(filePaths) {
126
+ if (!filePaths || filePaths.length === 0) {
127
+ throw new Error(
128
+ 'No pattern files specified.\n' +
129
+ 'Usage: sentinel [--trellis [--site=example.com]] path/to/patterns/\n' +
130
+ 'Or set WP_URL, WP_USER, WP_PASS in .env'
131
+ );
132
+ }
133
+
134
+ const resolved = [];
135
+ for (const filePath of filePaths) {
136
+ const abs = path.resolve(filePath);
137
+ if (!fs.existsSync(abs)) {
138
+ console.warn(`Warning: path not found — ${filePath}`);
139
+ continue;
140
+ }
141
+ const stat = fs.statSync(abs);
142
+ if (stat.isDirectory()) {
143
+ resolved.push(...findPhpFiles(abs));
144
+ } else {
145
+ resolved.push(abs);
146
+ }
147
+ }
148
+ return [...new Set(resolved)];
149
+ }
150
+
151
+ function findPhpFiles(dir) {
152
+ const results = [];
153
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
154
+ const full = path.join(dir, entry.name);
155
+ if (entry.isDirectory()) {
156
+ results.push(...findPhpFiles(full));
157
+ } else if (entry.isFile() && entry.name.endsWith('.php')) {
158
+ results.push(full);
159
+ }
160
+ }
161
+ return results;
162
+ }
package/src/editor.js ADDED
@@ -0,0 +1,123 @@
1
+ import { log } from './format.js';
2
+
3
+ /**
4
+ * Strip the PHP file header (opening tag + docblock) and return the raw block markup.
5
+ * Returns null if no block comment is found.
6
+ *
7
+ * WordPress pattern files look like:
8
+ * <?php
9
+ * /**
10
+ * * Title: My Pattern
11
+ * * ...
12
+ * *\/
13
+ * ?>
14
+ * <!-- wp:group -->...
15
+ */
16
+ export function extractBlockContent(fileContent) {
17
+ const stripped = fileContent
18
+ .replace(/^<\?php\s*/m, '')
19
+ .replace(/\/\*\*[\s\S]*?\*\//m, '')
20
+ .replace(/^\s*\?>\s*/m, '')
21
+ .trim();
22
+
23
+ if (!stripped.startsWith('<!--')) return null;
24
+ return stripped;
25
+ }
26
+
27
+ /**
28
+ * Navigate to a new draft page and return its post ID.
29
+ * WordPress redirects post-new.php → post.php?post=ID&action=edit,
30
+ * so we can read the ID directly from the final URL.
31
+ */
32
+ export async function createDraftPage(page, baseUrl) {
33
+ try {
34
+ await page.goto(`${baseUrl}/wp-admin/post-new.php?post_type=page`, {
35
+ waitUntil: 'domcontentloaded',
36
+ timeout: 30000,
37
+ });
38
+
39
+ await page.waitForSelector('.edit-post-layout, .editor-styles-wrapper', {
40
+ timeout: 30000,
41
+ });
42
+
43
+ const url = page.url();
44
+ const match = url.match(/[?&]post=(\d+)/);
45
+ if (match) return parseInt(match[1], 10);
46
+
47
+ // Fallback: read from wp.data (editor may not have redirected yet)
48
+ return await page.evaluate(() =>
49
+ window.wp?.data?.select('core/editor')?.getCurrentPostId?.() ?? null
50
+ );
51
+ } catch (error) {
52
+ log(`Failed to create draft page: ${error.message}`, 'red');
53
+ return null;
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Set the editor content via wp.data and wait for blocks to parse.
59
+ */
60
+ export async function insertPatternIntoEditor(page, blockContent) {
61
+ try {
62
+ await page.evaluate(content => {
63
+ window.wp.data.dispatch('core/editor').editPost({ content });
64
+ }, blockContent);
65
+
66
+ // Wait until at least one block is present
67
+ await page.waitForFunction(
68
+ () => window.wp.data.select('core/block-editor').getBlocks().length > 0,
69
+ { timeout: 15000 }
70
+ );
71
+
72
+ return true;
73
+ } catch (error) {
74
+ log(`Failed to insert pattern: ${error.message}`, 'red');
75
+ return false;
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Trigger savePost() and wait for the editor to finish saving.
81
+ */
82
+ export async function savePage(page) {
83
+ const result = { success: false, errors: [], warnings: [] };
84
+ try {
85
+ await page.evaluate(() => window.wp.data.dispatch('core/editor').savePost());
86
+
87
+ // Wait for save to start, then finish
88
+ await page
89
+ .waitForFunction(
90
+ () => window.wp.data.select('core/editor').isSavingPost(),
91
+ { timeout: 5000 }
92
+ )
93
+ .catch(() => {}); // save might be near-instant
94
+
95
+ await page.waitForFunction(
96
+ () => !window.wp.data.select('core/editor').isSavingPost(),
97
+ { timeout: 30000 }
98
+ );
99
+
100
+ result.success = true;
101
+ } catch (error) {
102
+ result.errors.push({ type: 'save_error', message: error.message });
103
+ }
104
+ return result;
105
+ }
106
+
107
+ /**
108
+ * Delete the draft page via the WP REST API (uses the active browser session's nonce).
109
+ * Non-fatal — a failure here does not affect validation results.
110
+ */
111
+ export async function deletePage(page, baseUrl, pageId) {
112
+ try {
113
+ await page.evaluate(async id => {
114
+ const nonce = window.wpApiSettings?.nonce ?? '';
115
+ await fetch(`/wp-json/wp/v2/pages/${id}?force=true`, {
116
+ method: 'DELETE',
117
+ headers: { 'X-WP-Nonce': nonce },
118
+ });
119
+ }, pageId);
120
+ } catch {
121
+ // Non-fatal
122
+ }
123
+ }
package/src/format.js ADDED
@@ -0,0 +1,49 @@
1
+ const C = {
2
+ reset: '\x1b[0m',
3
+ red: '\x1b[31m',
4
+ green: '\x1b[32m',
5
+ yellow: '\x1b[33m',
6
+ cyan: '\x1b[36m',
7
+ gray: '\x1b[90m',
8
+ };
9
+
10
+ export function log(message, color = 'reset') {
11
+ console.log(`${C[color] ?? C.reset}${message}${C.reset}`);
12
+ }
13
+
14
+ export function formatResult(result) {
15
+ const status = result.passed
16
+ ? `${C.green}✓ PASS${C.reset}`
17
+ : `${C.red}✗ FAIL${C.reset}`;
18
+
19
+ const lines = [`${status} ${result.pattern} (${result.duration}ms)`];
20
+
21
+ for (const err of result.errors) {
22
+ lines.push(` ${C.red}ERROR${C.reset} [${err.type}] ${err.message}`);
23
+ }
24
+ for (const warn of result.warnings) {
25
+ lines.push(` ${C.yellow}WARN${C.reset} [${warn.type}] ${warn.message}`);
26
+ }
27
+
28
+ return lines.join('\n');
29
+ }
30
+
31
+ export function printSummary(results) {
32
+ const passed = results.filter(r => r.passed).length;
33
+ const failed = results.filter(r => !r.passed).length;
34
+ const totalErrors = results.reduce((n, r) => n + r.errors.length, 0);
35
+ const totalWarns = results.reduce((n, r) => n + r.warnings.length, 0);
36
+ const totalMs = results.reduce((n, r) => n + r.duration, 0);
37
+ const avgMs = Math.round(totalMs / results.length);
38
+
39
+ log('\n' + '='.repeat(60));
40
+ log('Validation Summary', 'cyan');
41
+ log('='.repeat(60));
42
+ log(`Patterns : ${results.length}`);
43
+ log(`Passed : ${passed}`, passed > 0 ? 'green' : 'gray');
44
+ log(`Failed : ${failed}`, failed > 0 ? 'red' : 'gray');
45
+ log(`Errors : ${totalErrors}`, totalErrors > 0 ? 'red' : 'gray');
46
+ log(`Warnings : ${totalWarns}`, totalWarns > 0 ? 'yellow' : 'gray');
47
+ log(`Time : ${totalMs}ms (avg ${avgMs}ms/pattern)`);
48
+ console.log('');
49
+ }
package/src/login.js ADDED
@@ -0,0 +1,18 @@
1
+ import { log } from './format.js';
2
+
3
+ export async function loginToWordPress(page, url, user, pass) {
4
+ try {
5
+ await page.goto(`${url}/wp-login.php`, {
6
+ waitUntil: 'domcontentloaded',
7
+ timeout: 30000,
8
+ });
9
+ await page.fill('#user_login', user);
10
+ await page.fill('#user_pass', pass);
11
+ await page.click('#wp-submit');
12
+ await page.waitForURL(`${url}/wp-admin/**`, { timeout: 15000 });
13
+ return true;
14
+ } catch (error) {
15
+ log(`Login failed: ${error.message}`, 'red');
16
+ return false;
17
+ }
18
+ }
package/src/main.js ADDED
@@ -0,0 +1,150 @@
1
+ import { chromium } from 'playwright';
2
+ import PQueue from 'p-queue';
3
+ import path from 'path';
4
+ import fs from 'fs';
5
+ import { parseArgs, resolveFiles } from './args.js';
6
+ import { loginToWordPress } from './login.js';
7
+ import {
8
+ extractBlockContent,
9
+ createDraftPage,
10
+ insertPatternIntoEditor,
11
+ savePage,
12
+ deletePage,
13
+ } from './editor.js';
14
+ import { checkBlockValidation, compareContent } from './validation.js';
15
+ import { log, formatResult, printSummary } from './format.js';
16
+
17
+ export async function main() {
18
+ const options = await parseArgs(process.argv.slice(2));
19
+ const files = resolveFiles(options.files);
20
+
21
+ if (files.length === 0) {
22
+ log('No pattern files found.', 'red');
23
+ process.exit(1);
24
+ }
25
+
26
+ log(`\nFound ${files.length} pattern(s) — concurrency: ${options.concurrency}`, 'cyan');
27
+
28
+ const browser = await chromium.launch({
29
+ headless: options.headless,
30
+ args: ['--disable-web-security'],
31
+ });
32
+
33
+ // One authenticated context per worker — sessions are fully isolated
34
+ let contexts;
35
+ try {
36
+ contexts = await Promise.all(
37
+ Array.from({ length: options.concurrency }, async () => {
38
+ const context = await browser.newContext({ viewport: options.viewport });
39
+ const page = await context.newPage();
40
+ page.setDefaultTimeout(60000);
41
+ const ok = await loginToWordPress(page, options.url, options.user, options.pass);
42
+ await page.close();
43
+ if (!ok) throw new Error('Failed to authenticate with WordPress');
44
+ return context;
45
+ })
46
+ );
47
+ } catch (error) {
48
+ log(error.message, 'red');
49
+ await browser.close();
50
+ process.exit(1);
51
+ }
52
+
53
+ const queue = new PQueue({ concurrency: options.concurrency });
54
+ let idx = 0;
55
+
56
+ // Push ALL tasks without awaiting — this is what makes workers run in parallel
57
+ const promises = files.map(file => {
58
+ const context = contexts[idx++ % options.concurrency];
59
+ return queue.add(() => validatePatternFile(file, options, context));
60
+ });
61
+
62
+ const results = await Promise.all(promises);
63
+
64
+ await Promise.all(contexts.map(c => c.close()));
65
+ await browser.close();
66
+
67
+ if (options.json) {
68
+ for (const r of results) console.log(JSON.stringify(r));
69
+ } else {
70
+ for (const r of results) console.log(formatResult(r));
71
+ printSummary(results);
72
+ }
73
+
74
+ process.exit(results.some(r => !r.passed) ? 1 : 0);
75
+ }
76
+
77
+ async function validatePatternFile(patternPath, options, context) {
78
+ const patternName = path.basename(patternPath);
79
+ const startTime = Date.now();
80
+
81
+ let fileContent;
82
+ try {
83
+ fileContent = await fs.promises.readFile(patternPath, 'utf8');
84
+ } catch (error) {
85
+ return fail(patternName, startTime, 'file_error', error.message);
86
+ }
87
+
88
+ const blockContent = extractBlockContent(fileContent);
89
+ if (!blockContent) {
90
+ return fail(patternName, startTime, 'extraction_error', 'Could not extract block content from file');
91
+ }
92
+
93
+ const page = await context.newPage();
94
+ page.setDefaultTimeout(60000);
95
+
96
+ try {
97
+ const pageId = await createDraftPage(page, options.url);
98
+ if (pageId === null) {
99
+ return fail(patternName, startTime, 'page_creation_error', 'Failed to create test page');
100
+ }
101
+
102
+ if (!(await insertPatternIntoEditor(page, blockContent))) {
103
+ await deletePage(page, options.url, pageId);
104
+ return fail(patternName, startTime, 'insertion_error', 'Failed to insert pattern into editor');
105
+ }
106
+
107
+ const saveResult = await savePage(page);
108
+ const blockErrors = await checkBlockValidation(page);
109
+
110
+ if (blockErrors.length > 0) {
111
+ saveResult.errors.push(...blockErrors.map(e => ({
112
+ type: 'block_validation',
113
+ message: `${e.blockName} (${e.blockId}): ${e.error}`,
114
+ })));
115
+ }
116
+
117
+ const comparison = await compareContent(page, blockContent);
118
+ saveResult.errors.push(...comparison.errors);
119
+ saveResult.warnings.push(...comparison.warnings);
120
+
121
+ if (!options.keepPage) {
122
+ await deletePage(page, options.url, pageId);
123
+ }
124
+
125
+ return {
126
+ pattern: patternName,
127
+ passed: saveResult.success && comparison.matches && blockErrors.length === 0,
128
+ errors: saveResult.errors,
129
+ warnings: saveResult.warnings,
130
+ duration: Date.now() - startTime,
131
+ savedContent: comparison.savedContent,
132
+ };
133
+
134
+ } catch (error) {
135
+ log(`Unexpected error — ${patternName}: ${error.message}`, 'red');
136
+ return fail(patternName, startTime, 'validation_error', error.message);
137
+ } finally {
138
+ await page.close();
139
+ }
140
+ }
141
+
142
+ function fail(pattern, startTime, type, message) {
143
+ return {
144
+ pattern,
145
+ passed: false,
146
+ errors: [{ type, message }],
147
+ warnings: [],
148
+ duration: Date.now() - startTime,
149
+ };
150
+ }
package/src/prompt.js ADDED
@@ -0,0 +1,44 @@
1
+ import { createInterface } from 'readline';
2
+
3
+ /**
4
+ * Prompt the user for a value. Pass hidden: true for passwords
5
+ * (masks input with asterisks).
6
+ */
7
+ export function prompt(question, { hidden = false } = {}) {
8
+ return new Promise(resolve => {
9
+ if (!hidden) {
10
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
11
+ rl.question(question, answer => { rl.close(); resolve(answer); });
12
+ return;
13
+ }
14
+
15
+ process.stdout.write(question);
16
+ process.stdin.setRawMode(true);
17
+ process.stdin.resume();
18
+ process.stdin.setEncoding('utf8');
19
+
20
+ let value = '';
21
+
22
+ const onData = char => {
23
+ if (char === '\r' || char === '\n' || char === '') {
24
+ process.stdin.setRawMode(false);
25
+ process.stdin.pause();
26
+ process.stdin.removeListener('data', onData);
27
+ process.stdout.write('\n');
28
+ resolve(value);
29
+ } else if (char === '' || char === '') {
30
+ if (value.length > 0) {
31
+ value = value.slice(0, -1);
32
+ process.stdout.clearLine(0);
33
+ process.stdout.cursorTo(0);
34
+ process.stdout.write(question + '*'.repeat(value.length));
35
+ }
36
+ } else {
37
+ value += char;
38
+ process.stdout.write('*');
39
+ }
40
+ };
41
+
42
+ process.stdin.on('data', onData);
43
+ });
44
+ }
package/src/trellis.js ADDED
@@ -0,0 +1,113 @@
1
+ import { execSync } from 'child_process';
2
+ import path from 'path';
3
+ import fs from 'fs';
4
+ import yaml from 'js-yaml';
5
+
6
+ /**
7
+ * Walk up from startDir looking for a directory that contains group_vars/
8
+ * (the Trellis signature). Checks both the dir itself and a trellis/ subdir.
9
+ */
10
+ export function findTrellisDir(startDir = process.cwd()) {
11
+ let dir = path.resolve(startDir);
12
+
13
+ for (let i = 0; i < 10; i++) {
14
+ // Direct match (we're already inside the trellis dir)
15
+ if (fs.existsSync(path.join(dir, 'group_vars'))) return dir;
16
+
17
+ // Sibling trellis/ directory
18
+ const sibling = path.join(dir, 'trellis');
19
+ if (fs.existsSync(path.join(sibling, 'group_vars'))) return sibling;
20
+
21
+ const parent = path.dirname(dir);
22
+ if (parent === dir) break;
23
+ dir = parent;
24
+ }
25
+
26
+ throw new Error(
27
+ 'Could not find Trellis directory (looking for group_vars/ marker).\n' +
28
+ 'Run sentinel from inside your project, or pass --trellis-dir=/path/to/trellis.'
29
+ );
30
+ }
31
+
32
+ /**
33
+ * Try to match the current working directory against a site's local_path
34
+ * to auto-select which site to validate. Returns the site key or null.
35
+ *
36
+ * local_path in wordpress_sites.yml is relative to the trellis directory.
37
+ */
38
+ function detectSiteFromCwd(trellisDir, sites) {
39
+ const cwd = process.cwd();
40
+ for (const [key, config] of Object.entries(sites)) {
41
+ if (!config.local_path) continue;
42
+ const abs = path.resolve(trellisDir, config.local_path);
43
+ if (cwd.startsWith(abs)) return key;
44
+ }
45
+ return null;
46
+ }
47
+
48
+ /**
49
+ * Load credentials from Trellis vault and wordpress_sites.yml.
50
+ *
51
+ * Options:
52
+ * trellisDir — absolute path to the trellis directory
53
+ * site — site key, e.g. "demo.imagewize.com" (auto-detected if omitted)
54
+ * env — "development" | "staging" | "production" (default: "development")
55
+ * subsite — multisite subsite slug appended to the URL (e.g. "store")
56
+ *
57
+ * Returns { url, user, pass }
58
+ */
59
+ export function loadTrellisCredentials({ trellisDir, site, env = 'development', subsite = null }) {
60
+ const vaultFile = path.join(trellisDir, 'group_vars', env, 'vault.yml');
61
+ const vaultPassFile = path.join(trellisDir, '.vault_pass');
62
+ const sitesFile = path.join(trellisDir, 'group_vars', env, 'wordpress_sites.yml');
63
+
64
+ for (const [label, p] of [['Vault password file', vaultPassFile], ['Sites file', sitesFile]]) {
65
+ if (!fs.existsSync(p)) throw new Error(`${label} not found: ${p}`);
66
+ }
67
+
68
+ // Decrypt vault with ansible-vault
69
+ let vaultContent;
70
+ try {
71
+ vaultContent = execSync(
72
+ `ansible-vault view --vault-password-file "${vaultPassFile}" "${vaultFile}"`,
73
+ { cwd: trellisDir, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }
74
+ );
75
+ } catch (err) {
76
+ throw new Error(`Failed to decrypt Trellis vault: ${err.message}`);
77
+ }
78
+
79
+ const vault = yaml.load(vaultContent);
80
+ const sites = yaml.load(fs.readFileSync(sitesFile, 'utf8'));
81
+
82
+ const vaultSites = vault.vault_wordpress_sites ?? {};
83
+ const wordpressSites = sites.wordpress_sites ?? {};
84
+
85
+ // Auto-detect site from cwd, then fall back to first site in the file
86
+ const siteKey = site
87
+ ?? detectSiteFromCwd(trellisDir, wordpressSites)
88
+ ?? Object.keys(wordpressSites)[0];
89
+
90
+ if (!vaultSites[siteKey]) {
91
+ const available = Object.keys(vaultSites).join(', ');
92
+ throw new Error(`Site "${siteKey}" not found in vault. Available: ${available}`);
93
+ }
94
+ if (!wordpressSites[siteKey]) {
95
+ throw new Error(`Site "${siteKey}" not found in wordpress_sites.yml`);
96
+ }
97
+
98
+ const adminPassword = vaultSites[siteKey].admin_password;
99
+ if (!adminPassword) {
100
+ throw new Error(`No admin_password for "${siteKey}" in vault`);
101
+ }
102
+
103
+ // Build URL from canonical hostname + SSL setting
104
+ const canonical = wordpressSites[siteKey].site_hosts[0].canonical;
105
+ const ssl = wordpressSites[siteKey].ssl?.enabled ?? false;
106
+ const protocol = ssl ? 'https' : 'http';
107
+ const url = subsite
108
+ ? `${protocol}://${canonical}/${subsite}`
109
+ : `${protocol}://${canonical}`;
110
+
111
+ // Trellis admin username is always "admin" — not stored in the vault
112
+ return { url, user: 'admin', pass: adminPassword };
113
+ }
@@ -0,0 +1,65 @@
1
+ import { log } from './format.js';
2
+
3
+ /**
4
+ * Walk the block tree and collect any blocks where isValid === false.
5
+ */
6
+ export async function checkBlockValidation(page) {
7
+ try {
8
+ return await page.evaluate(() => {
9
+ const walk = blocks => blocks.flatMap(block => [
10
+ ...(block.isValid === false
11
+ ? [{ blockId: block.clientId, blockName: block.name, error: 'Block validation failed' }]
12
+ : []),
13
+ ...walk(block.innerBlocks ?? []),
14
+ ]);
15
+ return walk(window.wp.data.select('core/block-editor').getBlocks());
16
+ });
17
+ } catch (error) {
18
+ log(`Block validation check error: ${error.message}`, 'yellow');
19
+ return [];
20
+ }
21
+ }
22
+
23
+ /**
24
+ * Compare the editor's serialized output against the original source.
25
+ * Whitespace-normalizes both sides before diffing to avoid false positives
26
+ * from indentation changes, then surfaces up to 5 added/removed lines.
27
+ */
28
+ export async function compareContent(page, originalContent) {
29
+ const result = { matches: true, errors: [], warnings: [], savedContent: null };
30
+
31
+ try {
32
+ const savedContent = await page.evaluate(() =>
33
+ window.wp.data.select('core/editor').getEditedPostContent()
34
+ );
35
+ result.savedContent = savedContent;
36
+
37
+ const normalize = str => str.replace(/\s+/g, ' ').trim();
38
+ if (normalize(savedContent) === normalize(originalContent)) return result;
39
+
40
+ result.matches = false;
41
+
42
+ const origLines = originalContent.split('\n').map(l => l.trim()).filter(Boolean);
43
+ const savedLines = savedContent.split('\n').map(l => l.trim()).filter(Boolean);
44
+
45
+ const removed = origLines.filter(l => !savedLines.includes(l)).slice(0, 5);
46
+ const added = savedLines.filter(l => !origLines.includes(l)).slice(0, 5);
47
+
48
+ if (removed.length > 0) {
49
+ result.errors.push({
50
+ type: 'content_mismatch',
51
+ message: `Content removed by editor:\n ${removed.join('\n ')}`,
52
+ });
53
+ }
54
+ if (added.length > 0) {
55
+ result.warnings.push({
56
+ type: 'content_injected',
57
+ message: `Content injected by editor:\n ${added.join('\n ')}`,
58
+ });
59
+ }
60
+ } catch (error) {
61
+ result.errors.push({ type: 'comparison_error', message: error.message });
62
+ }
63
+
64
+ return result;
65
+ }