mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-11 21:57:14 +03:00
feat(live): config globs + drift-heal warning for multi-page projects
Config drift was a real tripwire for projects with static generators: new
HTML files get added, never make it into config.files, silently skip
injection. Two additions.
config.files entries now accept glob patterns (**, *, ?) expanded via
fs.globSync in live-inject. Multi-page projects can write
["public/**/*.html"] once and never maintain the list again. New optional
exclude field filters out matched files (email templates, demo fixtures).
HARD_EXCLUDES of node_modules and .git are enforced regardless of user
config so vendor trees can never receive a tracking script.
live.mjs now runs a drift scan after inject: walks common page-source
roots (public/, src/, app/, pages/) and reports HTML files not covered
by the resolved inject targets. Respects user excludes so intentional
omissions aren't flagged. Output JSON carries configDrift: { orphans,
orphanCount, hint } or null. live.md documents the agent flow for
surfacing drift to users without auto-mutating the config.
Unbundle config.json from the distributable skill: it's a per-project
artifact, not skill code. readSourceFiles now skips any PER_PROJECT_ARTIFACTS
during source scan so build output to .claude/ .cursor/ etc never ships
one project's inject targets to another's install. The per-harness
copies stay gitignored via the existing **/skills/impeccable/scripts/config.json
rule; each consuming project writes its own on first /impeccable live.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
6846a135ce
commit
b0feed06c9
@@ -351,16 +351,23 @@ Schema:
|
||||
|
||||
```json
|
||||
{
|
||||
"files": ["<path>", "<path>", ...],
|
||||
"files": ["<path-or-glob>", "<path-or-glob>", ...],
|
||||
"exclude": ["<optional-glob>", ...],
|
||||
"insertBefore": "</body>",
|
||||
"commentSyntax": "html",
|
||||
"cspChecked": true
|
||||
}
|
||||
```
|
||||
|
||||
`files` is the inject target — **the HTML files the browser actually loads**, not necessarily source. Each entry is either a literal path (`"public/index.html"`) or a glob pattern (`"public/**/*.html"`). Tracked or generated doesn't matter here; wrap has its own generated-file guard and routes accepts through the fallback flow.
|
||||
|
||||
`exclude` (optional) is a list of glob patterns matching files to skip, even if a `files` glob would have included them. Use for email templates, demo fixtures, or any HTML that isn't a live page.
|
||||
|
||||
`cspChecked` tracks whether the CSP detection step below has already run. Absent on first setup; set to `true` after CSP is checked (whether patched, declined, or not needed).
|
||||
|
||||
`files` is the inject target — **the HTML files the browser actually loads**, not necessarily source. Tracked or generated doesn't matter here; wrap has its own generated-file guard and routes accepts through the fallback flow.
|
||||
**Hard-excluded paths (cannot be overridden).** `**/node_modules/**` and `**/.git/**` are never matched regardless of what the user writes. These are vendor/metadata directories and injecting into them would silently instrument third-party code.
|
||||
|
||||
**Glob syntax.** `**` matches any number of path segments (including zero), `*` matches any characters except `/`, `?` matches a single character except `/`. Paths are always relative to the project root with forward slashes.
|
||||
|
||||
| Framework | `files` | `insertBefore` | `commentSyntax` |
|
||||
|-----------|---------|----------------|-----------------|
|
||||
@@ -370,12 +377,42 @@ Schema:
|
||||
| Nuxt | `["app.vue"]` | `</body>` | `html` |
|
||||
| Svelte / SvelteKit | `["src/app.html"]` | `</body>` | `html` |
|
||||
| Astro | `[" <root layout .astro>"]` | `</body>` | `html` |
|
||||
| Multi-page (separate HTML per route) | Every HTML file the dev server serves — glob the output dir, e.g. `public/**/*.html` | `</body>` | `html` |
|
||||
| Multi-page (separate HTML per route) | `["public/**/*.html"]` — a glob covering the served directory | `</body>` | `html` |
|
||||
|
||||
Pick an anchor that exists in every file (`</body>` almost always works). Use `insertAfter` if the anchor should match **after** a specific line.
|
||||
|
||||
For multi-page sites, **prefer a glob over a literal file list**. New pages added later are picked up automatically on the next `live-inject.mjs` run; no config maintenance needed.
|
||||
|
||||
For multi-page sites whose pages are *rebuilt* by a generator (Astro, static-site generators, custom scripts like `build-sub-pages.js`), the inject survives only until the next regeneration. Re-run `live.mjs` after each build. Accept is unaffected — it writes to true source via the fallback flow.
|
||||
|
||||
### Drift-heal warning
|
||||
|
||||
On every `live.mjs` boot, after inject, the project is scanned for HTML files under common page-source roots (`public/`, `src/`, `app/`, `pages/`). If any exist that aren't covered by the resolved `files` list, the output includes a `configDrift` field:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"serverPort": 8400,
|
||||
"pageFiles": [ "..." ],
|
||||
"configDrift": {
|
||||
"orphans": ["public/new-section/index.html", "public/docs/new-command.html"],
|
||||
"orphanCount": 2,
|
||||
"hint": "2 HTML file(s) exist but aren't in config.files. Consider adding them, or use a glob pattern like \"public/**/*.html\"."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
When `configDrift` is present, surface it to the user once per session before entering the poll loop:
|
||||
|
||||
> Noticed N HTML file(s) in the project that aren't in `config.files`:
|
||||
>
|
||||
> - `public/new-section/index.html`
|
||||
> - `public/docs/new-command.html`
|
||||
>
|
||||
> Add them, or switch `files` to a glob like `["public/**/*.html"]` and let it track new pages automatically?
|
||||
|
||||
Don't auto-update the config — let the user decide. `configDrift` is `null` when there's no drift.
|
||||
|
||||
### CSP detection (first-time only)
|
||||
|
||||
If `config.cspChecked === true`, skip this entire section. You already asked this user once; the answer sticks.
|
||||
|
||||
@@ -22,6 +22,16 @@ const CONFIG_PATH = process.env.IMPECCABLE_LIVE_CONFIG || path.join(__dirname, '
|
||||
const MARKER_OPEN_TEXT = 'impeccable-live-start';
|
||||
const MARKER_CLOSE_TEXT = 'impeccable-live-end';
|
||||
|
||||
/**
|
||||
* Hard-excluded directory patterns. These are NEVER user-facing pages and
|
||||
* matching them would silently inject tracking scripts into third-party
|
||||
* code. The user cannot turn these off via config — they are the floor.
|
||||
*/
|
||||
const HARD_EXCLUDES = [
|
||||
'**/node_modules/**',
|
||||
'**/.git/**',
|
||||
];
|
||||
|
||||
export async function injectCli() {
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
@@ -71,8 +81,10 @@ Output (JSON):
|
||||
const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
|
||||
validateConfig(config);
|
||||
|
||||
const resolvedFiles = resolveFiles(process.cwd(), config);
|
||||
|
||||
if (args.includes('--remove')) {
|
||||
const results = config.files.map((relFile) => {
|
||||
const results = resolvedFiles.map((relFile) => {
|
||||
const absFile = path.resolve(process.cwd(), relFile);
|
||||
if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' };
|
||||
const content = fs.readFileSync(absFile, 'utf-8');
|
||||
@@ -93,7 +105,7 @@ Output (JSON):
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const results = config.files.map((relFile) => {
|
||||
const results = resolvedFiles.map((relFile) => {
|
||||
const absFile = path.resolve(process.cwd(), relFile);
|
||||
if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' };
|
||||
const content = fs.readFileSync(absFile, 'utf-8');
|
||||
@@ -108,6 +120,95 @@ Output (JSON):
|
||||
if (!anyInserted) process.exit(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand config.files (which may contain glob patterns) into a literal list
|
||||
* of existing file paths relative to rootDir. Literal entries pass through;
|
||||
* glob patterns are expanded via fs.globSync. HARD_EXCLUDES and config.exclude
|
||||
* are applied as filters. Duplicates are removed. Order is preserved by
|
||||
* first appearance.
|
||||
*/
|
||||
export function resolveFiles(rootDir, config) {
|
||||
const patterns = config.files;
|
||||
const userExcludes = Array.isArray(config.exclude) ? config.exclude : [];
|
||||
const allExcludes = [...HARD_EXCLUDES, ...userExcludes];
|
||||
const excludeRegexes = allExcludes.map(globToRegex);
|
||||
|
||||
const isExcluded = (relPath) => excludeRegexes.some((re) => re.test(relPath));
|
||||
const isGlob = (s) => /[*?[]/.test(s);
|
||||
|
||||
const seen = new Set();
|
||||
const out = [];
|
||||
for (const pat of patterns) {
|
||||
if (!isGlob(pat)) {
|
||||
// Literal path — include even if it doesn't exist yet; the caller
|
||||
// reports file_not_found per-entry. Exclude list doesn't apply to
|
||||
// explicit literal entries (user named it on purpose).
|
||||
if (!seen.has(pat)) {
|
||||
seen.add(pat);
|
||||
out.push(pat);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let matches;
|
||||
try {
|
||||
matches = fs.globSync(pat, { cwd: rootDir, withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const ent of matches) {
|
||||
if (!ent.isFile || !ent.isFile()) continue;
|
||||
const abs = path.join(ent.parentPath || ent.path || rootDir, ent.name);
|
||||
const rel = path.relative(rootDir, abs).split(path.sep).join('/');
|
||||
if (isExcluded(rel)) continue;
|
||||
if (seen.has(rel)) continue;
|
||||
seen.add(rel);
|
||||
out.push(rel);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a glob pattern to a RegExp. Supports:
|
||||
* ** → any number of path segments (including zero)
|
||||
* * → any chars except `/`
|
||||
* ? → any single char except `/`
|
||||
* Paths are normalized to forward slashes before matching.
|
||||
*/
|
||||
function globToRegex(pattern) {
|
||||
let re = '';
|
||||
let i = 0;
|
||||
while (i < pattern.length) {
|
||||
const c = pattern[i];
|
||||
if (c === '*') {
|
||||
if (pattern[i + 1] === '*') {
|
||||
// ** — any number of segments, including zero. Handle the common
|
||||
// **/ and /** forms so `a/**/b` matches `a/b` as well as `a/x/y/b`.
|
||||
if (pattern[i + 2] === '/') {
|
||||
re += '(?:.*/)?';
|
||||
i += 3;
|
||||
} else {
|
||||
re += '.*';
|
||||
i += 2;
|
||||
}
|
||||
} else {
|
||||
re += '[^/]*';
|
||||
i += 1;
|
||||
}
|
||||
} else if (c === '?') {
|
||||
re += '[^/]';
|
||||
i += 1;
|
||||
} else if (/[.+^${}()|[\]\\]/.test(c)) {
|
||||
re += '\\' + c;
|
||||
i += 1;
|
||||
} else {
|
||||
re += c;
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
return new RegExp('^' + re + '$');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Core operations
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -120,6 +221,14 @@ function validateConfig(cfg) {
|
||||
if (!cfg.files.every((f) => typeof f === 'string' && f.length > 0)) {
|
||||
throw new Error('config.files must contain only non-empty strings');
|
||||
}
|
||||
if (cfg.exclude !== undefined) {
|
||||
if (!Array.isArray(cfg.exclude)) {
|
||||
throw new Error('config.exclude, if present, must be a string array');
|
||||
}
|
||||
if (!cfg.exclude.every((f) => typeof f === 'string' && f.length > 0)) {
|
||||
throw new Error('config.exclude must contain only non-empty strings');
|
||||
}
|
||||
}
|
||||
if (typeof cfg.insertBefore !== 'string' && typeof cfg.insertAfter !== 'string') {
|
||||
throw new Error('config.insertBefore or config.insertAfter (string) required');
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { loadContext } from './load-context.mjs';
|
||||
import { resolveFiles } from './live-inject.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const PID_FILE = path.join(process.cwd(), '.impeccable-live.json');
|
||||
@@ -82,12 +83,19 @@ The agent should then:
|
||||
// 4. Load PRODUCT.md + DESIGN.md context (auto-migrates legacy .impeccable.md)
|
||||
const ctx = loadContext(process.cwd());
|
||||
|
||||
// 5. Emit everything the agent needs
|
||||
// 5. Compute drift-heal: compare resolved inject targets against the
|
||||
// project's HTML files. Orphans are HTML files not covered by config.
|
||||
// Warning only — the agent decides whether to act.
|
||||
const resolvedFiles = resolveFiles(process.cwd(), checkResult.config);
|
||||
const drift = scanForDrift(process.cwd(), resolvedFiles, checkResult.config);
|
||||
|
||||
// 6. Emit everything the agent needs
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
serverPort: serverInfo.port,
|
||||
serverToken: serverInfo.token,
|
||||
pageFiles: checkResult.config.files,
|
||||
pageFiles: resolvedFiles,
|
||||
configDrift: drift,
|
||||
hasProduct: ctx.hasProduct,
|
||||
product: ctx.product,
|
||||
productPath: ctx.productPath,
|
||||
@@ -98,6 +106,98 @@ The agent should then:
|
||||
}, null, 2));
|
||||
}
|
||||
|
||||
/**
|
||||
* Drift-heal scan. Walks the project for HTML files under common
|
||||
* page-source directories (public/, src/, app/, pages/) and reports any
|
||||
* that aren't covered by the resolved inject targets. This is purely
|
||||
* advisory — the agent can ignore it, or suggest the user add the
|
||||
* orphans to config.files.
|
||||
*
|
||||
* Skipped if config.files already contains at least one glob pattern
|
||||
* covering everything in practice (signaled by the orphan count being 0).
|
||||
*/
|
||||
function scanForDrift(rootDir, resolvedFiles, config) {
|
||||
const SCAN_ROOTS = ['public', 'src', 'app', 'pages'];
|
||||
const IGNORE_DIRS = new Set([
|
||||
'node_modules', '.git', '.next', '.nuxt', '.svelte-kit', '.astro',
|
||||
'.turbo', '.vercel', '.cache', 'coverage', 'dist', 'build',
|
||||
]);
|
||||
|
||||
const resolvedSet = new Set(resolvedFiles.map((f) => f.split(path.sep).join('/')));
|
||||
|
||||
// Files matching the user's `exclude` globs are intentional omissions,
|
||||
// not drift. Compile them to regexes so the orphan list stays signal.
|
||||
const userExcludeRegexes = (Array.isArray(config.exclude) ? config.exclude : [])
|
||||
.map((p) => globToRegex(p));
|
||||
const isUserExcluded = (rel) => userExcludeRegexes.some((re) => re.test(rel));
|
||||
|
||||
const orphans = [];
|
||||
|
||||
const walk = (dir, relBase) => {
|
||||
let entries;
|
||||
try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
|
||||
catch { return; }
|
||||
for (const e of entries) {
|
||||
const rel = relBase ? `${relBase}/${e.name}` : e.name;
|
||||
if (e.isDirectory()) {
|
||||
if (IGNORE_DIRS.has(e.name) || e.name.startsWith('.')) continue;
|
||||
walk(path.join(dir, e.name), rel);
|
||||
} else if (e.isFile() && e.name.endsWith('.html')) {
|
||||
if (resolvedSet.has(rel)) continue;
|
||||
if (isUserExcluded(rel)) continue;
|
||||
orphans.push(rel);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
for (const root of SCAN_ROOTS) {
|
||||
const abs = path.join(rootDir, root);
|
||||
if (fs.existsSync(abs) && fs.statSync(abs).isDirectory()) {
|
||||
walk(abs, root);
|
||||
}
|
||||
}
|
||||
|
||||
if (orphans.length === 0) return null;
|
||||
const capped = orphans.slice(0, 20);
|
||||
return {
|
||||
orphans: capped,
|
||||
orphanCount: orphans.length,
|
||||
hint: `${orphans.length} HTML file(s) exist but aren't in config.files. Consider adding them, or use a glob pattern like "public/**/*.html".`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Same glob-to-regex mapping used by live-inject.mjs. Kept inline here
|
||||
* to avoid a circular import (live-inject.mjs already imports nothing
|
||||
* from live.mjs). The two must stay in sync.
|
||||
*/
|
||||
function globToRegex(pattern) {
|
||||
let re = '';
|
||||
let i = 0;
|
||||
while (i < pattern.length) {
|
||||
const c = pattern[i];
|
||||
if (c === '*') {
|
||||
if (pattern[i + 1] === '*') {
|
||||
if (pattern[i + 2] === '/') { re += '(?:.*/)?'; i += 3; }
|
||||
else { re += '.*'; i += 2; }
|
||||
} else {
|
||||
re += '[^/]*';
|
||||
i += 1;
|
||||
}
|
||||
} else if (c === '?') {
|
||||
re += '[^/]';
|
||||
i += 1;
|
||||
} else if (/[.+^${}()|[\]\\]/.test(c)) {
|
||||
re += '\\' + c;
|
||||
i += 1;
|
||||
} else {
|
||||
re += c;
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
return new RegExp('^' + re + '$');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -351,16 +351,23 @@ Schema:
|
||||
|
||||
```json
|
||||
{
|
||||
"files": ["<path>", "<path>", ...],
|
||||
"files": ["<path-or-glob>", "<path-or-glob>", ...],
|
||||
"exclude": ["<optional-glob>", ...],
|
||||
"insertBefore": "</body>",
|
||||
"commentSyntax": "html",
|
||||
"cspChecked": true
|
||||
}
|
||||
```
|
||||
|
||||
`files` is the inject target — **the HTML files the browser actually loads**, not necessarily source. Each entry is either a literal path (`"public/index.html"`) or a glob pattern (`"public/**/*.html"`). Tracked or generated doesn't matter here; wrap has its own generated-file guard and routes accepts through the fallback flow.
|
||||
|
||||
`exclude` (optional) is a list of glob patterns matching files to skip, even if a `files` glob would have included them. Use for email templates, demo fixtures, or any HTML that isn't a live page.
|
||||
|
||||
`cspChecked` tracks whether the CSP detection step below has already run. Absent on first setup; set to `true` after CSP is checked (whether patched, declined, or not needed).
|
||||
|
||||
`files` is the inject target — **the HTML files the browser actually loads**, not necessarily source. Tracked or generated doesn't matter here; wrap has its own generated-file guard and routes accepts through the fallback flow.
|
||||
**Hard-excluded paths (cannot be overridden).** `**/node_modules/**` and `**/.git/**` are never matched regardless of what the user writes. These are vendor/metadata directories and injecting into them would silently instrument third-party code.
|
||||
|
||||
**Glob syntax.** `**` matches any number of path segments (including zero), `*` matches any characters except `/`, `?` matches a single character except `/`. Paths are always relative to the project root with forward slashes.
|
||||
|
||||
| Framework | `files` | `insertBefore` | `commentSyntax` |
|
||||
|-----------|---------|----------------|-----------------|
|
||||
@@ -370,12 +377,42 @@ Schema:
|
||||
| Nuxt | `["app.vue"]` | `</body>` | `html` |
|
||||
| Svelte / SvelteKit | `["src/app.html"]` | `</body>` | `html` |
|
||||
| Astro | `[" <root layout .astro>"]` | `</body>` | `html` |
|
||||
| Multi-page (separate HTML per route) | Every HTML file the dev server serves — glob the output dir, e.g. `public/**/*.html` | `</body>` | `html` |
|
||||
| Multi-page (separate HTML per route) | `["public/**/*.html"]` — a glob covering the served directory | `</body>` | `html` |
|
||||
|
||||
Pick an anchor that exists in every file (`</body>` almost always works). Use `insertAfter` if the anchor should match **after** a specific line.
|
||||
|
||||
For multi-page sites, **prefer a glob over a literal file list**. New pages added later are picked up automatically on the next `live-inject.mjs` run; no config maintenance needed.
|
||||
|
||||
For multi-page sites whose pages are *rebuilt* by a generator (Astro, static-site generators, custom scripts like `build-sub-pages.js`), the inject survives only until the next regeneration. Re-run `live.mjs` after each build. Accept is unaffected — it writes to true source via the fallback flow.
|
||||
|
||||
### Drift-heal warning
|
||||
|
||||
On every `live.mjs` boot, after inject, the project is scanned for HTML files under common page-source roots (`public/`, `src/`, `app/`, `pages/`). If any exist that aren't covered by the resolved `files` list, the output includes a `configDrift` field:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"serverPort": 8400,
|
||||
"pageFiles": [ "..." ],
|
||||
"configDrift": {
|
||||
"orphans": ["public/new-section/index.html", "public/docs/new-command.html"],
|
||||
"orphanCount": 2,
|
||||
"hint": "2 HTML file(s) exist but aren't in config.files. Consider adding them, or use a glob pattern like \"public/**/*.html\"."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
When `configDrift` is present, surface it to the user once per session before entering the poll loop:
|
||||
|
||||
> Noticed N HTML file(s) in the project that aren't in `config.files`:
|
||||
>
|
||||
> - `public/new-section/index.html`
|
||||
> - `public/docs/new-command.html`
|
||||
>
|
||||
> Add them, or switch `files` to a glob like `["public/**/*.html"]` and let it track new pages automatically?
|
||||
|
||||
Don't auto-update the config — let the user decide. `configDrift` is `null` when there's no drift.
|
||||
|
||||
### CSP detection (first-time only)
|
||||
|
||||
If `config.cspChecked === true`, skip this entire section. You already asked this user once; the answer sticks.
|
||||
|
||||
@@ -22,6 +22,16 @@ const CONFIG_PATH = process.env.IMPECCABLE_LIVE_CONFIG || path.join(__dirname, '
|
||||
const MARKER_OPEN_TEXT = 'impeccable-live-start';
|
||||
const MARKER_CLOSE_TEXT = 'impeccable-live-end';
|
||||
|
||||
/**
|
||||
* Hard-excluded directory patterns. These are NEVER user-facing pages and
|
||||
* matching them would silently inject tracking scripts into third-party
|
||||
* code. The user cannot turn these off via config — they are the floor.
|
||||
*/
|
||||
const HARD_EXCLUDES = [
|
||||
'**/node_modules/**',
|
||||
'**/.git/**',
|
||||
];
|
||||
|
||||
export async function injectCli() {
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
@@ -71,8 +81,10 @@ Output (JSON):
|
||||
const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
|
||||
validateConfig(config);
|
||||
|
||||
const resolvedFiles = resolveFiles(process.cwd(), config);
|
||||
|
||||
if (args.includes('--remove')) {
|
||||
const results = config.files.map((relFile) => {
|
||||
const results = resolvedFiles.map((relFile) => {
|
||||
const absFile = path.resolve(process.cwd(), relFile);
|
||||
if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' };
|
||||
const content = fs.readFileSync(absFile, 'utf-8');
|
||||
@@ -93,7 +105,7 @@ Output (JSON):
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const results = config.files.map((relFile) => {
|
||||
const results = resolvedFiles.map((relFile) => {
|
||||
const absFile = path.resolve(process.cwd(), relFile);
|
||||
if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' };
|
||||
const content = fs.readFileSync(absFile, 'utf-8');
|
||||
@@ -108,6 +120,95 @@ Output (JSON):
|
||||
if (!anyInserted) process.exit(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand config.files (which may contain glob patterns) into a literal list
|
||||
* of existing file paths relative to rootDir. Literal entries pass through;
|
||||
* glob patterns are expanded via fs.globSync. HARD_EXCLUDES and config.exclude
|
||||
* are applied as filters. Duplicates are removed. Order is preserved by
|
||||
* first appearance.
|
||||
*/
|
||||
export function resolveFiles(rootDir, config) {
|
||||
const patterns = config.files;
|
||||
const userExcludes = Array.isArray(config.exclude) ? config.exclude : [];
|
||||
const allExcludes = [...HARD_EXCLUDES, ...userExcludes];
|
||||
const excludeRegexes = allExcludes.map(globToRegex);
|
||||
|
||||
const isExcluded = (relPath) => excludeRegexes.some((re) => re.test(relPath));
|
||||
const isGlob = (s) => /[*?[]/.test(s);
|
||||
|
||||
const seen = new Set();
|
||||
const out = [];
|
||||
for (const pat of patterns) {
|
||||
if (!isGlob(pat)) {
|
||||
// Literal path — include even if it doesn't exist yet; the caller
|
||||
// reports file_not_found per-entry. Exclude list doesn't apply to
|
||||
// explicit literal entries (user named it on purpose).
|
||||
if (!seen.has(pat)) {
|
||||
seen.add(pat);
|
||||
out.push(pat);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let matches;
|
||||
try {
|
||||
matches = fs.globSync(pat, { cwd: rootDir, withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const ent of matches) {
|
||||
if (!ent.isFile || !ent.isFile()) continue;
|
||||
const abs = path.join(ent.parentPath || ent.path || rootDir, ent.name);
|
||||
const rel = path.relative(rootDir, abs).split(path.sep).join('/');
|
||||
if (isExcluded(rel)) continue;
|
||||
if (seen.has(rel)) continue;
|
||||
seen.add(rel);
|
||||
out.push(rel);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a glob pattern to a RegExp. Supports:
|
||||
* ** → any number of path segments (including zero)
|
||||
* * → any chars except `/`
|
||||
* ? → any single char except `/`
|
||||
* Paths are normalized to forward slashes before matching.
|
||||
*/
|
||||
function globToRegex(pattern) {
|
||||
let re = '';
|
||||
let i = 0;
|
||||
while (i < pattern.length) {
|
||||
const c = pattern[i];
|
||||
if (c === '*') {
|
||||
if (pattern[i + 1] === '*') {
|
||||
// ** — any number of segments, including zero. Handle the common
|
||||
// **/ and /** forms so `a/**/b` matches `a/b` as well as `a/x/y/b`.
|
||||
if (pattern[i + 2] === '/') {
|
||||
re += '(?:.*/)?';
|
||||
i += 3;
|
||||
} else {
|
||||
re += '.*';
|
||||
i += 2;
|
||||
}
|
||||
} else {
|
||||
re += '[^/]*';
|
||||
i += 1;
|
||||
}
|
||||
} else if (c === '?') {
|
||||
re += '[^/]';
|
||||
i += 1;
|
||||
} else if (/[.+^${}()|[\]\\]/.test(c)) {
|
||||
re += '\\' + c;
|
||||
i += 1;
|
||||
} else {
|
||||
re += c;
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
return new RegExp('^' + re + '$');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Core operations
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -120,6 +221,14 @@ function validateConfig(cfg) {
|
||||
if (!cfg.files.every((f) => typeof f === 'string' && f.length > 0)) {
|
||||
throw new Error('config.files must contain only non-empty strings');
|
||||
}
|
||||
if (cfg.exclude !== undefined) {
|
||||
if (!Array.isArray(cfg.exclude)) {
|
||||
throw new Error('config.exclude, if present, must be a string array');
|
||||
}
|
||||
if (!cfg.exclude.every((f) => typeof f === 'string' && f.length > 0)) {
|
||||
throw new Error('config.exclude must contain only non-empty strings');
|
||||
}
|
||||
}
|
||||
if (typeof cfg.insertBefore !== 'string' && typeof cfg.insertAfter !== 'string') {
|
||||
throw new Error('config.insertBefore or config.insertAfter (string) required');
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { loadContext } from './load-context.mjs';
|
||||
import { resolveFiles } from './live-inject.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const PID_FILE = path.join(process.cwd(), '.impeccable-live.json');
|
||||
@@ -82,12 +83,19 @@ The agent should then:
|
||||
// 4. Load PRODUCT.md + DESIGN.md context (auto-migrates legacy .impeccable.md)
|
||||
const ctx = loadContext(process.cwd());
|
||||
|
||||
// 5. Emit everything the agent needs
|
||||
// 5. Compute drift-heal: compare resolved inject targets against the
|
||||
// project's HTML files. Orphans are HTML files not covered by config.
|
||||
// Warning only — the agent decides whether to act.
|
||||
const resolvedFiles = resolveFiles(process.cwd(), checkResult.config);
|
||||
const drift = scanForDrift(process.cwd(), resolvedFiles, checkResult.config);
|
||||
|
||||
// 6. Emit everything the agent needs
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
serverPort: serverInfo.port,
|
||||
serverToken: serverInfo.token,
|
||||
pageFiles: checkResult.config.files,
|
||||
pageFiles: resolvedFiles,
|
||||
configDrift: drift,
|
||||
hasProduct: ctx.hasProduct,
|
||||
product: ctx.product,
|
||||
productPath: ctx.productPath,
|
||||
@@ -98,6 +106,98 @@ The agent should then:
|
||||
}, null, 2));
|
||||
}
|
||||
|
||||
/**
|
||||
* Drift-heal scan. Walks the project for HTML files under common
|
||||
* page-source directories (public/, src/, app/, pages/) and reports any
|
||||
* that aren't covered by the resolved inject targets. This is purely
|
||||
* advisory — the agent can ignore it, or suggest the user add the
|
||||
* orphans to config.files.
|
||||
*
|
||||
* Skipped if config.files already contains at least one glob pattern
|
||||
* covering everything in practice (signaled by the orphan count being 0).
|
||||
*/
|
||||
function scanForDrift(rootDir, resolvedFiles, config) {
|
||||
const SCAN_ROOTS = ['public', 'src', 'app', 'pages'];
|
||||
const IGNORE_DIRS = new Set([
|
||||
'node_modules', '.git', '.next', '.nuxt', '.svelte-kit', '.astro',
|
||||
'.turbo', '.vercel', '.cache', 'coverage', 'dist', 'build',
|
||||
]);
|
||||
|
||||
const resolvedSet = new Set(resolvedFiles.map((f) => f.split(path.sep).join('/')));
|
||||
|
||||
// Files matching the user's `exclude` globs are intentional omissions,
|
||||
// not drift. Compile them to regexes so the orphan list stays signal.
|
||||
const userExcludeRegexes = (Array.isArray(config.exclude) ? config.exclude : [])
|
||||
.map((p) => globToRegex(p));
|
||||
const isUserExcluded = (rel) => userExcludeRegexes.some((re) => re.test(rel));
|
||||
|
||||
const orphans = [];
|
||||
|
||||
const walk = (dir, relBase) => {
|
||||
let entries;
|
||||
try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
|
||||
catch { return; }
|
||||
for (const e of entries) {
|
||||
const rel = relBase ? `${relBase}/${e.name}` : e.name;
|
||||
if (e.isDirectory()) {
|
||||
if (IGNORE_DIRS.has(e.name) || e.name.startsWith('.')) continue;
|
||||
walk(path.join(dir, e.name), rel);
|
||||
} else if (e.isFile() && e.name.endsWith('.html')) {
|
||||
if (resolvedSet.has(rel)) continue;
|
||||
if (isUserExcluded(rel)) continue;
|
||||
orphans.push(rel);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
for (const root of SCAN_ROOTS) {
|
||||
const abs = path.join(rootDir, root);
|
||||
if (fs.existsSync(abs) && fs.statSync(abs).isDirectory()) {
|
||||
walk(abs, root);
|
||||
}
|
||||
}
|
||||
|
||||
if (orphans.length === 0) return null;
|
||||
const capped = orphans.slice(0, 20);
|
||||
return {
|
||||
orphans: capped,
|
||||
orphanCount: orphans.length,
|
||||
hint: `${orphans.length} HTML file(s) exist but aren't in config.files. Consider adding them, or use a glob pattern like "public/**/*.html".`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Same glob-to-regex mapping used by live-inject.mjs. Kept inline here
|
||||
* to avoid a circular import (live-inject.mjs already imports nothing
|
||||
* from live.mjs). The two must stay in sync.
|
||||
*/
|
||||
function globToRegex(pattern) {
|
||||
let re = '';
|
||||
let i = 0;
|
||||
while (i < pattern.length) {
|
||||
const c = pattern[i];
|
||||
if (c === '*') {
|
||||
if (pattern[i + 1] === '*') {
|
||||
if (pattern[i + 2] === '/') { re += '(?:.*/)?'; i += 3; }
|
||||
else { re += '.*'; i += 2; }
|
||||
} else {
|
||||
re += '[^/]*';
|
||||
i += 1;
|
||||
}
|
||||
} else if (c === '?') {
|
||||
re += '[^/]';
|
||||
i += 1;
|
||||
} else if (/[.+^${}()|[\]\\]/.test(c)) {
|
||||
re += '\\' + c;
|
||||
i += 1;
|
||||
} else {
|
||||
re += c;
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
return new RegExp('^' + re + '$');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -351,16 +351,23 @@ Schema:
|
||||
|
||||
```json
|
||||
{
|
||||
"files": ["<path>", "<path>", ...],
|
||||
"files": ["<path-or-glob>", "<path-or-glob>", ...],
|
||||
"exclude": ["<optional-glob>", ...],
|
||||
"insertBefore": "</body>",
|
||||
"commentSyntax": "html",
|
||||
"cspChecked": true
|
||||
}
|
||||
```
|
||||
|
||||
`files` is the inject target — **the HTML files the browser actually loads**, not necessarily source. Each entry is either a literal path (`"public/index.html"`) or a glob pattern (`"public/**/*.html"`). Tracked or generated doesn't matter here; wrap has its own generated-file guard and routes accepts through the fallback flow.
|
||||
|
||||
`exclude` (optional) is a list of glob patterns matching files to skip, even if a `files` glob would have included them. Use for email templates, demo fixtures, or any HTML that isn't a live page.
|
||||
|
||||
`cspChecked` tracks whether the CSP detection step below has already run. Absent on first setup; set to `true` after CSP is checked (whether patched, declined, or not needed).
|
||||
|
||||
`files` is the inject target — **the HTML files the browser actually loads**, not necessarily source. Tracked or generated doesn't matter here; wrap has its own generated-file guard and routes accepts through the fallback flow.
|
||||
**Hard-excluded paths (cannot be overridden).** `**/node_modules/**` and `**/.git/**` are never matched regardless of what the user writes. These are vendor/metadata directories and injecting into them would silently instrument third-party code.
|
||||
|
||||
**Glob syntax.** `**` matches any number of path segments (including zero), `*` matches any characters except `/`, `?` matches a single character except `/`. Paths are always relative to the project root with forward slashes.
|
||||
|
||||
| Framework | `files` | `insertBefore` | `commentSyntax` |
|
||||
|-----------|---------|----------------|-----------------|
|
||||
@@ -370,12 +377,42 @@ Schema:
|
||||
| Nuxt | `["app.vue"]` | `</body>` | `html` |
|
||||
| Svelte / SvelteKit | `["src/app.html"]` | `</body>` | `html` |
|
||||
| Astro | `[" <root layout .astro>"]` | `</body>` | `html` |
|
||||
| Multi-page (separate HTML per route) | Every HTML file the dev server serves — glob the output dir, e.g. `public/**/*.html` | `</body>` | `html` |
|
||||
| Multi-page (separate HTML per route) | `["public/**/*.html"]` — a glob covering the served directory | `</body>` | `html` |
|
||||
|
||||
Pick an anchor that exists in every file (`</body>` almost always works). Use `insertAfter` if the anchor should match **after** a specific line.
|
||||
|
||||
For multi-page sites, **prefer a glob over a literal file list**. New pages added later are picked up automatically on the next `live-inject.mjs` run; no config maintenance needed.
|
||||
|
||||
For multi-page sites whose pages are *rebuilt* by a generator (Astro, static-site generators, custom scripts like `build-sub-pages.js`), the inject survives only until the next regeneration. Re-run `live.mjs` after each build. Accept is unaffected — it writes to true source via the fallback flow.
|
||||
|
||||
### Drift-heal warning
|
||||
|
||||
On every `live.mjs` boot, after inject, the project is scanned for HTML files under common page-source roots (`public/`, `src/`, `app/`, `pages/`). If any exist that aren't covered by the resolved `files` list, the output includes a `configDrift` field:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"serverPort": 8400,
|
||||
"pageFiles": [ "..." ],
|
||||
"configDrift": {
|
||||
"orphans": ["public/new-section/index.html", "public/docs/new-command.html"],
|
||||
"orphanCount": 2,
|
||||
"hint": "2 HTML file(s) exist but aren't in config.files. Consider adding them, or use a glob pattern like \"public/**/*.html\"."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
When `configDrift` is present, surface it to the user once per session before entering the poll loop:
|
||||
|
||||
> Noticed N HTML file(s) in the project that aren't in `config.files`:
|
||||
>
|
||||
> - `public/new-section/index.html`
|
||||
> - `public/docs/new-command.html`
|
||||
>
|
||||
> Add them, or switch `files` to a glob like `["public/**/*.html"]` and let it track new pages automatically?
|
||||
|
||||
Don't auto-update the config — let the user decide. `configDrift` is `null` when there's no drift.
|
||||
|
||||
### CSP detection (first-time only)
|
||||
|
||||
If `config.cspChecked === true`, skip this entire section. You already asked this user once; the answer sticks.
|
||||
|
||||
@@ -22,6 +22,16 @@ const CONFIG_PATH = process.env.IMPECCABLE_LIVE_CONFIG || path.join(__dirname, '
|
||||
const MARKER_OPEN_TEXT = 'impeccable-live-start';
|
||||
const MARKER_CLOSE_TEXT = 'impeccable-live-end';
|
||||
|
||||
/**
|
||||
* Hard-excluded directory patterns. These are NEVER user-facing pages and
|
||||
* matching them would silently inject tracking scripts into third-party
|
||||
* code. The user cannot turn these off via config — they are the floor.
|
||||
*/
|
||||
const HARD_EXCLUDES = [
|
||||
'**/node_modules/**',
|
||||
'**/.git/**',
|
||||
];
|
||||
|
||||
export async function injectCli() {
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
@@ -71,8 +81,10 @@ Output (JSON):
|
||||
const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
|
||||
validateConfig(config);
|
||||
|
||||
const resolvedFiles = resolveFiles(process.cwd(), config);
|
||||
|
||||
if (args.includes('--remove')) {
|
||||
const results = config.files.map((relFile) => {
|
||||
const results = resolvedFiles.map((relFile) => {
|
||||
const absFile = path.resolve(process.cwd(), relFile);
|
||||
if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' };
|
||||
const content = fs.readFileSync(absFile, 'utf-8');
|
||||
@@ -93,7 +105,7 @@ Output (JSON):
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const results = config.files.map((relFile) => {
|
||||
const results = resolvedFiles.map((relFile) => {
|
||||
const absFile = path.resolve(process.cwd(), relFile);
|
||||
if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' };
|
||||
const content = fs.readFileSync(absFile, 'utf-8');
|
||||
@@ -108,6 +120,95 @@ Output (JSON):
|
||||
if (!anyInserted) process.exit(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand config.files (which may contain glob patterns) into a literal list
|
||||
* of existing file paths relative to rootDir. Literal entries pass through;
|
||||
* glob patterns are expanded via fs.globSync. HARD_EXCLUDES and config.exclude
|
||||
* are applied as filters. Duplicates are removed. Order is preserved by
|
||||
* first appearance.
|
||||
*/
|
||||
export function resolveFiles(rootDir, config) {
|
||||
const patterns = config.files;
|
||||
const userExcludes = Array.isArray(config.exclude) ? config.exclude : [];
|
||||
const allExcludes = [...HARD_EXCLUDES, ...userExcludes];
|
||||
const excludeRegexes = allExcludes.map(globToRegex);
|
||||
|
||||
const isExcluded = (relPath) => excludeRegexes.some((re) => re.test(relPath));
|
||||
const isGlob = (s) => /[*?[]/.test(s);
|
||||
|
||||
const seen = new Set();
|
||||
const out = [];
|
||||
for (const pat of patterns) {
|
||||
if (!isGlob(pat)) {
|
||||
// Literal path — include even if it doesn't exist yet; the caller
|
||||
// reports file_not_found per-entry. Exclude list doesn't apply to
|
||||
// explicit literal entries (user named it on purpose).
|
||||
if (!seen.has(pat)) {
|
||||
seen.add(pat);
|
||||
out.push(pat);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let matches;
|
||||
try {
|
||||
matches = fs.globSync(pat, { cwd: rootDir, withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const ent of matches) {
|
||||
if (!ent.isFile || !ent.isFile()) continue;
|
||||
const abs = path.join(ent.parentPath || ent.path || rootDir, ent.name);
|
||||
const rel = path.relative(rootDir, abs).split(path.sep).join('/');
|
||||
if (isExcluded(rel)) continue;
|
||||
if (seen.has(rel)) continue;
|
||||
seen.add(rel);
|
||||
out.push(rel);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a glob pattern to a RegExp. Supports:
|
||||
* ** → any number of path segments (including zero)
|
||||
* * → any chars except `/`
|
||||
* ? → any single char except `/`
|
||||
* Paths are normalized to forward slashes before matching.
|
||||
*/
|
||||
function globToRegex(pattern) {
|
||||
let re = '';
|
||||
let i = 0;
|
||||
while (i < pattern.length) {
|
||||
const c = pattern[i];
|
||||
if (c === '*') {
|
||||
if (pattern[i + 1] === '*') {
|
||||
// ** — any number of segments, including zero. Handle the common
|
||||
// **/ and /** forms so `a/**/b` matches `a/b` as well as `a/x/y/b`.
|
||||
if (pattern[i + 2] === '/') {
|
||||
re += '(?:.*/)?';
|
||||
i += 3;
|
||||
} else {
|
||||
re += '.*';
|
||||
i += 2;
|
||||
}
|
||||
} else {
|
||||
re += '[^/]*';
|
||||
i += 1;
|
||||
}
|
||||
} else if (c === '?') {
|
||||
re += '[^/]';
|
||||
i += 1;
|
||||
} else if (/[.+^${}()|[\]\\]/.test(c)) {
|
||||
re += '\\' + c;
|
||||
i += 1;
|
||||
} else {
|
||||
re += c;
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
return new RegExp('^' + re + '$');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Core operations
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -120,6 +221,14 @@ function validateConfig(cfg) {
|
||||
if (!cfg.files.every((f) => typeof f === 'string' && f.length > 0)) {
|
||||
throw new Error('config.files must contain only non-empty strings');
|
||||
}
|
||||
if (cfg.exclude !== undefined) {
|
||||
if (!Array.isArray(cfg.exclude)) {
|
||||
throw new Error('config.exclude, if present, must be a string array');
|
||||
}
|
||||
if (!cfg.exclude.every((f) => typeof f === 'string' && f.length > 0)) {
|
||||
throw new Error('config.exclude must contain only non-empty strings');
|
||||
}
|
||||
}
|
||||
if (typeof cfg.insertBefore !== 'string' && typeof cfg.insertAfter !== 'string') {
|
||||
throw new Error('config.insertBefore or config.insertAfter (string) required');
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { loadContext } from './load-context.mjs';
|
||||
import { resolveFiles } from './live-inject.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const PID_FILE = path.join(process.cwd(), '.impeccable-live.json');
|
||||
@@ -82,12 +83,19 @@ The agent should then:
|
||||
// 4. Load PRODUCT.md + DESIGN.md context (auto-migrates legacy .impeccable.md)
|
||||
const ctx = loadContext(process.cwd());
|
||||
|
||||
// 5. Emit everything the agent needs
|
||||
// 5. Compute drift-heal: compare resolved inject targets against the
|
||||
// project's HTML files. Orphans are HTML files not covered by config.
|
||||
// Warning only — the agent decides whether to act.
|
||||
const resolvedFiles = resolveFiles(process.cwd(), checkResult.config);
|
||||
const drift = scanForDrift(process.cwd(), resolvedFiles, checkResult.config);
|
||||
|
||||
// 6. Emit everything the agent needs
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
serverPort: serverInfo.port,
|
||||
serverToken: serverInfo.token,
|
||||
pageFiles: checkResult.config.files,
|
||||
pageFiles: resolvedFiles,
|
||||
configDrift: drift,
|
||||
hasProduct: ctx.hasProduct,
|
||||
product: ctx.product,
|
||||
productPath: ctx.productPath,
|
||||
@@ -98,6 +106,98 @@ The agent should then:
|
||||
}, null, 2));
|
||||
}
|
||||
|
||||
/**
|
||||
* Drift-heal scan. Walks the project for HTML files under common
|
||||
* page-source directories (public/, src/, app/, pages/) and reports any
|
||||
* that aren't covered by the resolved inject targets. This is purely
|
||||
* advisory — the agent can ignore it, or suggest the user add the
|
||||
* orphans to config.files.
|
||||
*
|
||||
* Skipped if config.files already contains at least one glob pattern
|
||||
* covering everything in practice (signaled by the orphan count being 0).
|
||||
*/
|
||||
function scanForDrift(rootDir, resolvedFiles, config) {
|
||||
const SCAN_ROOTS = ['public', 'src', 'app', 'pages'];
|
||||
const IGNORE_DIRS = new Set([
|
||||
'node_modules', '.git', '.next', '.nuxt', '.svelte-kit', '.astro',
|
||||
'.turbo', '.vercel', '.cache', 'coverage', 'dist', 'build',
|
||||
]);
|
||||
|
||||
const resolvedSet = new Set(resolvedFiles.map((f) => f.split(path.sep).join('/')));
|
||||
|
||||
// Files matching the user's `exclude` globs are intentional omissions,
|
||||
// not drift. Compile them to regexes so the orphan list stays signal.
|
||||
const userExcludeRegexes = (Array.isArray(config.exclude) ? config.exclude : [])
|
||||
.map((p) => globToRegex(p));
|
||||
const isUserExcluded = (rel) => userExcludeRegexes.some((re) => re.test(rel));
|
||||
|
||||
const orphans = [];
|
||||
|
||||
const walk = (dir, relBase) => {
|
||||
let entries;
|
||||
try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
|
||||
catch { return; }
|
||||
for (const e of entries) {
|
||||
const rel = relBase ? `${relBase}/${e.name}` : e.name;
|
||||
if (e.isDirectory()) {
|
||||
if (IGNORE_DIRS.has(e.name) || e.name.startsWith('.')) continue;
|
||||
walk(path.join(dir, e.name), rel);
|
||||
} else if (e.isFile() && e.name.endsWith('.html')) {
|
||||
if (resolvedSet.has(rel)) continue;
|
||||
if (isUserExcluded(rel)) continue;
|
||||
orphans.push(rel);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
for (const root of SCAN_ROOTS) {
|
||||
const abs = path.join(rootDir, root);
|
||||
if (fs.existsSync(abs) && fs.statSync(abs).isDirectory()) {
|
||||
walk(abs, root);
|
||||
}
|
||||
}
|
||||
|
||||
if (orphans.length === 0) return null;
|
||||
const capped = orphans.slice(0, 20);
|
||||
return {
|
||||
orphans: capped,
|
||||
orphanCount: orphans.length,
|
||||
hint: `${orphans.length} HTML file(s) exist but aren't in config.files. Consider adding them, or use a glob pattern like "public/**/*.html".`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Same glob-to-regex mapping used by live-inject.mjs. Kept inline here
|
||||
* to avoid a circular import (live-inject.mjs already imports nothing
|
||||
* from live.mjs). The two must stay in sync.
|
||||
*/
|
||||
function globToRegex(pattern) {
|
||||
let re = '';
|
||||
let i = 0;
|
||||
while (i < pattern.length) {
|
||||
const c = pattern[i];
|
||||
if (c === '*') {
|
||||
if (pattern[i + 1] === '*') {
|
||||
if (pattern[i + 2] === '/') { re += '(?:.*/)?'; i += 3; }
|
||||
else { re += '.*'; i += 2; }
|
||||
} else {
|
||||
re += '[^/]*';
|
||||
i += 1;
|
||||
}
|
||||
} else if (c === '?') {
|
||||
re += '[^/]';
|
||||
i += 1;
|
||||
} else if (/[.+^${}()|[\]\\]/.test(c)) {
|
||||
re += '\\' + c;
|
||||
i += 1;
|
||||
} else {
|
||||
re += c;
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
return new RegExp('^' + re + '$');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -351,16 +351,23 @@ Schema:
|
||||
|
||||
```json
|
||||
{
|
||||
"files": ["<path>", "<path>", ...],
|
||||
"files": ["<path-or-glob>", "<path-or-glob>", ...],
|
||||
"exclude": ["<optional-glob>", ...],
|
||||
"insertBefore": "</body>",
|
||||
"commentSyntax": "html",
|
||||
"cspChecked": true
|
||||
}
|
||||
```
|
||||
|
||||
`files` is the inject target — **the HTML files the browser actually loads**, not necessarily source. Each entry is either a literal path (`"public/index.html"`) or a glob pattern (`"public/**/*.html"`). Tracked or generated doesn't matter here; wrap has its own generated-file guard and routes accepts through the fallback flow.
|
||||
|
||||
`exclude` (optional) is a list of glob patterns matching files to skip, even if a `files` glob would have included them. Use for email templates, demo fixtures, or any HTML that isn't a live page.
|
||||
|
||||
`cspChecked` tracks whether the CSP detection step below has already run. Absent on first setup; set to `true` after CSP is checked (whether patched, declined, or not needed).
|
||||
|
||||
`files` is the inject target — **the HTML files the browser actually loads**, not necessarily source. Tracked or generated doesn't matter here; wrap has its own generated-file guard and routes accepts through the fallback flow.
|
||||
**Hard-excluded paths (cannot be overridden).** `**/node_modules/**` and `**/.git/**` are never matched regardless of what the user writes. These are vendor/metadata directories and injecting into them would silently instrument third-party code.
|
||||
|
||||
**Glob syntax.** `**` matches any number of path segments (including zero), `*` matches any characters except `/`, `?` matches a single character except `/`. Paths are always relative to the project root with forward slashes.
|
||||
|
||||
| Framework | `files` | `insertBefore` | `commentSyntax` |
|
||||
|-----------|---------|----------------|-----------------|
|
||||
@@ -370,12 +377,42 @@ Schema:
|
||||
| Nuxt | `["app.vue"]` | `</body>` | `html` |
|
||||
| Svelte / SvelteKit | `["src/app.html"]` | `</body>` | `html` |
|
||||
| Astro | `[" <root layout .astro>"]` | `</body>` | `html` |
|
||||
| Multi-page (separate HTML per route) | Every HTML file the dev server serves — glob the output dir, e.g. `public/**/*.html` | `</body>` | `html` |
|
||||
| Multi-page (separate HTML per route) | `["public/**/*.html"]` — a glob covering the served directory | `</body>` | `html` |
|
||||
|
||||
Pick an anchor that exists in every file (`</body>` almost always works). Use `insertAfter` if the anchor should match **after** a specific line.
|
||||
|
||||
For multi-page sites, **prefer a glob over a literal file list**. New pages added later are picked up automatically on the next `live-inject.mjs` run; no config maintenance needed.
|
||||
|
||||
For multi-page sites whose pages are *rebuilt* by a generator (Astro, static-site generators, custom scripts like `build-sub-pages.js`), the inject survives only until the next regeneration. Re-run `live.mjs` after each build. Accept is unaffected — it writes to true source via the fallback flow.
|
||||
|
||||
### Drift-heal warning
|
||||
|
||||
On every `live.mjs` boot, after inject, the project is scanned for HTML files under common page-source roots (`public/`, `src/`, `app/`, `pages/`). If any exist that aren't covered by the resolved `files` list, the output includes a `configDrift` field:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"serverPort": 8400,
|
||||
"pageFiles": [ "..." ],
|
||||
"configDrift": {
|
||||
"orphans": ["public/new-section/index.html", "public/docs/new-command.html"],
|
||||
"orphanCount": 2,
|
||||
"hint": "2 HTML file(s) exist but aren't in config.files. Consider adding them, or use a glob pattern like \"public/**/*.html\"."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
When `configDrift` is present, surface it to the user once per session before entering the poll loop:
|
||||
|
||||
> Noticed N HTML file(s) in the project that aren't in `config.files`:
|
||||
>
|
||||
> - `public/new-section/index.html`
|
||||
> - `public/docs/new-command.html`
|
||||
>
|
||||
> Add them, or switch `files` to a glob like `["public/**/*.html"]` and let it track new pages automatically?
|
||||
|
||||
Don't auto-update the config — let the user decide. `configDrift` is `null` when there's no drift.
|
||||
|
||||
### CSP detection (first-time only)
|
||||
|
||||
If `config.cspChecked === true`, skip this entire section. You already asked this user once; the answer sticks.
|
||||
|
||||
@@ -22,6 +22,16 @@ const CONFIG_PATH = process.env.IMPECCABLE_LIVE_CONFIG || path.join(__dirname, '
|
||||
const MARKER_OPEN_TEXT = 'impeccable-live-start';
|
||||
const MARKER_CLOSE_TEXT = 'impeccable-live-end';
|
||||
|
||||
/**
|
||||
* Hard-excluded directory patterns. These are NEVER user-facing pages and
|
||||
* matching them would silently inject tracking scripts into third-party
|
||||
* code. The user cannot turn these off via config — they are the floor.
|
||||
*/
|
||||
const HARD_EXCLUDES = [
|
||||
'**/node_modules/**',
|
||||
'**/.git/**',
|
||||
];
|
||||
|
||||
export async function injectCli() {
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
@@ -71,8 +81,10 @@ Output (JSON):
|
||||
const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
|
||||
validateConfig(config);
|
||||
|
||||
const resolvedFiles = resolveFiles(process.cwd(), config);
|
||||
|
||||
if (args.includes('--remove')) {
|
||||
const results = config.files.map((relFile) => {
|
||||
const results = resolvedFiles.map((relFile) => {
|
||||
const absFile = path.resolve(process.cwd(), relFile);
|
||||
if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' };
|
||||
const content = fs.readFileSync(absFile, 'utf-8');
|
||||
@@ -93,7 +105,7 @@ Output (JSON):
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const results = config.files.map((relFile) => {
|
||||
const results = resolvedFiles.map((relFile) => {
|
||||
const absFile = path.resolve(process.cwd(), relFile);
|
||||
if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' };
|
||||
const content = fs.readFileSync(absFile, 'utf-8');
|
||||
@@ -108,6 +120,95 @@ Output (JSON):
|
||||
if (!anyInserted) process.exit(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand config.files (which may contain glob patterns) into a literal list
|
||||
* of existing file paths relative to rootDir. Literal entries pass through;
|
||||
* glob patterns are expanded via fs.globSync. HARD_EXCLUDES and config.exclude
|
||||
* are applied as filters. Duplicates are removed. Order is preserved by
|
||||
* first appearance.
|
||||
*/
|
||||
export function resolveFiles(rootDir, config) {
|
||||
const patterns = config.files;
|
||||
const userExcludes = Array.isArray(config.exclude) ? config.exclude : [];
|
||||
const allExcludes = [...HARD_EXCLUDES, ...userExcludes];
|
||||
const excludeRegexes = allExcludes.map(globToRegex);
|
||||
|
||||
const isExcluded = (relPath) => excludeRegexes.some((re) => re.test(relPath));
|
||||
const isGlob = (s) => /[*?[]/.test(s);
|
||||
|
||||
const seen = new Set();
|
||||
const out = [];
|
||||
for (const pat of patterns) {
|
||||
if (!isGlob(pat)) {
|
||||
// Literal path — include even if it doesn't exist yet; the caller
|
||||
// reports file_not_found per-entry. Exclude list doesn't apply to
|
||||
// explicit literal entries (user named it on purpose).
|
||||
if (!seen.has(pat)) {
|
||||
seen.add(pat);
|
||||
out.push(pat);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let matches;
|
||||
try {
|
||||
matches = fs.globSync(pat, { cwd: rootDir, withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const ent of matches) {
|
||||
if (!ent.isFile || !ent.isFile()) continue;
|
||||
const abs = path.join(ent.parentPath || ent.path || rootDir, ent.name);
|
||||
const rel = path.relative(rootDir, abs).split(path.sep).join('/');
|
||||
if (isExcluded(rel)) continue;
|
||||
if (seen.has(rel)) continue;
|
||||
seen.add(rel);
|
||||
out.push(rel);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a glob pattern to a RegExp. Supports:
|
||||
* ** → any number of path segments (including zero)
|
||||
* * → any chars except `/`
|
||||
* ? → any single char except `/`
|
||||
* Paths are normalized to forward slashes before matching.
|
||||
*/
|
||||
function globToRegex(pattern) {
|
||||
let re = '';
|
||||
let i = 0;
|
||||
while (i < pattern.length) {
|
||||
const c = pattern[i];
|
||||
if (c === '*') {
|
||||
if (pattern[i + 1] === '*') {
|
||||
// ** — any number of segments, including zero. Handle the common
|
||||
// **/ and /** forms so `a/**/b` matches `a/b` as well as `a/x/y/b`.
|
||||
if (pattern[i + 2] === '/') {
|
||||
re += '(?:.*/)?';
|
||||
i += 3;
|
||||
} else {
|
||||
re += '.*';
|
||||
i += 2;
|
||||
}
|
||||
} else {
|
||||
re += '[^/]*';
|
||||
i += 1;
|
||||
}
|
||||
} else if (c === '?') {
|
||||
re += '[^/]';
|
||||
i += 1;
|
||||
} else if (/[.+^${}()|[\]\\]/.test(c)) {
|
||||
re += '\\' + c;
|
||||
i += 1;
|
||||
} else {
|
||||
re += c;
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
return new RegExp('^' + re + '$');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Core operations
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -120,6 +221,14 @@ function validateConfig(cfg) {
|
||||
if (!cfg.files.every((f) => typeof f === 'string' && f.length > 0)) {
|
||||
throw new Error('config.files must contain only non-empty strings');
|
||||
}
|
||||
if (cfg.exclude !== undefined) {
|
||||
if (!Array.isArray(cfg.exclude)) {
|
||||
throw new Error('config.exclude, if present, must be a string array');
|
||||
}
|
||||
if (!cfg.exclude.every((f) => typeof f === 'string' && f.length > 0)) {
|
||||
throw new Error('config.exclude must contain only non-empty strings');
|
||||
}
|
||||
}
|
||||
if (typeof cfg.insertBefore !== 'string' && typeof cfg.insertAfter !== 'string') {
|
||||
throw new Error('config.insertBefore or config.insertAfter (string) required');
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { loadContext } from './load-context.mjs';
|
||||
import { resolveFiles } from './live-inject.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const PID_FILE = path.join(process.cwd(), '.impeccable-live.json');
|
||||
@@ -82,12 +83,19 @@ The agent should then:
|
||||
// 4. Load PRODUCT.md + DESIGN.md context (auto-migrates legacy .impeccable.md)
|
||||
const ctx = loadContext(process.cwd());
|
||||
|
||||
// 5. Emit everything the agent needs
|
||||
// 5. Compute drift-heal: compare resolved inject targets against the
|
||||
// project's HTML files. Orphans are HTML files not covered by config.
|
||||
// Warning only — the agent decides whether to act.
|
||||
const resolvedFiles = resolveFiles(process.cwd(), checkResult.config);
|
||||
const drift = scanForDrift(process.cwd(), resolvedFiles, checkResult.config);
|
||||
|
||||
// 6. Emit everything the agent needs
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
serverPort: serverInfo.port,
|
||||
serverToken: serverInfo.token,
|
||||
pageFiles: checkResult.config.files,
|
||||
pageFiles: resolvedFiles,
|
||||
configDrift: drift,
|
||||
hasProduct: ctx.hasProduct,
|
||||
product: ctx.product,
|
||||
productPath: ctx.productPath,
|
||||
@@ -98,6 +106,98 @@ The agent should then:
|
||||
}, null, 2));
|
||||
}
|
||||
|
||||
/**
|
||||
* Drift-heal scan. Walks the project for HTML files under common
|
||||
* page-source directories (public/, src/, app/, pages/) and reports any
|
||||
* that aren't covered by the resolved inject targets. This is purely
|
||||
* advisory — the agent can ignore it, or suggest the user add the
|
||||
* orphans to config.files.
|
||||
*
|
||||
* Skipped if config.files already contains at least one glob pattern
|
||||
* covering everything in practice (signaled by the orphan count being 0).
|
||||
*/
|
||||
function scanForDrift(rootDir, resolvedFiles, config) {
|
||||
const SCAN_ROOTS = ['public', 'src', 'app', 'pages'];
|
||||
const IGNORE_DIRS = new Set([
|
||||
'node_modules', '.git', '.next', '.nuxt', '.svelte-kit', '.astro',
|
||||
'.turbo', '.vercel', '.cache', 'coverage', 'dist', 'build',
|
||||
]);
|
||||
|
||||
const resolvedSet = new Set(resolvedFiles.map((f) => f.split(path.sep).join('/')));
|
||||
|
||||
// Files matching the user's `exclude` globs are intentional omissions,
|
||||
// not drift. Compile them to regexes so the orphan list stays signal.
|
||||
const userExcludeRegexes = (Array.isArray(config.exclude) ? config.exclude : [])
|
||||
.map((p) => globToRegex(p));
|
||||
const isUserExcluded = (rel) => userExcludeRegexes.some((re) => re.test(rel));
|
||||
|
||||
const orphans = [];
|
||||
|
||||
const walk = (dir, relBase) => {
|
||||
let entries;
|
||||
try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
|
||||
catch { return; }
|
||||
for (const e of entries) {
|
||||
const rel = relBase ? `${relBase}/${e.name}` : e.name;
|
||||
if (e.isDirectory()) {
|
||||
if (IGNORE_DIRS.has(e.name) || e.name.startsWith('.')) continue;
|
||||
walk(path.join(dir, e.name), rel);
|
||||
} else if (e.isFile() && e.name.endsWith('.html')) {
|
||||
if (resolvedSet.has(rel)) continue;
|
||||
if (isUserExcluded(rel)) continue;
|
||||
orphans.push(rel);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
for (const root of SCAN_ROOTS) {
|
||||
const abs = path.join(rootDir, root);
|
||||
if (fs.existsSync(abs) && fs.statSync(abs).isDirectory()) {
|
||||
walk(abs, root);
|
||||
}
|
||||
}
|
||||
|
||||
if (orphans.length === 0) return null;
|
||||
const capped = orphans.slice(0, 20);
|
||||
return {
|
||||
orphans: capped,
|
||||
orphanCount: orphans.length,
|
||||
hint: `${orphans.length} HTML file(s) exist but aren't in config.files. Consider adding them, or use a glob pattern like "public/**/*.html".`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Same glob-to-regex mapping used by live-inject.mjs. Kept inline here
|
||||
* to avoid a circular import (live-inject.mjs already imports nothing
|
||||
* from live.mjs). The two must stay in sync.
|
||||
*/
|
||||
function globToRegex(pattern) {
|
||||
let re = '';
|
||||
let i = 0;
|
||||
while (i < pattern.length) {
|
||||
const c = pattern[i];
|
||||
if (c === '*') {
|
||||
if (pattern[i + 1] === '*') {
|
||||
if (pattern[i + 2] === '/') { re += '(?:.*/)?'; i += 3; }
|
||||
else { re += '.*'; i += 2; }
|
||||
} else {
|
||||
re += '[^/]*';
|
||||
i += 1;
|
||||
}
|
||||
} else if (c === '?') {
|
||||
re += '[^/]';
|
||||
i += 1;
|
||||
} else if (/[.+^${}()|[\]\\]/.test(c)) {
|
||||
re += '\\' + c;
|
||||
i += 1;
|
||||
} else {
|
||||
re += c;
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
return new RegExp('^' + re + '$');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -351,16 +351,23 @@ Schema:
|
||||
|
||||
```json
|
||||
{
|
||||
"files": ["<path>", "<path>", ...],
|
||||
"files": ["<path-or-glob>", "<path-or-glob>", ...],
|
||||
"exclude": ["<optional-glob>", ...],
|
||||
"insertBefore": "</body>",
|
||||
"commentSyntax": "html",
|
||||
"cspChecked": true
|
||||
}
|
||||
```
|
||||
|
||||
`files` is the inject target — **the HTML files the browser actually loads**, not necessarily source. Each entry is either a literal path (`"public/index.html"`) or a glob pattern (`"public/**/*.html"`). Tracked or generated doesn't matter here; wrap has its own generated-file guard and routes accepts through the fallback flow.
|
||||
|
||||
`exclude` (optional) is a list of glob patterns matching files to skip, even if a `files` glob would have included them. Use for email templates, demo fixtures, or any HTML that isn't a live page.
|
||||
|
||||
`cspChecked` tracks whether the CSP detection step below has already run. Absent on first setup; set to `true` after CSP is checked (whether patched, declined, or not needed).
|
||||
|
||||
`files` is the inject target — **the HTML files the browser actually loads**, not necessarily source. Tracked or generated doesn't matter here; wrap has its own generated-file guard and routes accepts through the fallback flow.
|
||||
**Hard-excluded paths (cannot be overridden).** `**/node_modules/**` and `**/.git/**` are never matched regardless of what the user writes. These are vendor/metadata directories and injecting into them would silently instrument third-party code.
|
||||
|
||||
**Glob syntax.** `**` matches any number of path segments (including zero), `*` matches any characters except `/`, `?` matches a single character except `/`. Paths are always relative to the project root with forward slashes.
|
||||
|
||||
| Framework | `files` | `insertBefore` | `commentSyntax` |
|
||||
|-----------|---------|----------------|-----------------|
|
||||
@@ -370,12 +377,42 @@ Schema:
|
||||
| Nuxt | `["app.vue"]` | `</body>` | `html` |
|
||||
| Svelte / SvelteKit | `["src/app.html"]` | `</body>` | `html` |
|
||||
| Astro | `[" <root layout .astro>"]` | `</body>` | `html` |
|
||||
| Multi-page (separate HTML per route) | Every HTML file the dev server serves — glob the output dir, e.g. `public/**/*.html` | `</body>` | `html` |
|
||||
| Multi-page (separate HTML per route) | `["public/**/*.html"]` — a glob covering the served directory | `</body>` | `html` |
|
||||
|
||||
Pick an anchor that exists in every file (`</body>` almost always works). Use `insertAfter` if the anchor should match **after** a specific line.
|
||||
|
||||
For multi-page sites, **prefer a glob over a literal file list**. New pages added later are picked up automatically on the next `live-inject.mjs` run; no config maintenance needed.
|
||||
|
||||
For multi-page sites whose pages are *rebuilt* by a generator (Astro, static-site generators, custom scripts like `build-sub-pages.js`), the inject survives only until the next regeneration. Re-run `live.mjs` after each build. Accept is unaffected — it writes to true source via the fallback flow.
|
||||
|
||||
### Drift-heal warning
|
||||
|
||||
On every `live.mjs` boot, after inject, the project is scanned for HTML files under common page-source roots (`public/`, `src/`, `app/`, `pages/`). If any exist that aren't covered by the resolved `files` list, the output includes a `configDrift` field:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"serverPort": 8400,
|
||||
"pageFiles": [ "..." ],
|
||||
"configDrift": {
|
||||
"orphans": ["public/new-section/index.html", "public/docs/new-command.html"],
|
||||
"orphanCount": 2,
|
||||
"hint": "2 HTML file(s) exist but aren't in config.files. Consider adding them, or use a glob pattern like \"public/**/*.html\"."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
When `configDrift` is present, surface it to the user once per session before entering the poll loop:
|
||||
|
||||
> Noticed N HTML file(s) in the project that aren't in `config.files`:
|
||||
>
|
||||
> - `public/new-section/index.html`
|
||||
> - `public/docs/new-command.html`
|
||||
>
|
||||
> Add them, or switch `files` to a glob like `["public/**/*.html"]` and let it track new pages automatically?
|
||||
|
||||
Don't auto-update the config — let the user decide. `configDrift` is `null` when there's no drift.
|
||||
|
||||
### CSP detection (first-time only)
|
||||
|
||||
If `config.cspChecked === true`, skip this entire section. You already asked this user once; the answer sticks.
|
||||
|
||||
@@ -22,6 +22,16 @@ const CONFIG_PATH = process.env.IMPECCABLE_LIVE_CONFIG || path.join(__dirname, '
|
||||
const MARKER_OPEN_TEXT = 'impeccable-live-start';
|
||||
const MARKER_CLOSE_TEXT = 'impeccable-live-end';
|
||||
|
||||
/**
|
||||
* Hard-excluded directory patterns. These are NEVER user-facing pages and
|
||||
* matching them would silently inject tracking scripts into third-party
|
||||
* code. The user cannot turn these off via config — they are the floor.
|
||||
*/
|
||||
const HARD_EXCLUDES = [
|
||||
'**/node_modules/**',
|
||||
'**/.git/**',
|
||||
];
|
||||
|
||||
export async function injectCli() {
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
@@ -71,8 +81,10 @@ Output (JSON):
|
||||
const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
|
||||
validateConfig(config);
|
||||
|
||||
const resolvedFiles = resolveFiles(process.cwd(), config);
|
||||
|
||||
if (args.includes('--remove')) {
|
||||
const results = config.files.map((relFile) => {
|
||||
const results = resolvedFiles.map((relFile) => {
|
||||
const absFile = path.resolve(process.cwd(), relFile);
|
||||
if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' };
|
||||
const content = fs.readFileSync(absFile, 'utf-8');
|
||||
@@ -93,7 +105,7 @@ Output (JSON):
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const results = config.files.map((relFile) => {
|
||||
const results = resolvedFiles.map((relFile) => {
|
||||
const absFile = path.resolve(process.cwd(), relFile);
|
||||
if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' };
|
||||
const content = fs.readFileSync(absFile, 'utf-8');
|
||||
@@ -108,6 +120,95 @@ Output (JSON):
|
||||
if (!anyInserted) process.exit(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand config.files (which may contain glob patterns) into a literal list
|
||||
* of existing file paths relative to rootDir. Literal entries pass through;
|
||||
* glob patterns are expanded via fs.globSync. HARD_EXCLUDES and config.exclude
|
||||
* are applied as filters. Duplicates are removed. Order is preserved by
|
||||
* first appearance.
|
||||
*/
|
||||
export function resolveFiles(rootDir, config) {
|
||||
const patterns = config.files;
|
||||
const userExcludes = Array.isArray(config.exclude) ? config.exclude : [];
|
||||
const allExcludes = [...HARD_EXCLUDES, ...userExcludes];
|
||||
const excludeRegexes = allExcludes.map(globToRegex);
|
||||
|
||||
const isExcluded = (relPath) => excludeRegexes.some((re) => re.test(relPath));
|
||||
const isGlob = (s) => /[*?[]/.test(s);
|
||||
|
||||
const seen = new Set();
|
||||
const out = [];
|
||||
for (const pat of patterns) {
|
||||
if (!isGlob(pat)) {
|
||||
// Literal path — include even if it doesn't exist yet; the caller
|
||||
// reports file_not_found per-entry. Exclude list doesn't apply to
|
||||
// explicit literal entries (user named it on purpose).
|
||||
if (!seen.has(pat)) {
|
||||
seen.add(pat);
|
||||
out.push(pat);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let matches;
|
||||
try {
|
||||
matches = fs.globSync(pat, { cwd: rootDir, withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const ent of matches) {
|
||||
if (!ent.isFile || !ent.isFile()) continue;
|
||||
const abs = path.join(ent.parentPath || ent.path || rootDir, ent.name);
|
||||
const rel = path.relative(rootDir, abs).split(path.sep).join('/');
|
||||
if (isExcluded(rel)) continue;
|
||||
if (seen.has(rel)) continue;
|
||||
seen.add(rel);
|
||||
out.push(rel);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a glob pattern to a RegExp. Supports:
|
||||
* ** → any number of path segments (including zero)
|
||||
* * → any chars except `/`
|
||||
* ? → any single char except `/`
|
||||
* Paths are normalized to forward slashes before matching.
|
||||
*/
|
||||
function globToRegex(pattern) {
|
||||
let re = '';
|
||||
let i = 0;
|
||||
while (i < pattern.length) {
|
||||
const c = pattern[i];
|
||||
if (c === '*') {
|
||||
if (pattern[i + 1] === '*') {
|
||||
// ** — any number of segments, including zero. Handle the common
|
||||
// **/ and /** forms so `a/**/b` matches `a/b` as well as `a/x/y/b`.
|
||||
if (pattern[i + 2] === '/') {
|
||||
re += '(?:.*/)?';
|
||||
i += 3;
|
||||
} else {
|
||||
re += '.*';
|
||||
i += 2;
|
||||
}
|
||||
} else {
|
||||
re += '[^/]*';
|
||||
i += 1;
|
||||
}
|
||||
} else if (c === '?') {
|
||||
re += '[^/]';
|
||||
i += 1;
|
||||
} else if (/[.+^${}()|[\]\\]/.test(c)) {
|
||||
re += '\\' + c;
|
||||
i += 1;
|
||||
} else {
|
||||
re += c;
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
return new RegExp('^' + re + '$');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Core operations
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -120,6 +221,14 @@ function validateConfig(cfg) {
|
||||
if (!cfg.files.every((f) => typeof f === 'string' && f.length > 0)) {
|
||||
throw new Error('config.files must contain only non-empty strings');
|
||||
}
|
||||
if (cfg.exclude !== undefined) {
|
||||
if (!Array.isArray(cfg.exclude)) {
|
||||
throw new Error('config.exclude, if present, must be a string array');
|
||||
}
|
||||
if (!cfg.exclude.every((f) => typeof f === 'string' && f.length > 0)) {
|
||||
throw new Error('config.exclude must contain only non-empty strings');
|
||||
}
|
||||
}
|
||||
if (typeof cfg.insertBefore !== 'string' && typeof cfg.insertAfter !== 'string') {
|
||||
throw new Error('config.insertBefore or config.insertAfter (string) required');
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { loadContext } from './load-context.mjs';
|
||||
import { resolveFiles } from './live-inject.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const PID_FILE = path.join(process.cwd(), '.impeccable-live.json');
|
||||
@@ -82,12 +83,19 @@ The agent should then:
|
||||
// 4. Load PRODUCT.md + DESIGN.md context (auto-migrates legacy .impeccable.md)
|
||||
const ctx = loadContext(process.cwd());
|
||||
|
||||
// 5. Emit everything the agent needs
|
||||
// 5. Compute drift-heal: compare resolved inject targets against the
|
||||
// project's HTML files. Orphans are HTML files not covered by config.
|
||||
// Warning only — the agent decides whether to act.
|
||||
const resolvedFiles = resolveFiles(process.cwd(), checkResult.config);
|
||||
const drift = scanForDrift(process.cwd(), resolvedFiles, checkResult.config);
|
||||
|
||||
// 6. Emit everything the agent needs
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
serverPort: serverInfo.port,
|
||||
serverToken: serverInfo.token,
|
||||
pageFiles: checkResult.config.files,
|
||||
pageFiles: resolvedFiles,
|
||||
configDrift: drift,
|
||||
hasProduct: ctx.hasProduct,
|
||||
product: ctx.product,
|
||||
productPath: ctx.productPath,
|
||||
@@ -98,6 +106,98 @@ The agent should then:
|
||||
}, null, 2));
|
||||
}
|
||||
|
||||
/**
|
||||
* Drift-heal scan. Walks the project for HTML files under common
|
||||
* page-source directories (public/, src/, app/, pages/) and reports any
|
||||
* that aren't covered by the resolved inject targets. This is purely
|
||||
* advisory — the agent can ignore it, or suggest the user add the
|
||||
* orphans to config.files.
|
||||
*
|
||||
* Skipped if config.files already contains at least one glob pattern
|
||||
* covering everything in practice (signaled by the orphan count being 0).
|
||||
*/
|
||||
function scanForDrift(rootDir, resolvedFiles, config) {
|
||||
const SCAN_ROOTS = ['public', 'src', 'app', 'pages'];
|
||||
const IGNORE_DIRS = new Set([
|
||||
'node_modules', '.git', '.next', '.nuxt', '.svelte-kit', '.astro',
|
||||
'.turbo', '.vercel', '.cache', 'coverage', 'dist', 'build',
|
||||
]);
|
||||
|
||||
const resolvedSet = new Set(resolvedFiles.map((f) => f.split(path.sep).join('/')));
|
||||
|
||||
// Files matching the user's `exclude` globs are intentional omissions,
|
||||
// not drift. Compile them to regexes so the orphan list stays signal.
|
||||
const userExcludeRegexes = (Array.isArray(config.exclude) ? config.exclude : [])
|
||||
.map((p) => globToRegex(p));
|
||||
const isUserExcluded = (rel) => userExcludeRegexes.some((re) => re.test(rel));
|
||||
|
||||
const orphans = [];
|
||||
|
||||
const walk = (dir, relBase) => {
|
||||
let entries;
|
||||
try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
|
||||
catch { return; }
|
||||
for (const e of entries) {
|
||||
const rel = relBase ? `${relBase}/${e.name}` : e.name;
|
||||
if (e.isDirectory()) {
|
||||
if (IGNORE_DIRS.has(e.name) || e.name.startsWith('.')) continue;
|
||||
walk(path.join(dir, e.name), rel);
|
||||
} else if (e.isFile() && e.name.endsWith('.html')) {
|
||||
if (resolvedSet.has(rel)) continue;
|
||||
if (isUserExcluded(rel)) continue;
|
||||
orphans.push(rel);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
for (const root of SCAN_ROOTS) {
|
||||
const abs = path.join(rootDir, root);
|
||||
if (fs.existsSync(abs) && fs.statSync(abs).isDirectory()) {
|
||||
walk(abs, root);
|
||||
}
|
||||
}
|
||||
|
||||
if (orphans.length === 0) return null;
|
||||
const capped = orphans.slice(0, 20);
|
||||
return {
|
||||
orphans: capped,
|
||||
orphanCount: orphans.length,
|
||||
hint: `${orphans.length} HTML file(s) exist but aren't in config.files. Consider adding them, or use a glob pattern like "public/**/*.html".`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Same glob-to-regex mapping used by live-inject.mjs. Kept inline here
|
||||
* to avoid a circular import (live-inject.mjs already imports nothing
|
||||
* from live.mjs). The two must stay in sync.
|
||||
*/
|
||||
function globToRegex(pattern) {
|
||||
let re = '';
|
||||
let i = 0;
|
||||
while (i < pattern.length) {
|
||||
const c = pattern[i];
|
||||
if (c === '*') {
|
||||
if (pattern[i + 1] === '*') {
|
||||
if (pattern[i + 2] === '/') { re += '(?:.*/)?'; i += 3; }
|
||||
else { re += '.*'; i += 2; }
|
||||
} else {
|
||||
re += '[^/]*';
|
||||
i += 1;
|
||||
}
|
||||
} else if (c === '?') {
|
||||
re += '[^/]';
|
||||
i += 1;
|
||||
} else if (/[.+^${}()|[\]\\]/.test(c)) {
|
||||
re += '\\' + c;
|
||||
i += 1;
|
||||
} else {
|
||||
re += c;
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
return new RegExp('^' + re + '$');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -351,16 +351,23 @@ Schema:
|
||||
|
||||
```json
|
||||
{
|
||||
"files": ["<path>", "<path>", ...],
|
||||
"files": ["<path-or-glob>", "<path-or-glob>", ...],
|
||||
"exclude": ["<optional-glob>", ...],
|
||||
"insertBefore": "</body>",
|
||||
"commentSyntax": "html",
|
||||
"cspChecked": true
|
||||
}
|
||||
```
|
||||
|
||||
`files` is the inject target — **the HTML files the browser actually loads**, not necessarily source. Each entry is either a literal path (`"public/index.html"`) or a glob pattern (`"public/**/*.html"`). Tracked or generated doesn't matter here; wrap has its own generated-file guard and routes accepts through the fallback flow.
|
||||
|
||||
`exclude` (optional) is a list of glob patterns matching files to skip, even if a `files` glob would have included them. Use for email templates, demo fixtures, or any HTML that isn't a live page.
|
||||
|
||||
`cspChecked` tracks whether the CSP detection step below has already run. Absent on first setup; set to `true` after CSP is checked (whether patched, declined, or not needed).
|
||||
|
||||
`files` is the inject target — **the HTML files the browser actually loads**, not necessarily source. Tracked or generated doesn't matter here; wrap has its own generated-file guard and routes accepts through the fallback flow.
|
||||
**Hard-excluded paths (cannot be overridden).** `**/node_modules/**` and `**/.git/**` are never matched regardless of what the user writes. These are vendor/metadata directories and injecting into them would silently instrument third-party code.
|
||||
|
||||
**Glob syntax.** `**` matches any number of path segments (including zero), `*` matches any characters except `/`, `?` matches a single character except `/`. Paths are always relative to the project root with forward slashes.
|
||||
|
||||
| Framework | `files` | `insertBefore` | `commentSyntax` |
|
||||
|-----------|---------|----------------|-----------------|
|
||||
@@ -370,12 +377,42 @@ Schema:
|
||||
| Nuxt | `["app.vue"]` | `</body>` | `html` |
|
||||
| Svelte / SvelteKit | `["src/app.html"]` | `</body>` | `html` |
|
||||
| Astro | `[" <root layout .astro>"]` | `</body>` | `html` |
|
||||
| Multi-page (separate HTML per route) | Every HTML file the dev server serves — glob the output dir, e.g. `public/**/*.html` | `</body>` | `html` |
|
||||
| Multi-page (separate HTML per route) | `["public/**/*.html"]` — a glob covering the served directory | `</body>` | `html` |
|
||||
|
||||
Pick an anchor that exists in every file (`</body>` almost always works). Use `insertAfter` if the anchor should match **after** a specific line.
|
||||
|
||||
For multi-page sites, **prefer a glob over a literal file list**. New pages added later are picked up automatically on the next `live-inject.mjs` run; no config maintenance needed.
|
||||
|
||||
For multi-page sites whose pages are *rebuilt* by a generator (Astro, static-site generators, custom scripts like `build-sub-pages.js`), the inject survives only until the next regeneration. Re-run `live.mjs` after each build. Accept is unaffected — it writes to true source via the fallback flow.
|
||||
|
||||
### Drift-heal warning
|
||||
|
||||
On every `live.mjs` boot, after inject, the project is scanned for HTML files under common page-source roots (`public/`, `src/`, `app/`, `pages/`). If any exist that aren't covered by the resolved `files` list, the output includes a `configDrift` field:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"serverPort": 8400,
|
||||
"pageFiles": [ "..." ],
|
||||
"configDrift": {
|
||||
"orphans": ["public/new-section/index.html", "public/docs/new-command.html"],
|
||||
"orphanCount": 2,
|
||||
"hint": "2 HTML file(s) exist but aren't in config.files. Consider adding them, or use a glob pattern like \"public/**/*.html\"."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
When `configDrift` is present, surface it to the user once per session before entering the poll loop:
|
||||
|
||||
> Noticed N HTML file(s) in the project that aren't in `config.files`:
|
||||
>
|
||||
> - `public/new-section/index.html`
|
||||
> - `public/docs/new-command.html`
|
||||
>
|
||||
> Add them, or switch `files` to a glob like `["public/**/*.html"]` and let it track new pages automatically?
|
||||
|
||||
Don't auto-update the config — let the user decide. `configDrift` is `null` when there's no drift.
|
||||
|
||||
### CSP detection (first-time only)
|
||||
|
||||
If `config.cspChecked === true`, skip this entire section. You already asked this user once; the answer sticks.
|
||||
|
||||
@@ -22,6 +22,16 @@ const CONFIG_PATH = process.env.IMPECCABLE_LIVE_CONFIG || path.join(__dirname, '
|
||||
const MARKER_OPEN_TEXT = 'impeccable-live-start';
|
||||
const MARKER_CLOSE_TEXT = 'impeccable-live-end';
|
||||
|
||||
/**
|
||||
* Hard-excluded directory patterns. These are NEVER user-facing pages and
|
||||
* matching them would silently inject tracking scripts into third-party
|
||||
* code. The user cannot turn these off via config — they are the floor.
|
||||
*/
|
||||
const HARD_EXCLUDES = [
|
||||
'**/node_modules/**',
|
||||
'**/.git/**',
|
||||
];
|
||||
|
||||
export async function injectCli() {
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
@@ -71,8 +81,10 @@ Output (JSON):
|
||||
const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
|
||||
validateConfig(config);
|
||||
|
||||
const resolvedFiles = resolveFiles(process.cwd(), config);
|
||||
|
||||
if (args.includes('--remove')) {
|
||||
const results = config.files.map((relFile) => {
|
||||
const results = resolvedFiles.map((relFile) => {
|
||||
const absFile = path.resolve(process.cwd(), relFile);
|
||||
if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' };
|
||||
const content = fs.readFileSync(absFile, 'utf-8');
|
||||
@@ -93,7 +105,7 @@ Output (JSON):
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const results = config.files.map((relFile) => {
|
||||
const results = resolvedFiles.map((relFile) => {
|
||||
const absFile = path.resolve(process.cwd(), relFile);
|
||||
if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' };
|
||||
const content = fs.readFileSync(absFile, 'utf-8');
|
||||
@@ -108,6 +120,95 @@ Output (JSON):
|
||||
if (!anyInserted) process.exit(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand config.files (which may contain glob patterns) into a literal list
|
||||
* of existing file paths relative to rootDir. Literal entries pass through;
|
||||
* glob patterns are expanded via fs.globSync. HARD_EXCLUDES and config.exclude
|
||||
* are applied as filters. Duplicates are removed. Order is preserved by
|
||||
* first appearance.
|
||||
*/
|
||||
export function resolveFiles(rootDir, config) {
|
||||
const patterns = config.files;
|
||||
const userExcludes = Array.isArray(config.exclude) ? config.exclude : [];
|
||||
const allExcludes = [...HARD_EXCLUDES, ...userExcludes];
|
||||
const excludeRegexes = allExcludes.map(globToRegex);
|
||||
|
||||
const isExcluded = (relPath) => excludeRegexes.some((re) => re.test(relPath));
|
||||
const isGlob = (s) => /[*?[]/.test(s);
|
||||
|
||||
const seen = new Set();
|
||||
const out = [];
|
||||
for (const pat of patterns) {
|
||||
if (!isGlob(pat)) {
|
||||
// Literal path — include even if it doesn't exist yet; the caller
|
||||
// reports file_not_found per-entry. Exclude list doesn't apply to
|
||||
// explicit literal entries (user named it on purpose).
|
||||
if (!seen.has(pat)) {
|
||||
seen.add(pat);
|
||||
out.push(pat);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let matches;
|
||||
try {
|
||||
matches = fs.globSync(pat, { cwd: rootDir, withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const ent of matches) {
|
||||
if (!ent.isFile || !ent.isFile()) continue;
|
||||
const abs = path.join(ent.parentPath || ent.path || rootDir, ent.name);
|
||||
const rel = path.relative(rootDir, abs).split(path.sep).join('/');
|
||||
if (isExcluded(rel)) continue;
|
||||
if (seen.has(rel)) continue;
|
||||
seen.add(rel);
|
||||
out.push(rel);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a glob pattern to a RegExp. Supports:
|
||||
* ** → any number of path segments (including zero)
|
||||
* * → any chars except `/`
|
||||
* ? → any single char except `/`
|
||||
* Paths are normalized to forward slashes before matching.
|
||||
*/
|
||||
function globToRegex(pattern) {
|
||||
let re = '';
|
||||
let i = 0;
|
||||
while (i < pattern.length) {
|
||||
const c = pattern[i];
|
||||
if (c === '*') {
|
||||
if (pattern[i + 1] === '*') {
|
||||
// ** — any number of segments, including zero. Handle the common
|
||||
// **/ and /** forms so `a/**/b` matches `a/b` as well as `a/x/y/b`.
|
||||
if (pattern[i + 2] === '/') {
|
||||
re += '(?:.*/)?';
|
||||
i += 3;
|
||||
} else {
|
||||
re += '.*';
|
||||
i += 2;
|
||||
}
|
||||
} else {
|
||||
re += '[^/]*';
|
||||
i += 1;
|
||||
}
|
||||
} else if (c === '?') {
|
||||
re += '[^/]';
|
||||
i += 1;
|
||||
} else if (/[.+^${}()|[\]\\]/.test(c)) {
|
||||
re += '\\' + c;
|
||||
i += 1;
|
||||
} else {
|
||||
re += c;
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
return new RegExp('^' + re + '$');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Core operations
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -120,6 +221,14 @@ function validateConfig(cfg) {
|
||||
if (!cfg.files.every((f) => typeof f === 'string' && f.length > 0)) {
|
||||
throw new Error('config.files must contain only non-empty strings');
|
||||
}
|
||||
if (cfg.exclude !== undefined) {
|
||||
if (!Array.isArray(cfg.exclude)) {
|
||||
throw new Error('config.exclude, if present, must be a string array');
|
||||
}
|
||||
if (!cfg.exclude.every((f) => typeof f === 'string' && f.length > 0)) {
|
||||
throw new Error('config.exclude must contain only non-empty strings');
|
||||
}
|
||||
}
|
||||
if (typeof cfg.insertBefore !== 'string' && typeof cfg.insertAfter !== 'string') {
|
||||
throw new Error('config.insertBefore or config.insertAfter (string) required');
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { loadContext } from './load-context.mjs';
|
||||
import { resolveFiles } from './live-inject.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const PID_FILE = path.join(process.cwd(), '.impeccable-live.json');
|
||||
@@ -82,12 +83,19 @@ The agent should then:
|
||||
// 4. Load PRODUCT.md + DESIGN.md context (auto-migrates legacy .impeccable.md)
|
||||
const ctx = loadContext(process.cwd());
|
||||
|
||||
// 5. Emit everything the agent needs
|
||||
// 5. Compute drift-heal: compare resolved inject targets against the
|
||||
// project's HTML files. Orphans are HTML files not covered by config.
|
||||
// Warning only — the agent decides whether to act.
|
||||
const resolvedFiles = resolveFiles(process.cwd(), checkResult.config);
|
||||
const drift = scanForDrift(process.cwd(), resolvedFiles, checkResult.config);
|
||||
|
||||
// 6. Emit everything the agent needs
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
serverPort: serverInfo.port,
|
||||
serverToken: serverInfo.token,
|
||||
pageFiles: checkResult.config.files,
|
||||
pageFiles: resolvedFiles,
|
||||
configDrift: drift,
|
||||
hasProduct: ctx.hasProduct,
|
||||
product: ctx.product,
|
||||
productPath: ctx.productPath,
|
||||
@@ -98,6 +106,98 @@ The agent should then:
|
||||
}, null, 2));
|
||||
}
|
||||
|
||||
/**
|
||||
* Drift-heal scan. Walks the project for HTML files under common
|
||||
* page-source directories (public/, src/, app/, pages/) and reports any
|
||||
* that aren't covered by the resolved inject targets. This is purely
|
||||
* advisory — the agent can ignore it, or suggest the user add the
|
||||
* orphans to config.files.
|
||||
*
|
||||
* Skipped if config.files already contains at least one glob pattern
|
||||
* covering everything in practice (signaled by the orphan count being 0).
|
||||
*/
|
||||
function scanForDrift(rootDir, resolvedFiles, config) {
|
||||
const SCAN_ROOTS = ['public', 'src', 'app', 'pages'];
|
||||
const IGNORE_DIRS = new Set([
|
||||
'node_modules', '.git', '.next', '.nuxt', '.svelte-kit', '.astro',
|
||||
'.turbo', '.vercel', '.cache', 'coverage', 'dist', 'build',
|
||||
]);
|
||||
|
||||
const resolvedSet = new Set(resolvedFiles.map((f) => f.split(path.sep).join('/')));
|
||||
|
||||
// Files matching the user's `exclude` globs are intentional omissions,
|
||||
// not drift. Compile them to regexes so the orphan list stays signal.
|
||||
const userExcludeRegexes = (Array.isArray(config.exclude) ? config.exclude : [])
|
||||
.map((p) => globToRegex(p));
|
||||
const isUserExcluded = (rel) => userExcludeRegexes.some((re) => re.test(rel));
|
||||
|
||||
const orphans = [];
|
||||
|
||||
const walk = (dir, relBase) => {
|
||||
let entries;
|
||||
try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
|
||||
catch { return; }
|
||||
for (const e of entries) {
|
||||
const rel = relBase ? `${relBase}/${e.name}` : e.name;
|
||||
if (e.isDirectory()) {
|
||||
if (IGNORE_DIRS.has(e.name) || e.name.startsWith('.')) continue;
|
||||
walk(path.join(dir, e.name), rel);
|
||||
} else if (e.isFile() && e.name.endsWith('.html')) {
|
||||
if (resolvedSet.has(rel)) continue;
|
||||
if (isUserExcluded(rel)) continue;
|
||||
orphans.push(rel);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
for (const root of SCAN_ROOTS) {
|
||||
const abs = path.join(rootDir, root);
|
||||
if (fs.existsSync(abs) && fs.statSync(abs).isDirectory()) {
|
||||
walk(abs, root);
|
||||
}
|
||||
}
|
||||
|
||||
if (orphans.length === 0) return null;
|
||||
const capped = orphans.slice(0, 20);
|
||||
return {
|
||||
orphans: capped,
|
||||
orphanCount: orphans.length,
|
||||
hint: `${orphans.length} HTML file(s) exist but aren't in config.files. Consider adding them, or use a glob pattern like "public/**/*.html".`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Same glob-to-regex mapping used by live-inject.mjs. Kept inline here
|
||||
* to avoid a circular import (live-inject.mjs already imports nothing
|
||||
* from live.mjs). The two must stay in sync.
|
||||
*/
|
||||
function globToRegex(pattern) {
|
||||
let re = '';
|
||||
let i = 0;
|
||||
while (i < pattern.length) {
|
||||
const c = pattern[i];
|
||||
if (c === '*') {
|
||||
if (pattern[i + 1] === '*') {
|
||||
if (pattern[i + 2] === '/') { re += '(?:.*/)?'; i += 3; }
|
||||
else { re += '.*'; i += 2; }
|
||||
} else {
|
||||
re += '[^/]*';
|
||||
i += 1;
|
||||
}
|
||||
} else if (c === '?') {
|
||||
re += '[^/]';
|
||||
i += 1;
|
||||
} else if (/[.+^${}()|[\]\\]/.test(c)) {
|
||||
re += '\\' + c;
|
||||
i += 1;
|
||||
} else {
|
||||
re += c;
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
return new RegExp('^' + re + '$');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -351,16 +351,23 @@ Schema:
|
||||
|
||||
```json
|
||||
{
|
||||
"files": ["<path>", "<path>", ...],
|
||||
"files": ["<path-or-glob>", "<path-or-glob>", ...],
|
||||
"exclude": ["<optional-glob>", ...],
|
||||
"insertBefore": "</body>",
|
||||
"commentSyntax": "html",
|
||||
"cspChecked": true
|
||||
}
|
||||
```
|
||||
|
||||
`files` is the inject target — **the HTML files the browser actually loads**, not necessarily source. Each entry is either a literal path (`"public/index.html"`) or a glob pattern (`"public/**/*.html"`). Tracked or generated doesn't matter here; wrap has its own generated-file guard and routes accepts through the fallback flow.
|
||||
|
||||
`exclude` (optional) is a list of glob patterns matching files to skip, even if a `files` glob would have included them. Use for email templates, demo fixtures, or any HTML that isn't a live page.
|
||||
|
||||
`cspChecked` tracks whether the CSP detection step below has already run. Absent on first setup; set to `true` after CSP is checked (whether patched, declined, or not needed).
|
||||
|
||||
`files` is the inject target — **the HTML files the browser actually loads**, not necessarily source. Tracked or generated doesn't matter here; wrap has its own generated-file guard and routes accepts through the fallback flow.
|
||||
**Hard-excluded paths (cannot be overridden).** `**/node_modules/**` and `**/.git/**` are never matched regardless of what the user writes. These are vendor/metadata directories and injecting into them would silently instrument third-party code.
|
||||
|
||||
**Glob syntax.** `**` matches any number of path segments (including zero), `*` matches any characters except `/`, `?` matches a single character except `/`. Paths are always relative to the project root with forward slashes.
|
||||
|
||||
| Framework | `files` | `insertBefore` | `commentSyntax` |
|
||||
|-----------|---------|----------------|-----------------|
|
||||
@@ -370,12 +377,42 @@ Schema:
|
||||
| Nuxt | `["app.vue"]` | `</body>` | `html` |
|
||||
| Svelte / SvelteKit | `["src/app.html"]` | `</body>` | `html` |
|
||||
| Astro | `[" <root layout .astro>"]` | `</body>` | `html` |
|
||||
| Multi-page (separate HTML per route) | Every HTML file the dev server serves — glob the output dir, e.g. `public/**/*.html` | `</body>` | `html` |
|
||||
| Multi-page (separate HTML per route) | `["public/**/*.html"]` — a glob covering the served directory | `</body>` | `html` |
|
||||
|
||||
Pick an anchor that exists in every file (`</body>` almost always works). Use `insertAfter` if the anchor should match **after** a specific line.
|
||||
|
||||
For multi-page sites, **prefer a glob over a literal file list**. New pages added later are picked up automatically on the next `live-inject.mjs` run; no config maintenance needed.
|
||||
|
||||
For multi-page sites whose pages are *rebuilt* by a generator (Astro, static-site generators, custom scripts like `build-sub-pages.js`), the inject survives only until the next regeneration. Re-run `live.mjs` after each build. Accept is unaffected — it writes to true source via the fallback flow.
|
||||
|
||||
### Drift-heal warning
|
||||
|
||||
On every `live.mjs` boot, after inject, the project is scanned for HTML files under common page-source roots (`public/`, `src/`, `app/`, `pages/`). If any exist that aren't covered by the resolved `files` list, the output includes a `configDrift` field:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"serverPort": 8400,
|
||||
"pageFiles": [ "..." ],
|
||||
"configDrift": {
|
||||
"orphans": ["public/new-section/index.html", "public/docs/new-command.html"],
|
||||
"orphanCount": 2,
|
||||
"hint": "2 HTML file(s) exist but aren't in config.files. Consider adding them, or use a glob pattern like \"public/**/*.html\"."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
When `configDrift` is present, surface it to the user once per session before entering the poll loop:
|
||||
|
||||
> Noticed N HTML file(s) in the project that aren't in `config.files`:
|
||||
>
|
||||
> - `public/new-section/index.html`
|
||||
> - `public/docs/new-command.html`
|
||||
>
|
||||
> Add them, or switch `files` to a glob like `["public/**/*.html"]` and let it track new pages automatically?
|
||||
|
||||
Don't auto-update the config — let the user decide. `configDrift` is `null` when there's no drift.
|
||||
|
||||
### CSP detection (first-time only)
|
||||
|
||||
If `config.cspChecked === true`, skip this entire section. You already asked this user once; the answer sticks.
|
||||
|
||||
@@ -22,6 +22,16 @@ const CONFIG_PATH = process.env.IMPECCABLE_LIVE_CONFIG || path.join(__dirname, '
|
||||
const MARKER_OPEN_TEXT = 'impeccable-live-start';
|
||||
const MARKER_CLOSE_TEXT = 'impeccable-live-end';
|
||||
|
||||
/**
|
||||
* Hard-excluded directory patterns. These are NEVER user-facing pages and
|
||||
* matching them would silently inject tracking scripts into third-party
|
||||
* code. The user cannot turn these off via config — they are the floor.
|
||||
*/
|
||||
const HARD_EXCLUDES = [
|
||||
'**/node_modules/**',
|
||||
'**/.git/**',
|
||||
];
|
||||
|
||||
export async function injectCli() {
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
@@ -71,8 +81,10 @@ Output (JSON):
|
||||
const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
|
||||
validateConfig(config);
|
||||
|
||||
const resolvedFiles = resolveFiles(process.cwd(), config);
|
||||
|
||||
if (args.includes('--remove')) {
|
||||
const results = config.files.map((relFile) => {
|
||||
const results = resolvedFiles.map((relFile) => {
|
||||
const absFile = path.resolve(process.cwd(), relFile);
|
||||
if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' };
|
||||
const content = fs.readFileSync(absFile, 'utf-8');
|
||||
@@ -93,7 +105,7 @@ Output (JSON):
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const results = config.files.map((relFile) => {
|
||||
const results = resolvedFiles.map((relFile) => {
|
||||
const absFile = path.resolve(process.cwd(), relFile);
|
||||
if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' };
|
||||
const content = fs.readFileSync(absFile, 'utf-8');
|
||||
@@ -108,6 +120,95 @@ Output (JSON):
|
||||
if (!anyInserted) process.exit(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand config.files (which may contain glob patterns) into a literal list
|
||||
* of existing file paths relative to rootDir. Literal entries pass through;
|
||||
* glob patterns are expanded via fs.globSync. HARD_EXCLUDES and config.exclude
|
||||
* are applied as filters. Duplicates are removed. Order is preserved by
|
||||
* first appearance.
|
||||
*/
|
||||
export function resolveFiles(rootDir, config) {
|
||||
const patterns = config.files;
|
||||
const userExcludes = Array.isArray(config.exclude) ? config.exclude : [];
|
||||
const allExcludes = [...HARD_EXCLUDES, ...userExcludes];
|
||||
const excludeRegexes = allExcludes.map(globToRegex);
|
||||
|
||||
const isExcluded = (relPath) => excludeRegexes.some((re) => re.test(relPath));
|
||||
const isGlob = (s) => /[*?[]/.test(s);
|
||||
|
||||
const seen = new Set();
|
||||
const out = [];
|
||||
for (const pat of patterns) {
|
||||
if (!isGlob(pat)) {
|
||||
// Literal path — include even if it doesn't exist yet; the caller
|
||||
// reports file_not_found per-entry. Exclude list doesn't apply to
|
||||
// explicit literal entries (user named it on purpose).
|
||||
if (!seen.has(pat)) {
|
||||
seen.add(pat);
|
||||
out.push(pat);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let matches;
|
||||
try {
|
||||
matches = fs.globSync(pat, { cwd: rootDir, withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const ent of matches) {
|
||||
if (!ent.isFile || !ent.isFile()) continue;
|
||||
const abs = path.join(ent.parentPath || ent.path || rootDir, ent.name);
|
||||
const rel = path.relative(rootDir, abs).split(path.sep).join('/');
|
||||
if (isExcluded(rel)) continue;
|
||||
if (seen.has(rel)) continue;
|
||||
seen.add(rel);
|
||||
out.push(rel);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a glob pattern to a RegExp. Supports:
|
||||
* ** → any number of path segments (including zero)
|
||||
* * → any chars except `/`
|
||||
* ? → any single char except `/`
|
||||
* Paths are normalized to forward slashes before matching.
|
||||
*/
|
||||
function globToRegex(pattern) {
|
||||
let re = '';
|
||||
let i = 0;
|
||||
while (i < pattern.length) {
|
||||
const c = pattern[i];
|
||||
if (c === '*') {
|
||||
if (pattern[i + 1] === '*') {
|
||||
// ** — any number of segments, including zero. Handle the common
|
||||
// **/ and /** forms so `a/**/b` matches `a/b` as well as `a/x/y/b`.
|
||||
if (pattern[i + 2] === '/') {
|
||||
re += '(?:.*/)?';
|
||||
i += 3;
|
||||
} else {
|
||||
re += '.*';
|
||||
i += 2;
|
||||
}
|
||||
} else {
|
||||
re += '[^/]*';
|
||||
i += 1;
|
||||
}
|
||||
} else if (c === '?') {
|
||||
re += '[^/]';
|
||||
i += 1;
|
||||
} else if (/[.+^${}()|[\]\\]/.test(c)) {
|
||||
re += '\\' + c;
|
||||
i += 1;
|
||||
} else {
|
||||
re += c;
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
return new RegExp('^' + re + '$');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Core operations
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -120,6 +221,14 @@ function validateConfig(cfg) {
|
||||
if (!cfg.files.every((f) => typeof f === 'string' && f.length > 0)) {
|
||||
throw new Error('config.files must contain only non-empty strings');
|
||||
}
|
||||
if (cfg.exclude !== undefined) {
|
||||
if (!Array.isArray(cfg.exclude)) {
|
||||
throw new Error('config.exclude, if present, must be a string array');
|
||||
}
|
||||
if (!cfg.exclude.every((f) => typeof f === 'string' && f.length > 0)) {
|
||||
throw new Error('config.exclude must contain only non-empty strings');
|
||||
}
|
||||
}
|
||||
if (typeof cfg.insertBefore !== 'string' && typeof cfg.insertAfter !== 'string') {
|
||||
throw new Error('config.insertBefore or config.insertAfter (string) required');
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { loadContext } from './load-context.mjs';
|
||||
import { resolveFiles } from './live-inject.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const PID_FILE = path.join(process.cwd(), '.impeccable-live.json');
|
||||
@@ -82,12 +83,19 @@ The agent should then:
|
||||
// 4. Load PRODUCT.md + DESIGN.md context (auto-migrates legacy .impeccable.md)
|
||||
const ctx = loadContext(process.cwd());
|
||||
|
||||
// 5. Emit everything the agent needs
|
||||
// 5. Compute drift-heal: compare resolved inject targets against the
|
||||
// project's HTML files. Orphans are HTML files not covered by config.
|
||||
// Warning only — the agent decides whether to act.
|
||||
const resolvedFiles = resolveFiles(process.cwd(), checkResult.config);
|
||||
const drift = scanForDrift(process.cwd(), resolvedFiles, checkResult.config);
|
||||
|
||||
// 6. Emit everything the agent needs
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
serverPort: serverInfo.port,
|
||||
serverToken: serverInfo.token,
|
||||
pageFiles: checkResult.config.files,
|
||||
pageFiles: resolvedFiles,
|
||||
configDrift: drift,
|
||||
hasProduct: ctx.hasProduct,
|
||||
product: ctx.product,
|
||||
productPath: ctx.productPath,
|
||||
@@ -98,6 +106,98 @@ The agent should then:
|
||||
}, null, 2));
|
||||
}
|
||||
|
||||
/**
|
||||
* Drift-heal scan. Walks the project for HTML files under common
|
||||
* page-source directories (public/, src/, app/, pages/) and reports any
|
||||
* that aren't covered by the resolved inject targets. This is purely
|
||||
* advisory — the agent can ignore it, or suggest the user add the
|
||||
* orphans to config.files.
|
||||
*
|
||||
* Skipped if config.files already contains at least one glob pattern
|
||||
* covering everything in practice (signaled by the orphan count being 0).
|
||||
*/
|
||||
function scanForDrift(rootDir, resolvedFiles, config) {
|
||||
const SCAN_ROOTS = ['public', 'src', 'app', 'pages'];
|
||||
const IGNORE_DIRS = new Set([
|
||||
'node_modules', '.git', '.next', '.nuxt', '.svelte-kit', '.astro',
|
||||
'.turbo', '.vercel', '.cache', 'coverage', 'dist', 'build',
|
||||
]);
|
||||
|
||||
const resolvedSet = new Set(resolvedFiles.map((f) => f.split(path.sep).join('/')));
|
||||
|
||||
// Files matching the user's `exclude` globs are intentional omissions,
|
||||
// not drift. Compile them to regexes so the orphan list stays signal.
|
||||
const userExcludeRegexes = (Array.isArray(config.exclude) ? config.exclude : [])
|
||||
.map((p) => globToRegex(p));
|
||||
const isUserExcluded = (rel) => userExcludeRegexes.some((re) => re.test(rel));
|
||||
|
||||
const orphans = [];
|
||||
|
||||
const walk = (dir, relBase) => {
|
||||
let entries;
|
||||
try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
|
||||
catch { return; }
|
||||
for (const e of entries) {
|
||||
const rel = relBase ? `${relBase}/${e.name}` : e.name;
|
||||
if (e.isDirectory()) {
|
||||
if (IGNORE_DIRS.has(e.name) || e.name.startsWith('.')) continue;
|
||||
walk(path.join(dir, e.name), rel);
|
||||
} else if (e.isFile() && e.name.endsWith('.html')) {
|
||||
if (resolvedSet.has(rel)) continue;
|
||||
if (isUserExcluded(rel)) continue;
|
||||
orphans.push(rel);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
for (const root of SCAN_ROOTS) {
|
||||
const abs = path.join(rootDir, root);
|
||||
if (fs.existsSync(abs) && fs.statSync(abs).isDirectory()) {
|
||||
walk(abs, root);
|
||||
}
|
||||
}
|
||||
|
||||
if (orphans.length === 0) return null;
|
||||
const capped = orphans.slice(0, 20);
|
||||
return {
|
||||
orphans: capped,
|
||||
orphanCount: orphans.length,
|
||||
hint: `${orphans.length} HTML file(s) exist but aren't in config.files. Consider adding them, or use a glob pattern like "public/**/*.html".`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Same glob-to-regex mapping used by live-inject.mjs. Kept inline here
|
||||
* to avoid a circular import (live-inject.mjs already imports nothing
|
||||
* from live.mjs). The two must stay in sync.
|
||||
*/
|
||||
function globToRegex(pattern) {
|
||||
let re = '';
|
||||
let i = 0;
|
||||
while (i < pattern.length) {
|
||||
const c = pattern[i];
|
||||
if (c === '*') {
|
||||
if (pattern[i + 1] === '*') {
|
||||
if (pattern[i + 2] === '/') { re += '(?:.*/)?'; i += 3; }
|
||||
else { re += '.*'; i += 2; }
|
||||
} else {
|
||||
re += '[^/]*';
|
||||
i += 1;
|
||||
}
|
||||
} else if (c === '?') {
|
||||
re += '[^/]';
|
||||
i += 1;
|
||||
} else if (/[.+^${}()|[\]\\]/.test(c)) {
|
||||
re += '\\' + c;
|
||||
i += 1;
|
||||
} else {
|
||||
re += c;
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
return new RegExp('^' + re + '$');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -351,16 +351,23 @@ Schema:
|
||||
|
||||
```json
|
||||
{
|
||||
"files": ["<path>", "<path>", ...],
|
||||
"files": ["<path-or-glob>", "<path-or-glob>", ...],
|
||||
"exclude": ["<optional-glob>", ...],
|
||||
"insertBefore": "</body>",
|
||||
"commentSyntax": "html",
|
||||
"cspChecked": true
|
||||
}
|
||||
```
|
||||
|
||||
`files` is the inject target — **the HTML files the browser actually loads**, not necessarily source. Each entry is either a literal path (`"public/index.html"`) or a glob pattern (`"public/**/*.html"`). Tracked or generated doesn't matter here; wrap has its own generated-file guard and routes accepts through the fallback flow.
|
||||
|
||||
`exclude` (optional) is a list of glob patterns matching files to skip, even if a `files` glob would have included them. Use for email templates, demo fixtures, or any HTML that isn't a live page.
|
||||
|
||||
`cspChecked` tracks whether the CSP detection step below has already run. Absent on first setup; set to `true` after CSP is checked (whether patched, declined, or not needed).
|
||||
|
||||
`files` is the inject target — **the HTML files the browser actually loads**, not necessarily source. Tracked or generated doesn't matter here; wrap has its own generated-file guard and routes accepts through the fallback flow.
|
||||
**Hard-excluded paths (cannot be overridden).** `**/node_modules/**` and `**/.git/**` are never matched regardless of what the user writes. These are vendor/metadata directories and injecting into them would silently instrument third-party code.
|
||||
|
||||
**Glob syntax.** `**` matches any number of path segments (including zero), `*` matches any characters except `/`, `?` matches a single character except `/`. Paths are always relative to the project root with forward slashes.
|
||||
|
||||
| Framework | `files` | `insertBefore` | `commentSyntax` |
|
||||
|-----------|---------|----------------|-----------------|
|
||||
@@ -370,12 +377,42 @@ Schema:
|
||||
| Nuxt | `["app.vue"]` | `</body>` | `html` |
|
||||
| Svelte / SvelteKit | `["src/app.html"]` | `</body>` | `html` |
|
||||
| Astro | `[" <root layout .astro>"]` | `</body>` | `html` |
|
||||
| Multi-page (separate HTML per route) | Every HTML file the dev server serves — glob the output dir, e.g. `public/**/*.html` | `</body>` | `html` |
|
||||
| Multi-page (separate HTML per route) | `["public/**/*.html"]` — a glob covering the served directory | `</body>` | `html` |
|
||||
|
||||
Pick an anchor that exists in every file (`</body>` almost always works). Use `insertAfter` if the anchor should match **after** a specific line.
|
||||
|
||||
For multi-page sites, **prefer a glob over a literal file list**. New pages added later are picked up automatically on the next `live-inject.mjs` run; no config maintenance needed.
|
||||
|
||||
For multi-page sites whose pages are *rebuilt* by a generator (Astro, static-site generators, custom scripts like `build-sub-pages.js`), the inject survives only until the next regeneration. Re-run `live.mjs` after each build. Accept is unaffected — it writes to true source via the fallback flow.
|
||||
|
||||
### Drift-heal warning
|
||||
|
||||
On every `live.mjs` boot, after inject, the project is scanned for HTML files under common page-source roots (`public/`, `src/`, `app/`, `pages/`). If any exist that aren't covered by the resolved `files` list, the output includes a `configDrift` field:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"serverPort": 8400,
|
||||
"pageFiles": [ "..." ],
|
||||
"configDrift": {
|
||||
"orphans": ["public/new-section/index.html", "public/docs/new-command.html"],
|
||||
"orphanCount": 2,
|
||||
"hint": "2 HTML file(s) exist but aren't in config.files. Consider adding them, or use a glob pattern like \"public/**/*.html\"."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
When `configDrift` is present, surface it to the user once per session before entering the poll loop:
|
||||
|
||||
> Noticed N HTML file(s) in the project that aren't in `config.files`:
|
||||
>
|
||||
> - `public/new-section/index.html`
|
||||
> - `public/docs/new-command.html`
|
||||
>
|
||||
> Add them, or switch `files` to a glob like `["public/**/*.html"]` and let it track new pages automatically?
|
||||
|
||||
Don't auto-update the config — let the user decide. `configDrift` is `null` when there's no drift.
|
||||
|
||||
### CSP detection (first-time only)
|
||||
|
||||
If `config.cspChecked === true`, skip this entire section. You already asked this user once; the answer sticks.
|
||||
|
||||
@@ -22,6 +22,16 @@ const CONFIG_PATH = process.env.IMPECCABLE_LIVE_CONFIG || path.join(__dirname, '
|
||||
const MARKER_OPEN_TEXT = 'impeccable-live-start';
|
||||
const MARKER_CLOSE_TEXT = 'impeccable-live-end';
|
||||
|
||||
/**
|
||||
* Hard-excluded directory patterns. These are NEVER user-facing pages and
|
||||
* matching them would silently inject tracking scripts into third-party
|
||||
* code. The user cannot turn these off via config — they are the floor.
|
||||
*/
|
||||
const HARD_EXCLUDES = [
|
||||
'**/node_modules/**',
|
||||
'**/.git/**',
|
||||
];
|
||||
|
||||
export async function injectCli() {
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
@@ -71,8 +81,10 @@ Output (JSON):
|
||||
const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
|
||||
validateConfig(config);
|
||||
|
||||
const resolvedFiles = resolveFiles(process.cwd(), config);
|
||||
|
||||
if (args.includes('--remove')) {
|
||||
const results = config.files.map((relFile) => {
|
||||
const results = resolvedFiles.map((relFile) => {
|
||||
const absFile = path.resolve(process.cwd(), relFile);
|
||||
if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' };
|
||||
const content = fs.readFileSync(absFile, 'utf-8');
|
||||
@@ -93,7 +105,7 @@ Output (JSON):
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const results = config.files.map((relFile) => {
|
||||
const results = resolvedFiles.map((relFile) => {
|
||||
const absFile = path.resolve(process.cwd(), relFile);
|
||||
if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' };
|
||||
const content = fs.readFileSync(absFile, 'utf-8');
|
||||
@@ -108,6 +120,95 @@ Output (JSON):
|
||||
if (!anyInserted) process.exit(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand config.files (which may contain glob patterns) into a literal list
|
||||
* of existing file paths relative to rootDir. Literal entries pass through;
|
||||
* glob patterns are expanded via fs.globSync. HARD_EXCLUDES and config.exclude
|
||||
* are applied as filters. Duplicates are removed. Order is preserved by
|
||||
* first appearance.
|
||||
*/
|
||||
export function resolveFiles(rootDir, config) {
|
||||
const patterns = config.files;
|
||||
const userExcludes = Array.isArray(config.exclude) ? config.exclude : [];
|
||||
const allExcludes = [...HARD_EXCLUDES, ...userExcludes];
|
||||
const excludeRegexes = allExcludes.map(globToRegex);
|
||||
|
||||
const isExcluded = (relPath) => excludeRegexes.some((re) => re.test(relPath));
|
||||
const isGlob = (s) => /[*?[]/.test(s);
|
||||
|
||||
const seen = new Set();
|
||||
const out = [];
|
||||
for (const pat of patterns) {
|
||||
if (!isGlob(pat)) {
|
||||
// Literal path — include even if it doesn't exist yet; the caller
|
||||
// reports file_not_found per-entry. Exclude list doesn't apply to
|
||||
// explicit literal entries (user named it on purpose).
|
||||
if (!seen.has(pat)) {
|
||||
seen.add(pat);
|
||||
out.push(pat);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let matches;
|
||||
try {
|
||||
matches = fs.globSync(pat, { cwd: rootDir, withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const ent of matches) {
|
||||
if (!ent.isFile || !ent.isFile()) continue;
|
||||
const abs = path.join(ent.parentPath || ent.path || rootDir, ent.name);
|
||||
const rel = path.relative(rootDir, abs).split(path.sep).join('/');
|
||||
if (isExcluded(rel)) continue;
|
||||
if (seen.has(rel)) continue;
|
||||
seen.add(rel);
|
||||
out.push(rel);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a glob pattern to a RegExp. Supports:
|
||||
* ** → any number of path segments (including zero)
|
||||
* * → any chars except `/`
|
||||
* ? → any single char except `/`
|
||||
* Paths are normalized to forward slashes before matching.
|
||||
*/
|
||||
function globToRegex(pattern) {
|
||||
let re = '';
|
||||
let i = 0;
|
||||
while (i < pattern.length) {
|
||||
const c = pattern[i];
|
||||
if (c === '*') {
|
||||
if (pattern[i + 1] === '*') {
|
||||
// ** — any number of segments, including zero. Handle the common
|
||||
// **/ and /** forms so `a/**/b` matches `a/b` as well as `a/x/y/b`.
|
||||
if (pattern[i + 2] === '/') {
|
||||
re += '(?:.*/)?';
|
||||
i += 3;
|
||||
} else {
|
||||
re += '.*';
|
||||
i += 2;
|
||||
}
|
||||
} else {
|
||||
re += '[^/]*';
|
||||
i += 1;
|
||||
}
|
||||
} else if (c === '?') {
|
||||
re += '[^/]';
|
||||
i += 1;
|
||||
} else if (/[.+^${}()|[\]\\]/.test(c)) {
|
||||
re += '\\' + c;
|
||||
i += 1;
|
||||
} else {
|
||||
re += c;
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
return new RegExp('^' + re + '$');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Core operations
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -120,6 +221,14 @@ function validateConfig(cfg) {
|
||||
if (!cfg.files.every((f) => typeof f === 'string' && f.length > 0)) {
|
||||
throw new Error('config.files must contain only non-empty strings');
|
||||
}
|
||||
if (cfg.exclude !== undefined) {
|
||||
if (!Array.isArray(cfg.exclude)) {
|
||||
throw new Error('config.exclude, if present, must be a string array');
|
||||
}
|
||||
if (!cfg.exclude.every((f) => typeof f === 'string' && f.length > 0)) {
|
||||
throw new Error('config.exclude must contain only non-empty strings');
|
||||
}
|
||||
}
|
||||
if (typeof cfg.insertBefore !== 'string' && typeof cfg.insertAfter !== 'string') {
|
||||
throw new Error('config.insertBefore or config.insertAfter (string) required');
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { loadContext } from './load-context.mjs';
|
||||
import { resolveFiles } from './live-inject.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const PID_FILE = path.join(process.cwd(), '.impeccable-live.json');
|
||||
@@ -82,12 +83,19 @@ The agent should then:
|
||||
// 4. Load PRODUCT.md + DESIGN.md context (auto-migrates legacy .impeccable.md)
|
||||
const ctx = loadContext(process.cwd());
|
||||
|
||||
// 5. Emit everything the agent needs
|
||||
// 5. Compute drift-heal: compare resolved inject targets against the
|
||||
// project's HTML files. Orphans are HTML files not covered by config.
|
||||
// Warning only — the agent decides whether to act.
|
||||
const resolvedFiles = resolveFiles(process.cwd(), checkResult.config);
|
||||
const drift = scanForDrift(process.cwd(), resolvedFiles, checkResult.config);
|
||||
|
||||
// 6. Emit everything the agent needs
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
serverPort: serverInfo.port,
|
||||
serverToken: serverInfo.token,
|
||||
pageFiles: checkResult.config.files,
|
||||
pageFiles: resolvedFiles,
|
||||
configDrift: drift,
|
||||
hasProduct: ctx.hasProduct,
|
||||
product: ctx.product,
|
||||
productPath: ctx.productPath,
|
||||
@@ -98,6 +106,98 @@ The agent should then:
|
||||
}, null, 2));
|
||||
}
|
||||
|
||||
/**
|
||||
* Drift-heal scan. Walks the project for HTML files under common
|
||||
* page-source directories (public/, src/, app/, pages/) and reports any
|
||||
* that aren't covered by the resolved inject targets. This is purely
|
||||
* advisory — the agent can ignore it, or suggest the user add the
|
||||
* orphans to config.files.
|
||||
*
|
||||
* Skipped if config.files already contains at least one glob pattern
|
||||
* covering everything in practice (signaled by the orphan count being 0).
|
||||
*/
|
||||
function scanForDrift(rootDir, resolvedFiles, config) {
|
||||
const SCAN_ROOTS = ['public', 'src', 'app', 'pages'];
|
||||
const IGNORE_DIRS = new Set([
|
||||
'node_modules', '.git', '.next', '.nuxt', '.svelte-kit', '.astro',
|
||||
'.turbo', '.vercel', '.cache', 'coverage', 'dist', 'build',
|
||||
]);
|
||||
|
||||
const resolvedSet = new Set(resolvedFiles.map((f) => f.split(path.sep).join('/')));
|
||||
|
||||
// Files matching the user's `exclude` globs are intentional omissions,
|
||||
// not drift. Compile them to regexes so the orphan list stays signal.
|
||||
const userExcludeRegexes = (Array.isArray(config.exclude) ? config.exclude : [])
|
||||
.map((p) => globToRegex(p));
|
||||
const isUserExcluded = (rel) => userExcludeRegexes.some((re) => re.test(rel));
|
||||
|
||||
const orphans = [];
|
||||
|
||||
const walk = (dir, relBase) => {
|
||||
let entries;
|
||||
try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
|
||||
catch { return; }
|
||||
for (const e of entries) {
|
||||
const rel = relBase ? `${relBase}/${e.name}` : e.name;
|
||||
if (e.isDirectory()) {
|
||||
if (IGNORE_DIRS.has(e.name) || e.name.startsWith('.')) continue;
|
||||
walk(path.join(dir, e.name), rel);
|
||||
} else if (e.isFile() && e.name.endsWith('.html')) {
|
||||
if (resolvedSet.has(rel)) continue;
|
||||
if (isUserExcluded(rel)) continue;
|
||||
orphans.push(rel);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
for (const root of SCAN_ROOTS) {
|
||||
const abs = path.join(rootDir, root);
|
||||
if (fs.existsSync(abs) && fs.statSync(abs).isDirectory()) {
|
||||
walk(abs, root);
|
||||
}
|
||||
}
|
||||
|
||||
if (orphans.length === 0) return null;
|
||||
const capped = orphans.slice(0, 20);
|
||||
return {
|
||||
orphans: capped,
|
||||
orphanCount: orphans.length,
|
||||
hint: `${orphans.length} HTML file(s) exist but aren't in config.files. Consider adding them, or use a glob pattern like "public/**/*.html".`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Same glob-to-regex mapping used by live-inject.mjs. Kept inline here
|
||||
* to avoid a circular import (live-inject.mjs already imports nothing
|
||||
* from live.mjs). The two must stay in sync.
|
||||
*/
|
||||
function globToRegex(pattern) {
|
||||
let re = '';
|
||||
let i = 0;
|
||||
while (i < pattern.length) {
|
||||
const c = pattern[i];
|
||||
if (c === '*') {
|
||||
if (pattern[i + 1] === '*') {
|
||||
if (pattern[i + 2] === '/') { re += '(?:.*/)?'; i += 3; }
|
||||
else { re += '.*'; i += 2; }
|
||||
} else {
|
||||
re += '[^/]*';
|
||||
i += 1;
|
||||
}
|
||||
} else if (c === '?') {
|
||||
re += '[^/]';
|
||||
i += 1;
|
||||
} else if (/[.+^${}()|[\]\\]/.test(c)) {
|
||||
re += '\\' + c;
|
||||
i += 1;
|
||||
} else {
|
||||
re += c;
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
return new RegExp('^' + re + '$');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -351,16 +351,23 @@ Schema:
|
||||
|
||||
```json
|
||||
{
|
||||
"files": ["<path>", "<path>", ...],
|
||||
"files": ["<path-or-glob>", "<path-or-glob>", ...],
|
||||
"exclude": ["<optional-glob>", ...],
|
||||
"insertBefore": "</body>",
|
||||
"commentSyntax": "html",
|
||||
"cspChecked": true
|
||||
}
|
||||
```
|
||||
|
||||
`files` is the inject target — **the HTML files the browser actually loads**, not necessarily source. Each entry is either a literal path (`"public/index.html"`) or a glob pattern (`"public/**/*.html"`). Tracked or generated doesn't matter here; wrap has its own generated-file guard and routes accepts through the fallback flow.
|
||||
|
||||
`exclude` (optional) is a list of glob patterns matching files to skip, even if a `files` glob would have included them. Use for email templates, demo fixtures, or any HTML that isn't a live page.
|
||||
|
||||
`cspChecked` tracks whether the CSP detection step below has already run. Absent on first setup; set to `true` after CSP is checked (whether patched, declined, or not needed).
|
||||
|
||||
`files` is the inject target — **the HTML files the browser actually loads**, not necessarily source. Tracked or generated doesn't matter here; wrap has its own generated-file guard and routes accepts through the fallback flow.
|
||||
**Hard-excluded paths (cannot be overridden).** `**/node_modules/**` and `**/.git/**` are never matched regardless of what the user writes. These are vendor/metadata directories and injecting into them would silently instrument third-party code.
|
||||
|
||||
**Glob syntax.** `**` matches any number of path segments (including zero), `*` matches any characters except `/`, `?` matches a single character except `/`. Paths are always relative to the project root with forward slashes.
|
||||
|
||||
| Framework | `files` | `insertBefore` | `commentSyntax` |
|
||||
|-----------|---------|----------------|-----------------|
|
||||
@@ -370,12 +377,42 @@ Schema:
|
||||
| Nuxt | `["app.vue"]` | `</body>` | `html` |
|
||||
| Svelte / SvelteKit | `["src/app.html"]` | `</body>` | `html` |
|
||||
| Astro | `[" <root layout .astro>"]` | `</body>` | `html` |
|
||||
| Multi-page (separate HTML per route) | Every HTML file the dev server serves — glob the output dir, e.g. `public/**/*.html` | `</body>` | `html` |
|
||||
| Multi-page (separate HTML per route) | `["public/**/*.html"]` — a glob covering the served directory | `</body>` | `html` |
|
||||
|
||||
Pick an anchor that exists in every file (`</body>` almost always works). Use `insertAfter` if the anchor should match **after** a specific line.
|
||||
|
||||
For multi-page sites, **prefer a glob over a literal file list**. New pages added later are picked up automatically on the next `live-inject.mjs` run; no config maintenance needed.
|
||||
|
||||
For multi-page sites whose pages are *rebuilt* by a generator (Astro, static-site generators, custom scripts like `build-sub-pages.js`), the inject survives only until the next regeneration. Re-run `live.mjs` after each build. Accept is unaffected — it writes to true source via the fallback flow.
|
||||
|
||||
### Drift-heal warning
|
||||
|
||||
On every `live.mjs` boot, after inject, the project is scanned for HTML files under common page-source roots (`public/`, `src/`, `app/`, `pages/`). If any exist that aren't covered by the resolved `files` list, the output includes a `configDrift` field:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"serverPort": 8400,
|
||||
"pageFiles": [ "..." ],
|
||||
"configDrift": {
|
||||
"orphans": ["public/new-section/index.html", "public/docs/new-command.html"],
|
||||
"orphanCount": 2,
|
||||
"hint": "2 HTML file(s) exist but aren't in config.files. Consider adding them, or use a glob pattern like \"public/**/*.html\"."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
When `configDrift` is present, surface it to the user once per session before entering the poll loop:
|
||||
|
||||
> Noticed N HTML file(s) in the project that aren't in `config.files`:
|
||||
>
|
||||
> - `public/new-section/index.html`
|
||||
> - `public/docs/new-command.html`
|
||||
>
|
||||
> Add them, or switch `files` to a glob like `["public/**/*.html"]` and let it track new pages automatically?
|
||||
|
||||
Don't auto-update the config — let the user decide. `configDrift` is `null` when there's no drift.
|
||||
|
||||
### CSP detection (first-time only)
|
||||
|
||||
If `config.cspChecked === true`, skip this entire section. You already asked this user once; the answer sticks.
|
||||
|
||||
@@ -22,6 +22,16 @@ const CONFIG_PATH = process.env.IMPECCABLE_LIVE_CONFIG || path.join(__dirname, '
|
||||
const MARKER_OPEN_TEXT = 'impeccable-live-start';
|
||||
const MARKER_CLOSE_TEXT = 'impeccable-live-end';
|
||||
|
||||
/**
|
||||
* Hard-excluded directory patterns. These are NEVER user-facing pages and
|
||||
* matching them would silently inject tracking scripts into third-party
|
||||
* code. The user cannot turn these off via config — they are the floor.
|
||||
*/
|
||||
const HARD_EXCLUDES = [
|
||||
'**/node_modules/**',
|
||||
'**/.git/**',
|
||||
];
|
||||
|
||||
export async function injectCli() {
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
@@ -71,8 +81,10 @@ Output (JSON):
|
||||
const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
|
||||
validateConfig(config);
|
||||
|
||||
const resolvedFiles = resolveFiles(process.cwd(), config);
|
||||
|
||||
if (args.includes('--remove')) {
|
||||
const results = config.files.map((relFile) => {
|
||||
const results = resolvedFiles.map((relFile) => {
|
||||
const absFile = path.resolve(process.cwd(), relFile);
|
||||
if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' };
|
||||
const content = fs.readFileSync(absFile, 'utf-8');
|
||||
@@ -93,7 +105,7 @@ Output (JSON):
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const results = config.files.map((relFile) => {
|
||||
const results = resolvedFiles.map((relFile) => {
|
||||
const absFile = path.resolve(process.cwd(), relFile);
|
||||
if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' };
|
||||
const content = fs.readFileSync(absFile, 'utf-8');
|
||||
@@ -108,6 +120,95 @@ Output (JSON):
|
||||
if (!anyInserted) process.exit(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand config.files (which may contain glob patterns) into a literal list
|
||||
* of existing file paths relative to rootDir. Literal entries pass through;
|
||||
* glob patterns are expanded via fs.globSync. HARD_EXCLUDES and config.exclude
|
||||
* are applied as filters. Duplicates are removed. Order is preserved by
|
||||
* first appearance.
|
||||
*/
|
||||
export function resolveFiles(rootDir, config) {
|
||||
const patterns = config.files;
|
||||
const userExcludes = Array.isArray(config.exclude) ? config.exclude : [];
|
||||
const allExcludes = [...HARD_EXCLUDES, ...userExcludes];
|
||||
const excludeRegexes = allExcludes.map(globToRegex);
|
||||
|
||||
const isExcluded = (relPath) => excludeRegexes.some((re) => re.test(relPath));
|
||||
const isGlob = (s) => /[*?[]/.test(s);
|
||||
|
||||
const seen = new Set();
|
||||
const out = [];
|
||||
for (const pat of patterns) {
|
||||
if (!isGlob(pat)) {
|
||||
// Literal path — include even if it doesn't exist yet; the caller
|
||||
// reports file_not_found per-entry. Exclude list doesn't apply to
|
||||
// explicit literal entries (user named it on purpose).
|
||||
if (!seen.has(pat)) {
|
||||
seen.add(pat);
|
||||
out.push(pat);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let matches;
|
||||
try {
|
||||
matches = fs.globSync(pat, { cwd: rootDir, withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const ent of matches) {
|
||||
if (!ent.isFile || !ent.isFile()) continue;
|
||||
const abs = path.join(ent.parentPath || ent.path || rootDir, ent.name);
|
||||
const rel = path.relative(rootDir, abs).split(path.sep).join('/');
|
||||
if (isExcluded(rel)) continue;
|
||||
if (seen.has(rel)) continue;
|
||||
seen.add(rel);
|
||||
out.push(rel);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a glob pattern to a RegExp. Supports:
|
||||
* ** → any number of path segments (including zero)
|
||||
* * → any chars except `/`
|
||||
* ? → any single char except `/`
|
||||
* Paths are normalized to forward slashes before matching.
|
||||
*/
|
||||
function globToRegex(pattern) {
|
||||
let re = '';
|
||||
let i = 0;
|
||||
while (i < pattern.length) {
|
||||
const c = pattern[i];
|
||||
if (c === '*') {
|
||||
if (pattern[i + 1] === '*') {
|
||||
// ** — any number of segments, including zero. Handle the common
|
||||
// **/ and /** forms so `a/**/b` matches `a/b` as well as `a/x/y/b`.
|
||||
if (pattern[i + 2] === '/') {
|
||||
re += '(?:.*/)?';
|
||||
i += 3;
|
||||
} else {
|
||||
re += '.*';
|
||||
i += 2;
|
||||
}
|
||||
} else {
|
||||
re += '[^/]*';
|
||||
i += 1;
|
||||
}
|
||||
} else if (c === '?') {
|
||||
re += '[^/]';
|
||||
i += 1;
|
||||
} else if (/[.+^${}()|[\]\\]/.test(c)) {
|
||||
re += '\\' + c;
|
||||
i += 1;
|
||||
} else {
|
||||
re += c;
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
return new RegExp('^' + re + '$');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Core operations
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -120,6 +221,14 @@ function validateConfig(cfg) {
|
||||
if (!cfg.files.every((f) => typeof f === 'string' && f.length > 0)) {
|
||||
throw new Error('config.files must contain only non-empty strings');
|
||||
}
|
||||
if (cfg.exclude !== undefined) {
|
||||
if (!Array.isArray(cfg.exclude)) {
|
||||
throw new Error('config.exclude, if present, must be a string array');
|
||||
}
|
||||
if (!cfg.exclude.every((f) => typeof f === 'string' && f.length > 0)) {
|
||||
throw new Error('config.exclude must contain only non-empty strings');
|
||||
}
|
||||
}
|
||||
if (typeof cfg.insertBefore !== 'string' && typeof cfg.insertAfter !== 'string') {
|
||||
throw new Error('config.insertBefore or config.insertAfter (string) required');
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { loadContext } from './load-context.mjs';
|
||||
import { resolveFiles } from './live-inject.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const PID_FILE = path.join(process.cwd(), '.impeccable-live.json');
|
||||
@@ -82,12 +83,19 @@ The agent should then:
|
||||
// 4. Load PRODUCT.md + DESIGN.md context (auto-migrates legacy .impeccable.md)
|
||||
const ctx = loadContext(process.cwd());
|
||||
|
||||
// 5. Emit everything the agent needs
|
||||
// 5. Compute drift-heal: compare resolved inject targets against the
|
||||
// project's HTML files. Orphans are HTML files not covered by config.
|
||||
// Warning only — the agent decides whether to act.
|
||||
const resolvedFiles = resolveFiles(process.cwd(), checkResult.config);
|
||||
const drift = scanForDrift(process.cwd(), resolvedFiles, checkResult.config);
|
||||
|
||||
// 6. Emit everything the agent needs
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
serverPort: serverInfo.port,
|
||||
serverToken: serverInfo.token,
|
||||
pageFiles: checkResult.config.files,
|
||||
pageFiles: resolvedFiles,
|
||||
configDrift: drift,
|
||||
hasProduct: ctx.hasProduct,
|
||||
product: ctx.product,
|
||||
productPath: ctx.productPath,
|
||||
@@ -98,6 +106,98 @@ The agent should then:
|
||||
}, null, 2));
|
||||
}
|
||||
|
||||
/**
|
||||
* Drift-heal scan. Walks the project for HTML files under common
|
||||
* page-source directories (public/, src/, app/, pages/) and reports any
|
||||
* that aren't covered by the resolved inject targets. This is purely
|
||||
* advisory — the agent can ignore it, or suggest the user add the
|
||||
* orphans to config.files.
|
||||
*
|
||||
* Skipped if config.files already contains at least one glob pattern
|
||||
* covering everything in practice (signaled by the orphan count being 0).
|
||||
*/
|
||||
function scanForDrift(rootDir, resolvedFiles, config) {
|
||||
const SCAN_ROOTS = ['public', 'src', 'app', 'pages'];
|
||||
const IGNORE_DIRS = new Set([
|
||||
'node_modules', '.git', '.next', '.nuxt', '.svelte-kit', '.astro',
|
||||
'.turbo', '.vercel', '.cache', 'coverage', 'dist', 'build',
|
||||
]);
|
||||
|
||||
const resolvedSet = new Set(resolvedFiles.map((f) => f.split(path.sep).join('/')));
|
||||
|
||||
// Files matching the user's `exclude` globs are intentional omissions,
|
||||
// not drift. Compile them to regexes so the orphan list stays signal.
|
||||
const userExcludeRegexes = (Array.isArray(config.exclude) ? config.exclude : [])
|
||||
.map((p) => globToRegex(p));
|
||||
const isUserExcluded = (rel) => userExcludeRegexes.some((re) => re.test(rel));
|
||||
|
||||
const orphans = [];
|
||||
|
||||
const walk = (dir, relBase) => {
|
||||
let entries;
|
||||
try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
|
||||
catch { return; }
|
||||
for (const e of entries) {
|
||||
const rel = relBase ? `${relBase}/${e.name}` : e.name;
|
||||
if (e.isDirectory()) {
|
||||
if (IGNORE_DIRS.has(e.name) || e.name.startsWith('.')) continue;
|
||||
walk(path.join(dir, e.name), rel);
|
||||
} else if (e.isFile() && e.name.endsWith('.html')) {
|
||||
if (resolvedSet.has(rel)) continue;
|
||||
if (isUserExcluded(rel)) continue;
|
||||
orphans.push(rel);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
for (const root of SCAN_ROOTS) {
|
||||
const abs = path.join(rootDir, root);
|
||||
if (fs.existsSync(abs) && fs.statSync(abs).isDirectory()) {
|
||||
walk(abs, root);
|
||||
}
|
||||
}
|
||||
|
||||
if (orphans.length === 0) return null;
|
||||
const capped = orphans.slice(0, 20);
|
||||
return {
|
||||
orphans: capped,
|
||||
orphanCount: orphans.length,
|
||||
hint: `${orphans.length} HTML file(s) exist but aren't in config.files. Consider adding them, or use a glob pattern like "public/**/*.html".`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Same glob-to-regex mapping used by live-inject.mjs. Kept inline here
|
||||
* to avoid a circular import (live-inject.mjs already imports nothing
|
||||
* from live.mjs). The two must stay in sync.
|
||||
*/
|
||||
function globToRegex(pattern) {
|
||||
let re = '';
|
||||
let i = 0;
|
||||
while (i < pattern.length) {
|
||||
const c = pattern[i];
|
||||
if (c === '*') {
|
||||
if (pattern[i + 1] === '*') {
|
||||
if (pattern[i + 2] === '/') { re += '(?:.*/)?'; i += 3; }
|
||||
else { re += '.*'; i += 2; }
|
||||
} else {
|
||||
re += '[^/]*';
|
||||
i += 1;
|
||||
}
|
||||
} else if (c === '?') {
|
||||
re += '[^/]';
|
||||
i += 1;
|
||||
} else if (/[.+^${}()|[\]\\]/.test(c)) {
|
||||
re += '\\' + c;
|
||||
i += 1;
|
||||
} else {
|
||||
re += c;
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
return new RegExp('^' + re + '$');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -351,16 +351,23 @@ Schema:
|
||||
|
||||
```json
|
||||
{
|
||||
"files": ["<path>", "<path>", ...],
|
||||
"files": ["<path-or-glob>", "<path-or-glob>", ...],
|
||||
"exclude": ["<optional-glob>", ...],
|
||||
"insertBefore": "</body>",
|
||||
"commentSyntax": "html",
|
||||
"cspChecked": true
|
||||
}
|
||||
```
|
||||
|
||||
`files` is the inject target — **the HTML files the browser actually loads**, not necessarily source. Each entry is either a literal path (`"public/index.html"`) or a glob pattern (`"public/**/*.html"`). Tracked or generated doesn't matter here; wrap has its own generated-file guard and routes accepts through the fallback flow.
|
||||
|
||||
`exclude` (optional) is a list of glob patterns matching files to skip, even if a `files` glob would have included them. Use for email templates, demo fixtures, or any HTML that isn't a live page.
|
||||
|
||||
`cspChecked` tracks whether the CSP detection step below has already run. Absent on first setup; set to `true` after CSP is checked (whether patched, declined, or not needed).
|
||||
|
||||
`files` is the inject target — **the HTML files the browser actually loads**, not necessarily source. Tracked or generated doesn't matter here; wrap has its own generated-file guard and routes accepts through the fallback flow.
|
||||
**Hard-excluded paths (cannot be overridden).** `**/node_modules/**` and `**/.git/**` are never matched regardless of what the user writes. These are vendor/metadata directories and injecting into them would silently instrument third-party code.
|
||||
|
||||
**Glob syntax.** `**` matches any number of path segments (including zero), `*` matches any characters except `/`, `?` matches a single character except `/`. Paths are always relative to the project root with forward slashes.
|
||||
|
||||
| Framework | `files` | `insertBefore` | `commentSyntax` |
|
||||
|-----------|---------|----------------|-----------------|
|
||||
@@ -370,12 +377,42 @@ Schema:
|
||||
| Nuxt | `["app.vue"]` | `</body>` | `html` |
|
||||
| Svelte / SvelteKit | `["src/app.html"]` | `</body>` | `html` |
|
||||
| Astro | `[" <root layout .astro>"]` | `</body>` | `html` |
|
||||
| Multi-page (separate HTML per route) | Every HTML file the dev server serves — glob the output dir, e.g. `public/**/*.html` | `</body>` | `html` |
|
||||
| Multi-page (separate HTML per route) | `["public/**/*.html"]` — a glob covering the served directory | `</body>` | `html` |
|
||||
|
||||
Pick an anchor that exists in every file (`</body>` almost always works). Use `insertAfter` if the anchor should match **after** a specific line.
|
||||
|
||||
For multi-page sites, **prefer a glob over a literal file list**. New pages added later are picked up automatically on the next `live-inject.mjs` run; no config maintenance needed.
|
||||
|
||||
For multi-page sites whose pages are *rebuilt* by a generator (Astro, static-site generators, custom scripts like `build-sub-pages.js`), the inject survives only until the next regeneration. Re-run `live.mjs` after each build. Accept is unaffected — it writes to true source via the fallback flow.
|
||||
|
||||
### Drift-heal warning
|
||||
|
||||
On every `live.mjs` boot, after inject, the project is scanned for HTML files under common page-source roots (`public/`, `src/`, `app/`, `pages/`). If any exist that aren't covered by the resolved `files` list, the output includes a `configDrift` field:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"serverPort": 8400,
|
||||
"pageFiles": [ "..." ],
|
||||
"configDrift": {
|
||||
"orphans": ["public/new-section/index.html", "public/docs/new-command.html"],
|
||||
"orphanCount": 2,
|
||||
"hint": "2 HTML file(s) exist but aren't in config.files. Consider adding them, or use a glob pattern like \"public/**/*.html\"."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
When `configDrift` is present, surface it to the user once per session before entering the poll loop:
|
||||
|
||||
> Noticed N HTML file(s) in the project that aren't in `config.files`:
|
||||
>
|
||||
> - `public/new-section/index.html`
|
||||
> - `public/docs/new-command.html`
|
||||
>
|
||||
> Add them, or switch `files` to a glob like `["public/**/*.html"]` and let it track new pages automatically?
|
||||
|
||||
Don't auto-update the config — let the user decide. `configDrift` is `null` when there's no drift.
|
||||
|
||||
### CSP detection (first-time only)
|
||||
|
||||
If `config.cspChecked === true`, skip this entire section. You already asked this user once; the answer sticks.
|
||||
|
||||
@@ -22,6 +22,16 @@ const CONFIG_PATH = process.env.IMPECCABLE_LIVE_CONFIG || path.join(__dirname, '
|
||||
const MARKER_OPEN_TEXT = 'impeccable-live-start';
|
||||
const MARKER_CLOSE_TEXT = 'impeccable-live-end';
|
||||
|
||||
/**
|
||||
* Hard-excluded directory patterns. These are NEVER user-facing pages and
|
||||
* matching them would silently inject tracking scripts into third-party
|
||||
* code. The user cannot turn these off via config — they are the floor.
|
||||
*/
|
||||
const HARD_EXCLUDES = [
|
||||
'**/node_modules/**',
|
||||
'**/.git/**',
|
||||
];
|
||||
|
||||
export async function injectCli() {
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
@@ -71,8 +81,10 @@ Output (JSON):
|
||||
const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
|
||||
validateConfig(config);
|
||||
|
||||
const resolvedFiles = resolveFiles(process.cwd(), config);
|
||||
|
||||
if (args.includes('--remove')) {
|
||||
const results = config.files.map((relFile) => {
|
||||
const results = resolvedFiles.map((relFile) => {
|
||||
const absFile = path.resolve(process.cwd(), relFile);
|
||||
if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' };
|
||||
const content = fs.readFileSync(absFile, 'utf-8');
|
||||
@@ -93,7 +105,7 @@ Output (JSON):
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const results = config.files.map((relFile) => {
|
||||
const results = resolvedFiles.map((relFile) => {
|
||||
const absFile = path.resolve(process.cwd(), relFile);
|
||||
if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' };
|
||||
const content = fs.readFileSync(absFile, 'utf-8');
|
||||
@@ -108,6 +120,95 @@ Output (JSON):
|
||||
if (!anyInserted) process.exit(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand config.files (which may contain glob patterns) into a literal list
|
||||
* of existing file paths relative to rootDir. Literal entries pass through;
|
||||
* glob patterns are expanded via fs.globSync. HARD_EXCLUDES and config.exclude
|
||||
* are applied as filters. Duplicates are removed. Order is preserved by
|
||||
* first appearance.
|
||||
*/
|
||||
export function resolveFiles(rootDir, config) {
|
||||
const patterns = config.files;
|
||||
const userExcludes = Array.isArray(config.exclude) ? config.exclude : [];
|
||||
const allExcludes = [...HARD_EXCLUDES, ...userExcludes];
|
||||
const excludeRegexes = allExcludes.map(globToRegex);
|
||||
|
||||
const isExcluded = (relPath) => excludeRegexes.some((re) => re.test(relPath));
|
||||
const isGlob = (s) => /[*?[]/.test(s);
|
||||
|
||||
const seen = new Set();
|
||||
const out = [];
|
||||
for (const pat of patterns) {
|
||||
if (!isGlob(pat)) {
|
||||
// Literal path — include even if it doesn't exist yet; the caller
|
||||
// reports file_not_found per-entry. Exclude list doesn't apply to
|
||||
// explicit literal entries (user named it on purpose).
|
||||
if (!seen.has(pat)) {
|
||||
seen.add(pat);
|
||||
out.push(pat);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let matches;
|
||||
try {
|
||||
matches = fs.globSync(pat, { cwd: rootDir, withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const ent of matches) {
|
||||
if (!ent.isFile || !ent.isFile()) continue;
|
||||
const abs = path.join(ent.parentPath || ent.path || rootDir, ent.name);
|
||||
const rel = path.relative(rootDir, abs).split(path.sep).join('/');
|
||||
if (isExcluded(rel)) continue;
|
||||
if (seen.has(rel)) continue;
|
||||
seen.add(rel);
|
||||
out.push(rel);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a glob pattern to a RegExp. Supports:
|
||||
* ** → any number of path segments (including zero)
|
||||
* * → any chars except `/`
|
||||
* ? → any single char except `/`
|
||||
* Paths are normalized to forward slashes before matching.
|
||||
*/
|
||||
function globToRegex(pattern) {
|
||||
let re = '';
|
||||
let i = 0;
|
||||
while (i < pattern.length) {
|
||||
const c = pattern[i];
|
||||
if (c === '*') {
|
||||
if (pattern[i + 1] === '*') {
|
||||
// ** — any number of segments, including zero. Handle the common
|
||||
// **/ and /** forms so `a/**/b` matches `a/b` as well as `a/x/y/b`.
|
||||
if (pattern[i + 2] === '/') {
|
||||
re += '(?:.*/)?';
|
||||
i += 3;
|
||||
} else {
|
||||
re += '.*';
|
||||
i += 2;
|
||||
}
|
||||
} else {
|
||||
re += '[^/]*';
|
||||
i += 1;
|
||||
}
|
||||
} else if (c === '?') {
|
||||
re += '[^/]';
|
||||
i += 1;
|
||||
} else if (/[.+^${}()|[\]\\]/.test(c)) {
|
||||
re += '\\' + c;
|
||||
i += 1;
|
||||
} else {
|
||||
re += c;
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
return new RegExp('^' + re + '$');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Core operations
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -120,6 +221,14 @@ function validateConfig(cfg) {
|
||||
if (!cfg.files.every((f) => typeof f === 'string' && f.length > 0)) {
|
||||
throw new Error('config.files must contain only non-empty strings');
|
||||
}
|
||||
if (cfg.exclude !== undefined) {
|
||||
if (!Array.isArray(cfg.exclude)) {
|
||||
throw new Error('config.exclude, if present, must be a string array');
|
||||
}
|
||||
if (!cfg.exclude.every((f) => typeof f === 'string' && f.length > 0)) {
|
||||
throw new Error('config.exclude must contain only non-empty strings');
|
||||
}
|
||||
}
|
||||
if (typeof cfg.insertBefore !== 'string' && typeof cfg.insertAfter !== 'string') {
|
||||
throw new Error('config.insertBefore or config.insertAfter (string) required');
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { loadContext } from './load-context.mjs';
|
||||
import { resolveFiles } from './live-inject.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const PID_FILE = path.join(process.cwd(), '.impeccable-live.json');
|
||||
@@ -82,12 +83,19 @@ The agent should then:
|
||||
// 4. Load PRODUCT.md + DESIGN.md context (auto-migrates legacy .impeccable.md)
|
||||
const ctx = loadContext(process.cwd());
|
||||
|
||||
// 5. Emit everything the agent needs
|
||||
// 5. Compute drift-heal: compare resolved inject targets against the
|
||||
// project's HTML files. Orphans are HTML files not covered by config.
|
||||
// Warning only — the agent decides whether to act.
|
||||
const resolvedFiles = resolveFiles(process.cwd(), checkResult.config);
|
||||
const drift = scanForDrift(process.cwd(), resolvedFiles, checkResult.config);
|
||||
|
||||
// 6. Emit everything the agent needs
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
serverPort: serverInfo.port,
|
||||
serverToken: serverInfo.token,
|
||||
pageFiles: checkResult.config.files,
|
||||
pageFiles: resolvedFiles,
|
||||
configDrift: drift,
|
||||
hasProduct: ctx.hasProduct,
|
||||
product: ctx.product,
|
||||
productPath: ctx.productPath,
|
||||
@@ -98,6 +106,98 @@ The agent should then:
|
||||
}, null, 2));
|
||||
}
|
||||
|
||||
/**
|
||||
* Drift-heal scan. Walks the project for HTML files under common
|
||||
* page-source directories (public/, src/, app/, pages/) and reports any
|
||||
* that aren't covered by the resolved inject targets. This is purely
|
||||
* advisory — the agent can ignore it, or suggest the user add the
|
||||
* orphans to config.files.
|
||||
*
|
||||
* Skipped if config.files already contains at least one glob pattern
|
||||
* covering everything in practice (signaled by the orphan count being 0).
|
||||
*/
|
||||
function scanForDrift(rootDir, resolvedFiles, config) {
|
||||
const SCAN_ROOTS = ['public', 'src', 'app', 'pages'];
|
||||
const IGNORE_DIRS = new Set([
|
||||
'node_modules', '.git', '.next', '.nuxt', '.svelte-kit', '.astro',
|
||||
'.turbo', '.vercel', '.cache', 'coverage', 'dist', 'build',
|
||||
]);
|
||||
|
||||
const resolvedSet = new Set(resolvedFiles.map((f) => f.split(path.sep).join('/')));
|
||||
|
||||
// Files matching the user's `exclude` globs are intentional omissions,
|
||||
// not drift. Compile them to regexes so the orphan list stays signal.
|
||||
const userExcludeRegexes = (Array.isArray(config.exclude) ? config.exclude : [])
|
||||
.map((p) => globToRegex(p));
|
||||
const isUserExcluded = (rel) => userExcludeRegexes.some((re) => re.test(rel));
|
||||
|
||||
const orphans = [];
|
||||
|
||||
const walk = (dir, relBase) => {
|
||||
let entries;
|
||||
try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
|
||||
catch { return; }
|
||||
for (const e of entries) {
|
||||
const rel = relBase ? `${relBase}/${e.name}` : e.name;
|
||||
if (e.isDirectory()) {
|
||||
if (IGNORE_DIRS.has(e.name) || e.name.startsWith('.')) continue;
|
||||
walk(path.join(dir, e.name), rel);
|
||||
} else if (e.isFile() && e.name.endsWith('.html')) {
|
||||
if (resolvedSet.has(rel)) continue;
|
||||
if (isUserExcluded(rel)) continue;
|
||||
orphans.push(rel);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
for (const root of SCAN_ROOTS) {
|
||||
const abs = path.join(rootDir, root);
|
||||
if (fs.existsSync(abs) && fs.statSync(abs).isDirectory()) {
|
||||
walk(abs, root);
|
||||
}
|
||||
}
|
||||
|
||||
if (orphans.length === 0) return null;
|
||||
const capped = orphans.slice(0, 20);
|
||||
return {
|
||||
orphans: capped,
|
||||
orphanCount: orphans.length,
|
||||
hint: `${orphans.length} HTML file(s) exist but aren't in config.files. Consider adding them, or use a glob pattern like "public/**/*.html".`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Same glob-to-regex mapping used by live-inject.mjs. Kept inline here
|
||||
* to avoid a circular import (live-inject.mjs already imports nothing
|
||||
* from live.mjs). The two must stay in sync.
|
||||
*/
|
||||
function globToRegex(pattern) {
|
||||
let re = '';
|
||||
let i = 0;
|
||||
while (i < pattern.length) {
|
||||
const c = pattern[i];
|
||||
if (c === '*') {
|
||||
if (pattern[i + 1] === '*') {
|
||||
if (pattern[i + 2] === '/') { re += '(?:.*/)?'; i += 3; }
|
||||
else { re += '.*'; i += 2; }
|
||||
} else {
|
||||
re += '[^/]*';
|
||||
i += 1;
|
||||
}
|
||||
} else if (c === '?') {
|
||||
re += '[^/]';
|
||||
i += 1;
|
||||
} else if (/[.+^${}()|[\]\\]/.test(c)) {
|
||||
re += '\\' + c;
|
||||
i += 1;
|
||||
} else {
|
||||
re += c;
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
return new RegExp('^' + re + '$');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -351,16 +351,23 @@ Schema:
|
||||
|
||||
```json
|
||||
{
|
||||
"files": ["<path>", "<path>", ...],
|
||||
"files": ["<path-or-glob>", "<path-or-glob>", ...],
|
||||
"exclude": ["<optional-glob>", ...],
|
||||
"insertBefore": "</body>",
|
||||
"commentSyntax": "html",
|
||||
"cspChecked": true
|
||||
}
|
||||
```
|
||||
|
||||
`files` is the inject target — **the HTML files the browser actually loads**, not necessarily source. Each entry is either a literal path (`"public/index.html"`) or a glob pattern (`"public/**/*.html"`). Tracked or generated doesn't matter here; wrap has its own generated-file guard and routes accepts through the fallback flow.
|
||||
|
||||
`exclude` (optional) is a list of glob patterns matching files to skip, even if a `files` glob would have included them. Use for email templates, demo fixtures, or any HTML that isn't a live page.
|
||||
|
||||
`cspChecked` tracks whether the CSP detection step below has already run. Absent on first setup; set to `true` after CSP is checked (whether patched, declined, or not needed).
|
||||
|
||||
`files` is the inject target — **the HTML files the browser actually loads**, not necessarily source. Tracked or generated doesn't matter here; wrap has its own generated-file guard and routes accepts through the fallback flow.
|
||||
**Hard-excluded paths (cannot be overridden).** `**/node_modules/**` and `**/.git/**` are never matched regardless of what the user writes. These are vendor/metadata directories and injecting into them would silently instrument third-party code.
|
||||
|
||||
**Glob syntax.** `**` matches any number of path segments (including zero), `*` matches any characters except `/`, `?` matches a single character except `/`. Paths are always relative to the project root with forward slashes.
|
||||
|
||||
| Framework | `files` | `insertBefore` | `commentSyntax` |
|
||||
|-----------|---------|----------------|-----------------|
|
||||
@@ -370,12 +377,42 @@ Schema:
|
||||
| Nuxt | `["app.vue"]` | `</body>` | `html` |
|
||||
| Svelte / SvelteKit | `["src/app.html"]` | `</body>` | `html` |
|
||||
| Astro | `[" <root layout .astro>"]` | `</body>` | `html` |
|
||||
| Multi-page (separate HTML per route) | Every HTML file the dev server serves — glob the output dir, e.g. `public/**/*.html` | `</body>` | `html` |
|
||||
| Multi-page (separate HTML per route) | `["public/**/*.html"]` — a glob covering the served directory | `</body>` | `html` |
|
||||
|
||||
Pick an anchor that exists in every file (`</body>` almost always works). Use `insertAfter` if the anchor should match **after** a specific line.
|
||||
|
||||
For multi-page sites, **prefer a glob over a literal file list**. New pages added later are picked up automatically on the next `live-inject.mjs` run; no config maintenance needed.
|
||||
|
||||
For multi-page sites whose pages are *rebuilt* by a generator (Astro, static-site generators, custom scripts like `build-sub-pages.js`), the inject survives only until the next regeneration. Re-run `live.mjs` after each build. Accept is unaffected — it writes to true source via the fallback flow.
|
||||
|
||||
### Drift-heal warning
|
||||
|
||||
On every `live.mjs` boot, after inject, the project is scanned for HTML files under common page-source roots (`public/`, `src/`, `app/`, `pages/`). If any exist that aren't covered by the resolved `files` list, the output includes a `configDrift` field:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"serverPort": 8400,
|
||||
"pageFiles": [ "..." ],
|
||||
"configDrift": {
|
||||
"orphans": ["public/new-section/index.html", "public/docs/new-command.html"],
|
||||
"orphanCount": 2,
|
||||
"hint": "2 HTML file(s) exist but aren't in config.files. Consider adding them, or use a glob pattern like \"public/**/*.html\"."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
When `configDrift` is present, surface it to the user once per session before entering the poll loop:
|
||||
|
||||
> Noticed N HTML file(s) in the project that aren't in `config.files`:
|
||||
>
|
||||
> - `public/new-section/index.html`
|
||||
> - `public/docs/new-command.html`
|
||||
>
|
||||
> Add them, or switch `files` to a glob like `["public/**/*.html"]` and let it track new pages automatically?
|
||||
|
||||
Don't auto-update the config — let the user decide. `configDrift` is `null` when there's no drift.
|
||||
|
||||
### CSP detection (first-time only)
|
||||
|
||||
If `config.cspChecked === true`, skip this entire section. You already asked this user once; the answer sticks.
|
||||
|
||||
@@ -22,6 +22,16 @@ const CONFIG_PATH = process.env.IMPECCABLE_LIVE_CONFIG || path.join(__dirname, '
|
||||
const MARKER_OPEN_TEXT = 'impeccable-live-start';
|
||||
const MARKER_CLOSE_TEXT = 'impeccable-live-end';
|
||||
|
||||
/**
|
||||
* Hard-excluded directory patterns. These are NEVER user-facing pages and
|
||||
* matching them would silently inject tracking scripts into third-party
|
||||
* code. The user cannot turn these off via config — they are the floor.
|
||||
*/
|
||||
const HARD_EXCLUDES = [
|
||||
'**/node_modules/**',
|
||||
'**/.git/**',
|
||||
];
|
||||
|
||||
export async function injectCli() {
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
@@ -71,8 +81,10 @@ Output (JSON):
|
||||
const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
|
||||
validateConfig(config);
|
||||
|
||||
const resolvedFiles = resolveFiles(process.cwd(), config);
|
||||
|
||||
if (args.includes('--remove')) {
|
||||
const results = config.files.map((relFile) => {
|
||||
const results = resolvedFiles.map((relFile) => {
|
||||
const absFile = path.resolve(process.cwd(), relFile);
|
||||
if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' };
|
||||
const content = fs.readFileSync(absFile, 'utf-8');
|
||||
@@ -93,7 +105,7 @@ Output (JSON):
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const results = config.files.map((relFile) => {
|
||||
const results = resolvedFiles.map((relFile) => {
|
||||
const absFile = path.resolve(process.cwd(), relFile);
|
||||
if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' };
|
||||
const content = fs.readFileSync(absFile, 'utf-8');
|
||||
@@ -108,6 +120,95 @@ Output (JSON):
|
||||
if (!anyInserted) process.exit(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand config.files (which may contain glob patterns) into a literal list
|
||||
* of existing file paths relative to rootDir. Literal entries pass through;
|
||||
* glob patterns are expanded via fs.globSync. HARD_EXCLUDES and config.exclude
|
||||
* are applied as filters. Duplicates are removed. Order is preserved by
|
||||
* first appearance.
|
||||
*/
|
||||
export function resolveFiles(rootDir, config) {
|
||||
const patterns = config.files;
|
||||
const userExcludes = Array.isArray(config.exclude) ? config.exclude : [];
|
||||
const allExcludes = [...HARD_EXCLUDES, ...userExcludes];
|
||||
const excludeRegexes = allExcludes.map(globToRegex);
|
||||
|
||||
const isExcluded = (relPath) => excludeRegexes.some((re) => re.test(relPath));
|
||||
const isGlob = (s) => /[*?[]/.test(s);
|
||||
|
||||
const seen = new Set();
|
||||
const out = [];
|
||||
for (const pat of patterns) {
|
||||
if (!isGlob(pat)) {
|
||||
// Literal path — include even if it doesn't exist yet; the caller
|
||||
// reports file_not_found per-entry. Exclude list doesn't apply to
|
||||
// explicit literal entries (user named it on purpose).
|
||||
if (!seen.has(pat)) {
|
||||
seen.add(pat);
|
||||
out.push(pat);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let matches;
|
||||
try {
|
||||
matches = fs.globSync(pat, { cwd: rootDir, withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const ent of matches) {
|
||||
if (!ent.isFile || !ent.isFile()) continue;
|
||||
const abs = path.join(ent.parentPath || ent.path || rootDir, ent.name);
|
||||
const rel = path.relative(rootDir, abs).split(path.sep).join('/');
|
||||
if (isExcluded(rel)) continue;
|
||||
if (seen.has(rel)) continue;
|
||||
seen.add(rel);
|
||||
out.push(rel);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a glob pattern to a RegExp. Supports:
|
||||
* ** → any number of path segments (including zero)
|
||||
* * → any chars except `/`
|
||||
* ? → any single char except `/`
|
||||
* Paths are normalized to forward slashes before matching.
|
||||
*/
|
||||
function globToRegex(pattern) {
|
||||
let re = '';
|
||||
let i = 0;
|
||||
while (i < pattern.length) {
|
||||
const c = pattern[i];
|
||||
if (c === '*') {
|
||||
if (pattern[i + 1] === '*') {
|
||||
// ** — any number of segments, including zero. Handle the common
|
||||
// **/ and /** forms so `a/**/b` matches `a/b` as well as `a/x/y/b`.
|
||||
if (pattern[i + 2] === '/') {
|
||||
re += '(?:.*/)?';
|
||||
i += 3;
|
||||
} else {
|
||||
re += '.*';
|
||||
i += 2;
|
||||
}
|
||||
} else {
|
||||
re += '[^/]*';
|
||||
i += 1;
|
||||
}
|
||||
} else if (c === '?') {
|
||||
re += '[^/]';
|
||||
i += 1;
|
||||
} else if (/[.+^${}()|[\]\\]/.test(c)) {
|
||||
re += '\\' + c;
|
||||
i += 1;
|
||||
} else {
|
||||
re += c;
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
return new RegExp('^' + re + '$');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Core operations
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -120,6 +221,14 @@ function validateConfig(cfg) {
|
||||
if (!cfg.files.every((f) => typeof f === 'string' && f.length > 0)) {
|
||||
throw new Error('config.files must contain only non-empty strings');
|
||||
}
|
||||
if (cfg.exclude !== undefined) {
|
||||
if (!Array.isArray(cfg.exclude)) {
|
||||
throw new Error('config.exclude, if present, must be a string array');
|
||||
}
|
||||
if (!cfg.exclude.every((f) => typeof f === 'string' && f.length > 0)) {
|
||||
throw new Error('config.exclude must contain only non-empty strings');
|
||||
}
|
||||
}
|
||||
if (typeof cfg.insertBefore !== 'string' && typeof cfg.insertAfter !== 'string') {
|
||||
throw new Error('config.insertBefore or config.insertAfter (string) required');
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { loadContext } from './load-context.mjs';
|
||||
import { resolveFiles } from './live-inject.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const PID_FILE = path.join(process.cwd(), '.impeccable-live.json');
|
||||
@@ -82,12 +83,19 @@ The agent should then:
|
||||
// 4. Load PRODUCT.md + DESIGN.md context (auto-migrates legacy .impeccable.md)
|
||||
const ctx = loadContext(process.cwd());
|
||||
|
||||
// 5. Emit everything the agent needs
|
||||
// 5. Compute drift-heal: compare resolved inject targets against the
|
||||
// project's HTML files. Orphans are HTML files not covered by config.
|
||||
// Warning only — the agent decides whether to act.
|
||||
const resolvedFiles = resolveFiles(process.cwd(), checkResult.config);
|
||||
const drift = scanForDrift(process.cwd(), resolvedFiles, checkResult.config);
|
||||
|
||||
// 6. Emit everything the agent needs
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
serverPort: serverInfo.port,
|
||||
serverToken: serverInfo.token,
|
||||
pageFiles: checkResult.config.files,
|
||||
pageFiles: resolvedFiles,
|
||||
configDrift: drift,
|
||||
hasProduct: ctx.hasProduct,
|
||||
product: ctx.product,
|
||||
productPath: ctx.productPath,
|
||||
@@ -98,6 +106,98 @@ The agent should then:
|
||||
}, null, 2));
|
||||
}
|
||||
|
||||
/**
|
||||
* Drift-heal scan. Walks the project for HTML files under common
|
||||
* page-source directories (public/, src/, app/, pages/) and reports any
|
||||
* that aren't covered by the resolved inject targets. This is purely
|
||||
* advisory — the agent can ignore it, or suggest the user add the
|
||||
* orphans to config.files.
|
||||
*
|
||||
* Skipped if config.files already contains at least one glob pattern
|
||||
* covering everything in practice (signaled by the orphan count being 0).
|
||||
*/
|
||||
function scanForDrift(rootDir, resolvedFiles, config) {
|
||||
const SCAN_ROOTS = ['public', 'src', 'app', 'pages'];
|
||||
const IGNORE_DIRS = new Set([
|
||||
'node_modules', '.git', '.next', '.nuxt', '.svelte-kit', '.astro',
|
||||
'.turbo', '.vercel', '.cache', 'coverage', 'dist', 'build',
|
||||
]);
|
||||
|
||||
const resolvedSet = new Set(resolvedFiles.map((f) => f.split(path.sep).join('/')));
|
||||
|
||||
// Files matching the user's `exclude` globs are intentional omissions,
|
||||
// not drift. Compile them to regexes so the orphan list stays signal.
|
||||
const userExcludeRegexes = (Array.isArray(config.exclude) ? config.exclude : [])
|
||||
.map((p) => globToRegex(p));
|
||||
const isUserExcluded = (rel) => userExcludeRegexes.some((re) => re.test(rel));
|
||||
|
||||
const orphans = [];
|
||||
|
||||
const walk = (dir, relBase) => {
|
||||
let entries;
|
||||
try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
|
||||
catch { return; }
|
||||
for (const e of entries) {
|
||||
const rel = relBase ? `${relBase}/${e.name}` : e.name;
|
||||
if (e.isDirectory()) {
|
||||
if (IGNORE_DIRS.has(e.name) || e.name.startsWith('.')) continue;
|
||||
walk(path.join(dir, e.name), rel);
|
||||
} else if (e.isFile() && e.name.endsWith('.html')) {
|
||||
if (resolvedSet.has(rel)) continue;
|
||||
if (isUserExcluded(rel)) continue;
|
||||
orphans.push(rel);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
for (const root of SCAN_ROOTS) {
|
||||
const abs = path.join(rootDir, root);
|
||||
if (fs.existsSync(abs) && fs.statSync(abs).isDirectory()) {
|
||||
walk(abs, root);
|
||||
}
|
||||
}
|
||||
|
||||
if (orphans.length === 0) return null;
|
||||
const capped = orphans.slice(0, 20);
|
||||
return {
|
||||
orphans: capped,
|
||||
orphanCount: orphans.length,
|
||||
hint: `${orphans.length} HTML file(s) exist but aren't in config.files. Consider adding them, or use a glob pattern like "public/**/*.html".`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Same glob-to-regex mapping used by live-inject.mjs. Kept inline here
|
||||
* to avoid a circular import (live-inject.mjs already imports nothing
|
||||
* from live.mjs). The two must stay in sync.
|
||||
*/
|
||||
function globToRegex(pattern) {
|
||||
let re = '';
|
||||
let i = 0;
|
||||
while (i < pattern.length) {
|
||||
const c = pattern[i];
|
||||
if (c === '*') {
|
||||
if (pattern[i + 1] === '*') {
|
||||
if (pattern[i + 2] === '/') { re += '(?:.*/)?'; i += 3; }
|
||||
else { re += '.*'; i += 2; }
|
||||
} else {
|
||||
re += '[^/]*';
|
||||
i += 1;
|
||||
}
|
||||
} else if (c === '?') {
|
||||
re += '[^/]';
|
||||
i += 1;
|
||||
} else if (/[.+^${}()|[\]\\]/.test(c)) {
|
||||
re += '\\' + c;
|
||||
i += 1;
|
||||
} else {
|
||||
re += c;
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
return new RegExp('^' + re + '$');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
+13
-2
@@ -147,11 +147,22 @@ export function readSourceFiles(rootDir) {
|
||||
}
|
||||
}
|
||||
|
||||
// Read script files if they exist
|
||||
// Read script files if they exist.
|
||||
//
|
||||
// Per-project artifacts (state files that belong to the consuming
|
||||
// project, not the distributable skill) must be excluded here so
|
||||
// the build never bundles them into the skill that ships to users.
|
||||
// - config.json: the live-mode inject-target list for the current
|
||||
// project. Written by the agent at first /impeccable live; tied
|
||||
// to the project's filesystem layout.
|
||||
const PER_PROJECT_ARTIFACTS = new Set(['config.json']);
|
||||
const scripts = [];
|
||||
const scriptsDir = path.join(entryPath, 'scripts');
|
||||
if (fs.existsSync(scriptsDir)) {
|
||||
const scriptFiles = fs.readdirSync(scriptsDir).filter(f => fs.statSync(path.join(scriptsDir, f)).isFile());
|
||||
const scriptFiles = fs.readdirSync(scriptsDir).filter(f => {
|
||||
if (PER_PROJECT_ARTIFACTS.has(f)) return false;
|
||||
return fs.statSync(path.join(scriptsDir, f)).isFile();
|
||||
});
|
||||
for (const scriptFile of scriptFiles) {
|
||||
const scriptPath = path.join(scriptsDir, scriptFile);
|
||||
const scriptContent = fs.readFileSync(scriptPath, 'utf-8');
|
||||
|
||||
@@ -351,16 +351,23 @@ Schema:
|
||||
|
||||
```json
|
||||
{
|
||||
"files": ["<path>", "<path>", ...],
|
||||
"files": ["<path-or-glob>", "<path-or-glob>", ...],
|
||||
"exclude": ["<optional-glob>", ...],
|
||||
"insertBefore": "</body>",
|
||||
"commentSyntax": "html",
|
||||
"cspChecked": true
|
||||
}
|
||||
```
|
||||
|
||||
`files` is the inject target — **the HTML files the browser actually loads**, not necessarily source. Each entry is either a literal path (`"public/index.html"`) or a glob pattern (`"public/**/*.html"`). Tracked or generated doesn't matter here; wrap has its own generated-file guard and routes accepts through the fallback flow.
|
||||
|
||||
`exclude` (optional) is a list of glob patterns matching files to skip, even if a `files` glob would have included them. Use for email templates, demo fixtures, or any HTML that isn't a live page.
|
||||
|
||||
`cspChecked` tracks whether the CSP detection step below has already run. Absent on first setup; set to `true` after CSP is checked (whether patched, declined, or not needed).
|
||||
|
||||
`files` is the inject target — **the HTML files the browser actually loads**, not necessarily source. Tracked or generated doesn't matter here; wrap has its own generated-file guard and routes accepts through the fallback flow.
|
||||
**Hard-excluded paths (cannot be overridden).** `**/node_modules/**` and `**/.git/**` are never matched regardless of what the user writes. These are vendor/metadata directories and injecting into them would silently instrument third-party code.
|
||||
|
||||
**Glob syntax.** `**` matches any number of path segments (including zero), `*` matches any characters except `/`, `?` matches a single character except `/`. Paths are always relative to the project root with forward slashes.
|
||||
|
||||
| Framework | `files` | `insertBefore` | `commentSyntax` |
|
||||
|-----------|---------|----------------|-----------------|
|
||||
@@ -370,12 +377,42 @@ Schema:
|
||||
| Nuxt | `["app.vue"]` | `</body>` | `html` |
|
||||
| Svelte / SvelteKit | `["src/app.html"]` | `</body>` | `html` |
|
||||
| Astro | `[" <root layout .astro>"]` | `</body>` | `html` |
|
||||
| Multi-page (separate HTML per route) | Every HTML file the dev server serves — glob the output dir, e.g. `public/**/*.html` | `</body>` | `html` |
|
||||
| Multi-page (separate HTML per route) | `["public/**/*.html"]` — a glob covering the served directory | `</body>` | `html` |
|
||||
|
||||
Pick an anchor that exists in every file (`</body>` almost always works). Use `insertAfter` if the anchor should match **after** a specific line.
|
||||
|
||||
For multi-page sites, **prefer a glob over a literal file list**. New pages added later are picked up automatically on the next `live-inject.mjs` run; no config maintenance needed.
|
||||
|
||||
For multi-page sites whose pages are *rebuilt* by a generator (Astro, static-site generators, custom scripts like `build-sub-pages.js`), the inject survives only until the next regeneration. Re-run `live.mjs` after each build. Accept is unaffected — it writes to true source via the fallback flow.
|
||||
|
||||
### Drift-heal warning
|
||||
|
||||
On every `live.mjs` boot, after inject, the project is scanned for HTML files under common page-source roots (`public/`, `src/`, `app/`, `pages/`). If any exist that aren't covered by the resolved `files` list, the output includes a `configDrift` field:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"serverPort": 8400,
|
||||
"pageFiles": [ "..." ],
|
||||
"configDrift": {
|
||||
"orphans": ["public/new-section/index.html", "public/docs/new-command.html"],
|
||||
"orphanCount": 2,
|
||||
"hint": "2 HTML file(s) exist but aren't in config.files. Consider adding them, or use a glob pattern like \"public/**/*.html\"."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
When `configDrift` is present, surface it to the user once per session before entering the poll loop:
|
||||
|
||||
> Noticed N HTML file(s) in the project that aren't in `config.files`:
|
||||
>
|
||||
> - `public/new-section/index.html`
|
||||
> - `public/docs/new-command.html`
|
||||
>
|
||||
> Add them, or switch `files` to a glob like `["public/**/*.html"]` and let it track new pages automatically?
|
||||
|
||||
Don't auto-update the config — let the user decide. `configDrift` is `null` when there's no drift.
|
||||
|
||||
### CSP detection (first-time only)
|
||||
|
||||
If `config.cspChecked === true`, skip this entire section. You already asked this user once; the answer sticks.
|
||||
|
||||
@@ -22,6 +22,16 @@ const CONFIG_PATH = process.env.IMPECCABLE_LIVE_CONFIG || path.join(__dirname, '
|
||||
const MARKER_OPEN_TEXT = 'impeccable-live-start';
|
||||
const MARKER_CLOSE_TEXT = 'impeccable-live-end';
|
||||
|
||||
/**
|
||||
* Hard-excluded directory patterns. These are NEVER user-facing pages and
|
||||
* matching them would silently inject tracking scripts into third-party
|
||||
* code. The user cannot turn these off via config — they are the floor.
|
||||
*/
|
||||
const HARD_EXCLUDES = [
|
||||
'**/node_modules/**',
|
||||
'**/.git/**',
|
||||
];
|
||||
|
||||
export async function injectCli() {
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
@@ -71,8 +81,10 @@ Output (JSON):
|
||||
const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
|
||||
validateConfig(config);
|
||||
|
||||
const resolvedFiles = resolveFiles(process.cwd(), config);
|
||||
|
||||
if (args.includes('--remove')) {
|
||||
const results = config.files.map((relFile) => {
|
||||
const results = resolvedFiles.map((relFile) => {
|
||||
const absFile = path.resolve(process.cwd(), relFile);
|
||||
if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' };
|
||||
const content = fs.readFileSync(absFile, 'utf-8');
|
||||
@@ -93,7 +105,7 @@ Output (JSON):
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const results = config.files.map((relFile) => {
|
||||
const results = resolvedFiles.map((relFile) => {
|
||||
const absFile = path.resolve(process.cwd(), relFile);
|
||||
if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' };
|
||||
const content = fs.readFileSync(absFile, 'utf-8');
|
||||
@@ -108,6 +120,95 @@ Output (JSON):
|
||||
if (!anyInserted) process.exit(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand config.files (which may contain glob patterns) into a literal list
|
||||
* of existing file paths relative to rootDir. Literal entries pass through;
|
||||
* glob patterns are expanded via fs.globSync. HARD_EXCLUDES and config.exclude
|
||||
* are applied as filters. Duplicates are removed. Order is preserved by
|
||||
* first appearance.
|
||||
*/
|
||||
export function resolveFiles(rootDir, config) {
|
||||
const patterns = config.files;
|
||||
const userExcludes = Array.isArray(config.exclude) ? config.exclude : [];
|
||||
const allExcludes = [...HARD_EXCLUDES, ...userExcludes];
|
||||
const excludeRegexes = allExcludes.map(globToRegex);
|
||||
|
||||
const isExcluded = (relPath) => excludeRegexes.some((re) => re.test(relPath));
|
||||
const isGlob = (s) => /[*?[]/.test(s);
|
||||
|
||||
const seen = new Set();
|
||||
const out = [];
|
||||
for (const pat of patterns) {
|
||||
if (!isGlob(pat)) {
|
||||
// Literal path — include even if it doesn't exist yet; the caller
|
||||
// reports file_not_found per-entry. Exclude list doesn't apply to
|
||||
// explicit literal entries (user named it on purpose).
|
||||
if (!seen.has(pat)) {
|
||||
seen.add(pat);
|
||||
out.push(pat);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let matches;
|
||||
try {
|
||||
matches = fs.globSync(pat, { cwd: rootDir, withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const ent of matches) {
|
||||
if (!ent.isFile || !ent.isFile()) continue;
|
||||
const abs = path.join(ent.parentPath || ent.path || rootDir, ent.name);
|
||||
const rel = path.relative(rootDir, abs).split(path.sep).join('/');
|
||||
if (isExcluded(rel)) continue;
|
||||
if (seen.has(rel)) continue;
|
||||
seen.add(rel);
|
||||
out.push(rel);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a glob pattern to a RegExp. Supports:
|
||||
* ** → any number of path segments (including zero)
|
||||
* * → any chars except `/`
|
||||
* ? → any single char except `/`
|
||||
* Paths are normalized to forward slashes before matching.
|
||||
*/
|
||||
function globToRegex(pattern) {
|
||||
let re = '';
|
||||
let i = 0;
|
||||
while (i < pattern.length) {
|
||||
const c = pattern[i];
|
||||
if (c === '*') {
|
||||
if (pattern[i + 1] === '*') {
|
||||
// ** — any number of segments, including zero. Handle the common
|
||||
// **/ and /** forms so `a/**/b` matches `a/b` as well as `a/x/y/b`.
|
||||
if (pattern[i + 2] === '/') {
|
||||
re += '(?:.*/)?';
|
||||
i += 3;
|
||||
} else {
|
||||
re += '.*';
|
||||
i += 2;
|
||||
}
|
||||
} else {
|
||||
re += '[^/]*';
|
||||
i += 1;
|
||||
}
|
||||
} else if (c === '?') {
|
||||
re += '[^/]';
|
||||
i += 1;
|
||||
} else if (/[.+^${}()|[\]\\]/.test(c)) {
|
||||
re += '\\' + c;
|
||||
i += 1;
|
||||
} else {
|
||||
re += c;
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
return new RegExp('^' + re + '$');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Core operations
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -120,6 +221,14 @@ function validateConfig(cfg) {
|
||||
if (!cfg.files.every((f) => typeof f === 'string' && f.length > 0)) {
|
||||
throw new Error('config.files must contain only non-empty strings');
|
||||
}
|
||||
if (cfg.exclude !== undefined) {
|
||||
if (!Array.isArray(cfg.exclude)) {
|
||||
throw new Error('config.exclude, if present, must be a string array');
|
||||
}
|
||||
if (!cfg.exclude.every((f) => typeof f === 'string' && f.length > 0)) {
|
||||
throw new Error('config.exclude must contain only non-empty strings');
|
||||
}
|
||||
}
|
||||
if (typeof cfg.insertBefore !== 'string' && typeof cfg.insertAfter !== 'string') {
|
||||
throw new Error('config.insertBefore or config.insertAfter (string) required');
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { loadContext } from './load-context.mjs';
|
||||
import { resolveFiles } from './live-inject.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const PID_FILE = path.join(process.cwd(), '.impeccable-live.json');
|
||||
@@ -82,12 +83,19 @@ The agent should then:
|
||||
// 4. Load PRODUCT.md + DESIGN.md context (auto-migrates legacy .impeccable.md)
|
||||
const ctx = loadContext(process.cwd());
|
||||
|
||||
// 5. Emit everything the agent needs
|
||||
// 5. Compute drift-heal: compare resolved inject targets against the
|
||||
// project's HTML files. Orphans are HTML files not covered by config.
|
||||
// Warning only — the agent decides whether to act.
|
||||
const resolvedFiles = resolveFiles(process.cwd(), checkResult.config);
|
||||
const drift = scanForDrift(process.cwd(), resolvedFiles, checkResult.config);
|
||||
|
||||
// 6. Emit everything the agent needs
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
serverPort: serverInfo.port,
|
||||
serverToken: serverInfo.token,
|
||||
pageFiles: checkResult.config.files,
|
||||
pageFiles: resolvedFiles,
|
||||
configDrift: drift,
|
||||
hasProduct: ctx.hasProduct,
|
||||
product: ctx.product,
|
||||
productPath: ctx.productPath,
|
||||
@@ -98,6 +106,98 @@ The agent should then:
|
||||
}, null, 2));
|
||||
}
|
||||
|
||||
/**
|
||||
* Drift-heal scan. Walks the project for HTML files under common
|
||||
* page-source directories (public/, src/, app/, pages/) and reports any
|
||||
* that aren't covered by the resolved inject targets. This is purely
|
||||
* advisory — the agent can ignore it, or suggest the user add the
|
||||
* orphans to config.files.
|
||||
*
|
||||
* Skipped if config.files already contains at least one glob pattern
|
||||
* covering everything in practice (signaled by the orphan count being 0).
|
||||
*/
|
||||
function scanForDrift(rootDir, resolvedFiles, config) {
|
||||
const SCAN_ROOTS = ['public', 'src', 'app', 'pages'];
|
||||
const IGNORE_DIRS = new Set([
|
||||
'node_modules', '.git', '.next', '.nuxt', '.svelte-kit', '.astro',
|
||||
'.turbo', '.vercel', '.cache', 'coverage', 'dist', 'build',
|
||||
]);
|
||||
|
||||
const resolvedSet = new Set(resolvedFiles.map((f) => f.split(path.sep).join('/')));
|
||||
|
||||
// Files matching the user's `exclude` globs are intentional omissions,
|
||||
// not drift. Compile them to regexes so the orphan list stays signal.
|
||||
const userExcludeRegexes = (Array.isArray(config.exclude) ? config.exclude : [])
|
||||
.map((p) => globToRegex(p));
|
||||
const isUserExcluded = (rel) => userExcludeRegexes.some((re) => re.test(rel));
|
||||
|
||||
const orphans = [];
|
||||
|
||||
const walk = (dir, relBase) => {
|
||||
let entries;
|
||||
try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
|
||||
catch { return; }
|
||||
for (const e of entries) {
|
||||
const rel = relBase ? `${relBase}/${e.name}` : e.name;
|
||||
if (e.isDirectory()) {
|
||||
if (IGNORE_DIRS.has(e.name) || e.name.startsWith('.')) continue;
|
||||
walk(path.join(dir, e.name), rel);
|
||||
} else if (e.isFile() && e.name.endsWith('.html')) {
|
||||
if (resolvedSet.has(rel)) continue;
|
||||
if (isUserExcluded(rel)) continue;
|
||||
orphans.push(rel);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
for (const root of SCAN_ROOTS) {
|
||||
const abs = path.join(rootDir, root);
|
||||
if (fs.existsSync(abs) && fs.statSync(abs).isDirectory()) {
|
||||
walk(abs, root);
|
||||
}
|
||||
}
|
||||
|
||||
if (orphans.length === 0) return null;
|
||||
const capped = orphans.slice(0, 20);
|
||||
return {
|
||||
orphans: capped,
|
||||
orphanCount: orphans.length,
|
||||
hint: `${orphans.length} HTML file(s) exist but aren't in config.files. Consider adding them, or use a glob pattern like "public/**/*.html".`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Same glob-to-regex mapping used by live-inject.mjs. Kept inline here
|
||||
* to avoid a circular import (live-inject.mjs already imports nothing
|
||||
* from live.mjs). The two must stay in sync.
|
||||
*/
|
||||
function globToRegex(pattern) {
|
||||
let re = '';
|
||||
let i = 0;
|
||||
while (i < pattern.length) {
|
||||
const c = pattern[i];
|
||||
if (c === '*') {
|
||||
if (pattern[i + 1] === '*') {
|
||||
if (pattern[i + 2] === '/') { re += '(?:.*/)?'; i += 3; }
|
||||
else { re += '.*'; i += 2; }
|
||||
} else {
|
||||
re += '[^/]*';
|
||||
i += 1;
|
||||
}
|
||||
} else if (c === '?') {
|
||||
re += '[^/]';
|
||||
i += 1;
|
||||
} else if (/[.+^${}()|[\]\\]/.test(c)) {
|
||||
re += '\\' + c;
|
||||
i += 1;
|
||||
} else {
|
||||
re += c;
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
return new RegExp('^' + re + '$');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user